//! A value argument — `set`'s value and `--seq` items, `insert`'s value,
//! `replace`'s replacement — read once as a fig value and rendered into whatever
//! format it lands in, the way a binding's `Value` is (CLI 5 §1). The format
//! decides the spelling, never the meaning: `fig set f version 5` writes the
//! number 5 into JSON, YAML and TOML alike, and `hello` is a string in all
//! three.
//!
//! Reading, by `Mode`:
//!
//! * `.fig` (the default) — the argument is what `v = <arg>` makes of it in
//! fig's own dialect: `5`, `2.5`, `true`, `null` and `2026-09-22` are
//! typed; `hello world`, `Yes` and `007` are strings (the last two with
//! the lint the dialect gives them); `[1, 2]` and `{a = 1}` are
//! structures; `'"5"'` is the string `5`. An empty argument, one with a
//! line break in it or space at either end, and one in which a `#`
//! would start a comment (`see #3`) are strings as written — a command
//! line has no comments, and fig's reading would trim or split them.
//! An argument that is none of these (`[1, 2`) is a usage error.
//! * `.string` (`--string`) — a string, whatever it looks like.
//! * `.raw` (`--raw`) — the argument's own text, spliced as source in the
//! target format and reparsed in place: for what only the target can
//! spell (a YAML anchor, a TOML local datetime, a ZON enum literal).
//!
//! Rendering (`render`) is `fig patch`'s: the value re-rooted as a fragment
//! with `splice` set, flow for fig, and the printer's newline trimmed — on
//! one line for JSON, and for any format when the site is inside a flow
//! collection (`Layout.flow`).
const std = @import("std");
const fig = @import("fig");
const build_options = @import("build_options");
const types = @import("types.zig");
const diag_report = @import("diag_report.zig");
const parse_dispatch = @import("parse_dispatch.zig");
const languages = @import("languages.zig");
const Format = types.Format;
const Io = std.Io;
pub const Mode = types.ValueMode;
/// A value argument as read: its tree, or (under `--raw`) its text.
pub const Value = union(enum) {
tree: fig.AST,
raw: []const u8,
};
/// What `v = ` puts in front of the argument to make it a fig document.
const prefix = "v = ";
/// Read `text` under `mode`. A `.fig` argument that is not a value is
/// reported against the argument itself and exits 2 — the command line is
/// wrong, not the file; its lints are printed to `term` and reading goes on.
pub fn read(allocator: std.mem.Allocator, term: *Io.Terminal, text: []const u8, mode: Mode) !Value {
return switch (mode) {
.raw => .{ .raw = text },
.string => .{ .tree = try stringTree(allocator, text) },
.fig => readFig(allocator, term, text),
};
}
fn readFig(allocator: std.mem.Allocator, term: *Io.Terminal, text: []const u8) !Value {
// Empty, blank, with a line break, or with space at either end: fig's
// reading would drop or split what the user typed, so it is a string as
// typed (`' x'` stays ` x`, where `v = x` would trim it).
const trimmed = std.mem.trim(u8, text, " \t");
if (trimmed.len == 0 or trimmed.len != text.len or std.mem.indexOfAny(u8, text, "\r\n") != null)
return .{ .tree = try stringTree(allocator, text) };
// Without the fig dialect compiled in, the language standing in for it
// reads the value, where there is one.
if (comptime !build_options.lang_fig) return readFigStandIn(allocator, term, text);
const source = try std.mem.concat(allocator, u8, &.{ prefix, text, "\n" });
var reports: parse_dispatch.Reports = .{};
const doc = parse_dispatch.parseSliceAs(.fig, .{}, allocator, source, false, &reports) catch |err| {
if (std.mem.indexOfScalar(u8, text, '#') != null) return .{ .tree = try stringTree(allocator, text) };
const d = reports.fig.diag orelse return err;
const P = fig.Language.entryFor("fig").Lang.Parser;
try diag_report.printDiag(term, text, "<value>", shift(d.offset, text), if (d.end) |e| shift(e, text) else null, "error", .red, P.describe(d.code), P.shortLabel(d.code));
try term.writer.print("help: this argument is read as a fig value; pass --string to take it as a string, or --raw to splice it as the file's own syntax\n", .{});
try term.writer.flush();
std.process.exit(2);
};
// A `#` the dialect took as a comment start was part of the argument.
if (doc.ast.node_comments.len > 0) return .{ .tree = try stringTree(allocator, text) };
const W = fig.Language.entryFor("fig").Lang.Parser.Warning;
for (reports.fig.warnings) |w|
try diag_report.printDiag(term, text, "<value>", shift(w.offset, text), if (w.end) |e| shift(e, text) else null, "warning", .yellow, W.describeWarning(w.code), W.shortLabel(w.code));
try term.writer.flush();
var ast = doc.ast;
ast.root = (try ast.getValByPath(&.{.{ .key = "v" }})).id;
return .{ .tree = ast };
}
/// `readFig` through the language standing in for fig (`languages.standIn`)
/// in a build without fig: its refusal is reported against the argument
/// as the compiled dialect's is, and with no language for fig at all every
/// argument is a string, the one reading that needs no parser.
fn readFigStandIn(allocator: std.mem.Allocator, term: *Io.Terminal, text: []const u8) !Value {
const format = languages.standIn(.fig);
if (types.runtimeEntry(format) == null) return .{ .tree = try stringTree(allocator, text) };
const source = try std.mem.concat(allocator, u8, &.{ prefix, text, "\n" });
var reports: parse_dispatch.Reports = .{};
const doc = parse_dispatch.parseSliceAs(format, .{}, allocator, source, false, &reports) catch |err| {
if (std.mem.indexOfScalar(u8, text, '#') != null) return .{ .tree = try stringTree(allocator, text) };
const d = reports.runtime orelse return err;
try diag_report.printDiag(term, text, "<value>", shift(d.offset, text), if (d.end) |e| shift(e, text) else null, "error", .red, d.message, d.short_label);
try term.writer.print("help: this argument is read as a fig value; pass --string to take it as a string, or --raw to splice it as the file's own syntax\n", .{});
try term.writer.flush();
std.process.exit(2);
};
if (doc.ast.node_comments.len > 0) return .{ .tree = try stringTree(allocator, text) };
var ast = doc.ast;
ast.root = (try ast.getValByPath(&.{.{ .key = "v" }})).id;
return .{ .tree = ast };
}
/// An offset into `v = <text>\n`, as an offset into `text`.
fn shift(offset: usize, text: []const u8) usize {
return @min(offset -| prefix.len, text.len);
}
fn stringTree(allocator: std.mem.Allocator, text: []const u8) !fig.AST {
var b = fig.AST.Builder.init(allocator);
const id = try b.addString(text);
return b.finish(id);
}
/// How a structure is laid out: the format's own layout, or on one line.
pub const Layout = enum {
/// A YAML block, a TOML inline table, a ZON literal across lines: what
/// the format writes for a value of its own. JSON is compact either way,
/// since a value spliced into an object cannot span lines.
natural,
/// On one line, for a site inside a flow collection (`k: {a: 1}` in
/// YAML), where a block spelling cannot land. YAML's is JSON's, which
/// every YAML reads as flow.
flow,
};
/// The splice text for `value` in `format` — what the editor takes for it.
/// A value the format has no spelling for (`null` in TOML, a table in
/// dotenv) is `error.Unwritable*`, for the caller to report.
pub fn render(allocator: std.mem.Allocator, value: Value, format: Format, layout: Layout) ![]const u8 {
const ast = switch (value) {
.raw => |text| return text,
.tree => |*t| t,
};
var w: std.Io.Writer.Allocating = .init(allocator);
defer w.deinit();
const one_line = layout == .flow or switch (format) {
.json, .jsonc, .json5 => true,
else => false,
};
const options: fig.AST.SerializeOptions = .{
// What the editor takes, not a document — see `SerializeOptions.splice`.
.splice = true,
.pretty = !one_line,
// fig's block spellings only parse as standalone lines; a value
// spliced after `key = ` has to be flow.
.flow = format == .fig,
};
if (types.runtimeEntry(format)) |e| {
try fig.Runtime.printNodeWith(e, &w.writer, ast, ast.root, options);
} else {
const target: fig.AST.SerializeFormat = if (layout == .flow and format == .yaml)
.json
else
types.toSerializeFormat(format) orelse return error.UnsupportedValueTarget;
// Renamed, so a value the format cannot hold is never mistaken for a
// parse error the file raised under the same name (INI's and TOML's
// parsers raise `InvalidKey` on a malformed file).
ast.serializeFragmentWith(&w.writer, target, options) catch |err| return switch (err) {
error.NullUnsupported => error.UnwritableNull,
error.UnsupportedValue => error.UnwritableNested,
error.NonStringKey, error.InvalidKey => error.UnwritableKey,
else => err,
};
}
// Every printer ends a document with a newline; a spliced value never
// carries one (an appended flow item would land on a line of its own).
return allocator.dupe(u8, std.mem.trimEnd(u8, w.written(), "\n"));
}
/// Refuse a value `format` cannot hold once it lands `depth` levels below the
/// document root — a mapping into dotenv, a table under an INI section —
/// with the error `render` gives one the printer refuses outright. The
/// printer cannot tell on its own: a mapping is a fine dotenv DOCUMENT, and
/// prints as `x=1`, which spliced after `n=` reads back as the string `x=1`.
/// Text with no value behind it (`--raw`) is the user's own spelling and is
/// not second-guessed.
pub fn refuseDropped(allocator: std.mem.Allocator, value: Value, format: Format, depth: usize) !void {
const ast = switch (value) {
.raw => return,
.tree => |*t| t,
};
const target: fig.Runtime.Target = if (types.runtimeEntry(format)) |e|
.{ .runtime = e }
else
.{ .compiled = types.toSerializeFormat(format) orelse return };
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const dropped = try fig.Diagnostics.firstDropped(arena.allocator(), ast, ast.root, target, depth) orelse return;
return switch (dropped) {
.null_ => error.UnwritableNull,
.container => error.UnwritableNested,
};
}
test "a fig reading types what fig types and keeps the rest a string" {
if (comptime !build_options.lang_fig or !build_options.lang_json) return error.SkipZigTest;
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
var sink: std.Io.Writer.Discarding = .init(&.{});
var term: Io.Terminal = .{ .writer = &sink.writer, .mode = .no_color };
const cases = [_][2][]const u8{
.{ "5", "5" },
.{ "-2.5", "-2.5" },
.{ "true", "true" },
.{ "null", "null" },
.{ "hello", "\"hello\"" },
.{ "hello world", "\"hello world\"" },
.{ "a: b", "\"a: b\"" },
.{ "\"5\"", "\"5\"" },
.{ "'q'", "\"q\"" },
.{ "see #3", "\"see #3\"" },
.{ "#3", "\"#3\"" },
.{ "", "\"\"" },
.{ "hello ", "\"hello \"" },
.{ " 5", "\" 5\"" },
.{ "two\nlines", "\"two\\nlines\"" },
.{ "[1, 2]", "[1,2]" },
.{ "{a = 1, b = [x]}", "{\"a\":1,\"b\":[\"x\"]}" },
};
for (cases) |c|
try t.expectEqualStrings(c[1], try render(a, try read(a, &term, c[0], .fig), .json, .natural));
}
test "--string and --raw take the argument as it stands" {
if (comptime !build_options.lang_json) return error.SkipZigTest;
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
var sink: std.Io.Writer.Discarding = .init(&.{});
var term: Io.Terminal = .{ .writer = &sink.writer, .mode = .no_color };
try t.expectEqualStrings("\"1.10\"", try render(a, try read(a, &term, "1.10", .string), .json, .natural));
try t.expectEqualStrings("[1,2", try render(a, try read(a, &term, "[1,2", .raw), .json, .natural));
}