//! The body of each CLI action — one function per `CliAction`, called from
//! `main.zig`'s thin dispatch switch. Each function takes the arena
//! allocator, the process `Io`, the stdout/stderr terminals, and its own
//! action's option struct (from `types.zig`); together they're exactly the
//! free variables the original monolithic `main()` switch closed over.
const std = @import("std");
const fig = @import("fig");
const build_options = @import("build_options");
const gron = @import("gron.zig");
const diff = @import("diff.zig");
const types = @import("types.zig");
const help = @import("help.zig");
const args_mod = @import("args.zig");
const fileio = @import("fileio.zig");
const diag_report = @import("diag_report.zig");
const parse_dispatch = @import("parse_dispatch.zig");
const edit_ops = @import("edit_ops.zig");
const reformat = @import("reformat.zig");
const Help = help.Help;
const Format = types.Format;
const EditOp = types.EditOp;
const append_index = types.append_index;
const Io = std.Io;
pub fn runHelp(stderr_term: *Io.Terminal, binary_name: []const u8) !void {
try stderr_term.writer.print(help.title_string, .{});
try Help.general(stderr_term, binary_name);
}
/// Print the CLI's own version alongside the core library version it embeds
/// and its marketing epoch — two independent SemVer tracks (see
/// docs/VERSIONING.md; a CLI-only breaking change bumps `cli_version`
/// without requiring a `core_version`/ABI release, and vice versa) plus one
/// purely cosmetic label (`epoch` has no compatibility meaning — it's the
/// core's marketing name, not a version number).
pub fn runVersion(stdout_term: *Io.Terminal, cli_version: []const u8, core_version: []const u8, epoch: []const u8) !void {
try stdout_term.writer.print("fig {s} (core {s} \"{s}\")\n", .{ cli_version, core_version, epoch });
try stdout_term.writer.flush();
}
pub fn runEdit(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, binary_name: []const u8, opts: types.EditOptions) !void {
if (opts.requested_help) {
try Help.edit(stdout_term, binary_name);
return;
}
const input = try fileio.getInput(io, opts.file, .read_write);
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
const op: EditOp = if (opts.key) .replace_key else .replace_value;
if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| {
try edit_ops.applyToEmbed(a, io, input, embed_type, opts.path, opts.replacement, op);
} else switch (if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format) {
.json, .jsonc => |f| if (comptime build_options.lang_json) {
const replacement = try std.fmt.allocPrint(a, "\"{s}\"", .{opts.replacement});
try edit_ops.applyToFile(fig.Language.JSON, a, io, input, opts.path, replacement, op, edit_ops.jsonDialect(f));
} else return error.FormatDisabled,
.yaml, .yml => if (comptime build_options.lang_yaml) {
try edit_ops.applyToFile(fig.Language.YAML, a, io, input, opts.path, opts.replacement, op, fig.Language.YAML.default_type);
} else return error.FormatDisabled,
// TOML value/key replacement: a value or key node has a tight,
// contiguous span (the parser's node_spans point at the original
// source bytes), so the generic span-splice editor handles it
// even when the owning table is assembled from scattered headers.
// The replacement is taken verbatim as a TOML literal, like YAML
// and ZON. (Structural inserts/deletes that must place text
// relative to a scattered table are still unsupported.)
.toml => if (comptime build_options.lang_toml)
try edit_ops.applyToFile(fig.Language.TOML, a, io, input, opts.path, opts.replacement, op, fig.Language.TOML.default_type)
else
return error.FormatDisabled,
// ZON edits take the replacement verbatim (a literal ZON value),
// like YAML — the editor splices and reparses it.
.zon => if (comptime build_options.lang_zon)
try edit_ops.applyToFile(fig.Language.ZON, a, io, input, opts.path, opts.replacement, op, fig.Language.ZON.default_type)
else
return error.FormatDisabled,
// XML is reader-only: no in-place editor yet.
.xml => return error.UnsupportedXmlEdit,
// JSON5 is read/serialize only so far; comment-preserving
// in-place editing of it is not wired yet.
.json5 => return error.UnsupportedJson5Edit,
// The canonical form is a parse/print pair with no span-splicing
// editor; convert via `get` instead of editing in place.
.canonical => return error.UnsupportedCanonicalEdit,
// fig value/key replacement: `Editor(Fig)` splices the exact
// node span (`Fig.Parser` now tracks real spans — see
// `fig/parser.zig`'s "AST assembly" section), same as TOML.
// The replacement is taken verbatim as a fig literal.
.fig => if (comptime build_options.lang_fig)
try edit_ops.applyToFile(fig.Language.FIG, a, io, input, opts.path, opts.replacement, op, fig.Language.FIG.default_type)
else
return error.FormatDisabled,
// gron is a CLI-only get/echo format with no in-place editor.
.gron => return error.UnsupportedGronEdit,
}
}
pub fn runSet(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.SetOptions) !void {
if (opts.requested_help) {
try Help.set(stdout_term, binary_name);
return;
}
if (opts.path.len == 0) {
try stderr_term.writer.print("error: set needs a path to the key (or, with --seq, the sequence) to upsert.\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
}
// `set` upserts, so it may target a file that doesn't exist yet:
// create it and seed a minimal valid empty document (see
// `createSeededFile`/`emptyDocSeed`) so the editor lands the first
// key into a parseable buffer. The other structural actions keep the
// plain open — they edit what's already there and have nothing to
// seed into a blank file. Two cases refuse the create up front,
// before any file lands on disk:
// - a freshly created file is empty, so its format can't be sniffed
// (`--detect`): it must carry a known extension;
// - a format with no empty-document form (`emptyDocSeed` == null,
// e.g. fig) has nothing valid to seed.
// When targeting an embed, the host is seeded empty ("") and the
// embed machinery (`initRegion`) synthesizes the inner block itself.
var created = false;
const input = fileio.getInput(io, opts.file, .read_write) catch |err| switch (err) {
error.FileNotFound => blk: {
if (std.mem.eql(u8, opts.file, "-")) return err;
if (opts.detect) {
try stderr_term.writer.print("error: cannot create {s}: an unrecognized extension gives no format to seed. Use a known extension (.json/.jsonc/.yaml/.toml/.zon) or an existing file.\n", .{opts.file});
try stderr_term.writer.flush();
std.process.exit(2);
}
const seed: []const u8 = if (opts.embed != null or opts.detect_embed) "" else edit_ops.emptyDocSeed(opts.format) orelse {
try stderr_term.writer.print("error: cannot create {s}: {s} has no empty-document form to seed a new file. Start from an existing file.\n", .{ opts.file, @tagName(opts.format) });
try stderr_term.writer.flush();
std.process.exit(2);
};
created = true;
break :blk try fileio.createSeededFile(io, opts.file, seed);
},
else => return err,
};
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
// `--seq` reconciles the sequence at `path`; otherwise upsert a scalar.
// Both flow through the shared structural-edit router, so the embed
// path (which open-or-inits a missing block) is reused for free.
const op: EditOp = if (opts.seq) .{ .set_sequence = opts.values } else .set;
const text: []const u8 = if (opts.seq) "" else opts.value;
// On a from-scratch create, roll the new file back if the edit fails
// (e.g. a path whose parent segment resolves to a scalar, which `set`
// refuses rather than clobber) so a failed `set` leaves no bare seed
// behind — matching how it leaves an existing file untouched on
// failure. A merely-missing parent map is no longer a failure: `set`
// auto-vivifies it (see `Editor.set`).
const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path, text, op) catch |err| {
if (created) fileio.deleteCreatedFile(io, opts.file);
return err;
};
}
pub fn runInsert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.InsertOptions) !void {
if (opts.requested_help) {
try Help.insert(stdout_term, binary_name);
return;
}
const input = try fileio.getInput(io, opts.file, .read_write);
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
// The destination is the *parent* container plus the trailing slot.
// A trailing key inserts into a mapping; a trailing index pre/appends
// to a sequence. An empty parent is the root container.
if (opts.path.len == 0) {
try stderr_term.writer.print("error: insert needs a destination path (e.g. a.b.newkey or list[-]).\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
}
const parent = opts.path[0 .. opts.path.len - 1];
const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
switch (opts.path[opts.path.len - 1]) {
.key => |key| try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, parent, opts.value, .{ .insert_key = key }),
.index => |index| {
// The editor only prepends or appends; an addressable middle
// index has no primitive, so reject it rather than guess.
const op: EditOp = if (index == 0)
.prepend_seq
else if (index == append_index)
.append_seq
else {
try stderr_term.writer.print("error: sequence insert supports only [0] (prepend) or [-]/[$] (append); mid-sequence insert is not yet available.\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
};
try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, parent, opts.value, op);
},
}
}
pub fn runDelete(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.DeleteOptions) !void {
if (opts.requested_help) {
try Help.delete(stdout_term, binary_name);
return;
}
const input = try fileio.getInput(io, opts.file, .read_write);
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
if (opts.path.len == 0) {
try stderr_term.writer.print("error: delete needs a path to the entry or item to remove.\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
}
const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
// A trailing index removes that item from the parent sequence; a
// trailing key deletes the mapping entry named by the full path.
switch (opts.path[opts.path.len - 1]) {
.index => |index| try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path[0 .. opts.path.len - 1], "", .{ .remove_seq_item = index }),
.key => try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path, "", .delete_key),
}
}
pub fn runGet(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.GetOptions) !void {
if (opts.requested_help) {
try Help.get(stdout_term, binary_name);
return;
}
const input = try fileio.getInput(io, opts.file, .read_only);
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
// `--body`: print the host prose OUTSIDE the fences (the region's
// `body` span) — the complement of extracting the embed content. With
// no such region the whole file is the body.
if (opts.body) {
const content = try fileio.readAll(a, io, input);
const embed_type = args_mod.resolveEmbedTypeFromContent(content, opts.embed, opts.detect_embed) orelse fig.Embed.Type.FrontmatterYaml;
if (fig.Embed.locateRegion(content, embed_type)) |region| {
try stdout_term.writer.writeAll(content[region.body.start..region.body.end]);
} else |err| switch (err) {
error.NotFound => try stdout_term.writer.writeAll(content),
else => return err,
}
try stdout_term.writer.flush();
return;
}
// Resolved input/output formats. They equal the parsed options unless
// the input format has to be sniffed from the file's contents (no
// `--input`, unrecognized extension): detection overwrites `from`, and
// — when no `--output` was given — `to` follows it (an echo round-trip
// rather than a silent convert-to-JSON).
var from = opts.from;
var to = opts.to;
const doc = if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| blk_embed: {
// `embed_type` may have just been sniffed at runtime
// (`detect_embed`), in which case the parse-time
// placeholder `from`/`to` (the extension's guess, not the
// real archetype) needs correcting: the inner format
// always follows the archetype outright, and — same as
// the whole-file `detect` echo below — an unpinned
// output follows it too rather than silently defaulting
// to something else (e.g. `.yaml` for a `.md` file whose
// actual frontmatter turned out to be fig or JSON).
from = args_mod.embedFormat(embed_type);
if (!opts.output_explicit) to = from;
break :blk_embed try edit_ops.parseEmbeddedFromFile(a, io, input, embed_type);
} else blk: {
// Read once so detection and parsing share the same bytes — a
// piped stdin can only be consumed a single time.
const content = try fileio.readAll(a, io, input);
if (opts.detect) {
from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);
if (!opts.output_explicit) to = from;
}
var fig_report: fig.Language.FIG.Parser.Report = .{};
var json_report: fig.Language.JSON.Parser.Report = .{};
var toml_report: fig.Language.TOML.Parser.Report = .{};
const parsed = parse_dispatch.parseSliceAs(from, .{}, a, content, false, .{ .fig = &fig_report, .json = &json_report, .toml = &toml_report }) catch |err| {
// A parse failure renders as a `file:line:col` teaching
// message (DESIGN.md: every diagnostic names the fix) and
// exits cleanly — no error-return trace for a user typo.
// Only fig/JSON/TOML fill a report so far; every other
// format still falls through to the bare `return err`.
if (fig_report.diag) |d|
try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.FIG.Parser.describe(d.code), fig.Language.FIG.Parser.shortLabel(d.code));
if (json_report.diag) |d|
try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.JSON.Parser.describe(d.code), fig.Language.JSON.Parser.shortLabel(d.code));
if (toml_report.diag) |d|
try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.TOML.Parser.describe(d.code), fig.Language.TOML.Parser.shortLabel(d.code));
return err;
};
// Authoring-time lints (parse-time warnings) ride the same
// `--quiet`/`--strict` contract as the serialize-side
// diagnostics below: quiet silences, strict aborts.
try diag_report.handleParseWarnings(stderr_term, content, opts.file, "fig authoring", fig_report.warnings, fig.Language.FIG.Parser.Warning.describeWarning, fig.Language.FIG.Parser.Warning.shortLabel, opts.quiet, opts.strict);
try diag_report.handleParseWarnings(stderr_term, content, opts.file, "JSON authoring", json_report.warnings, fig.Language.JSON.Parser.Warning.describeWarning, fig.Language.JSON.Parser.Warning.shortLabel, opts.quiet, opts.strict);
try diag_report.handleParseWarnings(stderr_term, content, opts.file, "TOML authoring", toml_report.warnings, fig.Language.TOML.Parser.Warning.describeWarning, fig.Language.TOML.Parser.Warning.shortLabel, opts.quiet, opts.strict);
break :blk parsed;
};
// Converting YAML to a non-YAML format resolves the reference layer
// first (aliases → copies, merges → flattened, tags applied/dropped).
// YAML→YAML keeps it intact for round-trip; JSON never has it.
const src_is_yaml = from == .yaml or from == .yml;
const dst_is_yaml = to == .yaml or to == .yml;
const base_ast: *const fig.AST = if (src_is_yaml and !dst_is_yaml) blk: {
// Reachable only when the source is YAML, so YAML is compiled in;
// the comptime guard keeps `Language.YAML` out of the gated build.
if (comptime build_options.lang_yaml) {
const mode: fig.Language.YAML.TagMode = if (opts.lax_tags) .lax else .strict;
const mat = try a.create(fig.AST);
mat.* = try fig.Language.YAML.materialize(a, &doc.ast, mode);
break :blk mat;
} else unreachable;
} else &doc.ast;
// Lossless mode: decode any `$fig` envelopes in the input back to
// their real node kinds, then re-encode for the target format. Skipped
// for YAML→YAML, whose reference layer (anchors/tags) lives in
// side-tables the core-AST passes would strip — and which round-trips
// losslessly already. The passes operate on a core AST, so any
// non-YAML source (or a materialized YAML source) is safe.
const ast: *const fig.AST = if (opts.lossless and !(src_is_yaml and dst_is_yaml)) blk: {
const maybe_target: ?fig.Lossless.Target = switch (to) {
// JSON5 reuses the JSON envelope target. It could hold
// Infinity/NaN natively, so this is conservative (those ride
// in a `$fig` envelope) but still fully lossless.
// gron's value layer is JSON, so it shares the JSON envelope
// target: an unrepresentable value (a TOML datetime, etc.)
// rides in a `$fig` envelope that prints as a JSON object.
.json, .jsonc, .json5, .gron => .json,
.yaml, .yml => .yaml,
.toml => .toml,
.zon => .zon,
// Canonical encodes every node kind directly, so no envelope
// is needed on output — only decode envelopes found in input.
// fig shares canonical's node model (same AST, no distinct
// envelope value-space of its own), so it gets the same
// decode-only treatment; a node fig can't natively spell
// (non-string key, YAML alias, scalar root) is the
// documented "no authoring spelling" gap — fall to
// `canonical`/`--lossless` with a different `--to` instead.
// (A scalar root specifically makes the fig printer hard-error
// with `FigUnrepresentableRoot` rather than emit non-conforming
// text — see `languages/fig/printer.zig`'s `root`.)
// XML has no envelope of its own either: every scalar
// collapses to element/attribute text regardless (see
// `languages/xml/printer.zig`), so an envelope couldn't
// preserve anything a plain print doesn't already lose.
.canonical, .fig, .xml => null,
};
const decoded = try a.create(fig.AST);
decoded.* = try fig.Lossless.decode(a, base_ast);
const target = maybe_target orelse break :blk decoded;
const encoded = try a.create(fig.AST);
encoded.* = try fig.Lossless.encode(a, decoded, target);
break :blk encoded;
} else base_ast;
const node_id = if (opts.path) |p| (try ast.getValByPath(p)).id else ast.root;
// gron is a CLI-only projection that derives straight from the AST,
// so it has no `SerializeFormat`: print it here and return, bypassing
// the serializer dispatch, the lossy/lossless diagnostics below, and
// the C ABI entirely. YAML aliases are already materialized above.
if (to == .gron) {
if (comptime build_options.lang_json) {
try gron.printNode(stdout_term.writer, ast, node_id, opts.gron_projection);
try stdout_term.writer.flush();
} else return error.FormatDisabled;
return;
}
const target: fig.AST.SerializeFormat = switch (to) {
.json => .json,
.jsonc => .jsonc,
.json5 => .json5,
.yaml, .yml => .yaml,
.toml => .toml,
.zon => .zon,
.canonical => .canonical,
.fig => .fig,
.xml => .xml,
.gron => unreachable, // handled by the early return above
};
// Surface everything the conversion would silently lose (comments
// dropped/degraded, values dropped/degraded) — unless `--quiet`. The
// pass is read-only and runs on the AST as it will be printed: under
// `--lossless` the lossy nodes are already enveloped, so no value
// warnings fire. `--strict` turns any warning into a hard failure.
if (!opts.quiet or opts.strict) {
const warnings = try fig.Diagnostics.analyze(a, ast, node_id, target, .{
.pretty = opts.serialize.pretty,
.strip_comments = opts.serialize.strip_comments,
.lossless = opts.lossless,
});
// The CLI only surfaces losses the FORMAT forced. A loss the user
// explicitly asked for (e.g. `--strip-comments`) carries
// `explicit_option` and is not surprising, so it neither warns nor
// trips `--strict` — it just rides through on the warning layer for
// a library consumer that wants it.
var surfaced: usize = 0;
for (warnings) |w| {
if (w.cause != .format_limitation) continue;
surfaced += 1;
if (!opts.quiet) {
try stderr_term.setColor(.yellow);
try stderr_term.writer.writeAll("warning: ");
try stderr_term.setColor(.reset);
try w.render(stderr_term.writer, target);
try stderr_term.writer.writeByte('\n');
}
}
if (!opts.quiet) try stderr_term.writer.flush();
if (opts.strict and surfaced > 0) {
try stderr_term.writer.print("error: {d} lossy conversion warning(s); --strict aborts.\n", .{surfaced});
try stderr_term.writer.flush();
std.process.exit(1);
}
}
if (target == .toml and !opts.lossless) {
// TOML has no null. In lossy mode, rather than the printer
// aborting mid-document on one, strip unrepresentable values up
// front so output stays valid and complete (the warnings above
// already reported them). `lossyStrip` re-roots at `node_id`, so
// the result serializes whole.
const result = try fig.Lossless.lossyStrip(a, ast, node_id, .toml);
if (result.ast) |stripped| {
try stripped.serializeWith(stdout_term.writer, .toml, opts.serialize);
}
} else if (opts.path == null) {
ast.serializeWith(stdout_term.writer, target, opts.serialize) catch |err| switch (err) {
error.FigUnrepresentableRoot => diag_report.reportFigUnrepresentableRoot(stderr_term),
else => return err,
};
} else {
ast.serializeNodeWith(stdout_term.writer, target, node_id, opts.serialize) catch |err| switch (err) {
error.FigUnrepresentableRoot => diag_report.reportFigUnrepresentableRoot(stderr_term),
else => return err,
};
}
try stdout_term.writer.flush();
}
pub fn runComment(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.CommentOptions) !void {
if (opts.requested_help) {
try Help.comment(stdout_term, binary_name);
return;
}
// `--get` only reads: open read-only and never write back.
const input = try fileio.getInput(io, opts.file, if (opts.get) .read_only else .read_write);
defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);
const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
if (opts.get) {
const comment = if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type|
try edit_ops.getCommentFromEmbed(a, io, input, embed_type, opts.path, opts.inline_comment)
else switch (resolved) {
// Strict JSON has no comment syntax: there can be nothing to get.
.json => {
try stderr_term.writer.print("error: strict JSON has no comments; use a .jsonc or .json5 file instead.\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
},
.jsonc, .json5 => |f| if (comptime build_options.lang_json) try edit_ops.getCommentFromFile(fig.Language.JSON, a, io, input, opts.path, opts.inline_comment, edit_ops.jsonDialect(f)) else return error.FormatDisabled,
.yaml, .yml => if (comptime build_options.lang_yaml)
try edit_ops.getCommentFromFile(fig.Language.YAML, a, io, input, opts.path, opts.inline_comment, fig.Language.YAML.default_type)
else
return error.FormatDisabled,
.toml => if (comptime build_options.lang_toml)
try edit_ops.getCommentFromFile(fig.Language.TOML, a, io, input, opts.path, opts.inline_comment, fig.Language.TOML.default_type)
else
return error.FormatDisabled,
.zon => if (comptime build_options.lang_zon)
try edit_ops.getCommentFromFile(fig.Language.ZON, a, io, input, opts.path, opts.inline_comment, fig.Language.ZON.default_type)
else
return error.FormatDisabled,
.xml => return error.UnsupportedXmlEdit,
.canonical => return error.UnsupportedCanonicalEdit,
.fig => if (comptime build_options.lang_fig)
try edit_ops.getCommentFromFile(fig.Language.FIG, a, io, input, opts.path, opts.inline_comment, fig.Language.FIG.default_type)
else
return error.FormatDisabled,
.gron => return error.UnsupportedGronEdit,
};
// Print the comment followed by a newline. An absent comment (null)
// and a present-but-empty one both print just the newline — the CLI
// can't distinguish them, but the bindings can (Option / null).
try stdout_term.writer.print("{s}\n", .{comment orelse ""});
try stdout_term.writer.flush();
return;
}
// Pick the op from the two flags: `--inline` selects the trailing
// (same-line) comment vs the leading block; `--delete` removes it
// rather than adding/setting. The marker (`#`, `//`) is the editor's
// job.
const op: EditOp = if (opts.delete)
(if (opts.inline_comment) .delete_trailing_comment else .delete_leading_comments)
else
(if (opts.inline_comment) .set_trailing_comment else .add_leading_comment);
if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| {
try edit_ops.applyToEmbed(a, io, input, embed_type, opts.path, opts.text, op);
} else switch (resolved) {
// Strict JSON has no comment syntax: fail with a clear message
// rather than letting the editor surface a bare error.
.json => {
try stderr_term.writer.print("error: strict JSON has no comments; use a .jsonc or .json5 file instead.\n", .{});
try stderr_term.writer.flush();
std.process.exit(2);
},
// JSONC/JSON5 accept `//` comments (reparsed under the dialect).
.jsonc, .json5 => |f| if (comptime build_options.lang_json) try edit_ops.applyToFile(fig.Language.JSON, a, io, input, opts.path, opts.text, op, edit_ops.jsonDialect(f)) else return error.FormatDisabled,
.yaml, .yml => if (comptime build_options.lang_yaml)
try edit_ops.applyToFile(fig.Language.YAML, a, io, input, opts.path, opts.text, op, fig.Language.YAML.default_type)
else
return error.FormatDisabled,
.toml => if (comptime build_options.lang_toml)
try edit_ops.applyToFile(fig.Language.TOML, a, io, input, opts.path, opts.text, op, fig.Language.TOML.default_type)
else
return error.FormatDisabled,
.zon => if (comptime build_options.lang_zon)
try edit_ops.applyToFile(fig.Language.ZON, a, io, input, opts.path, opts.text, op, fig.Language.ZON.default_type)
else
return error.FormatDisabled,
.xml => return error.UnsupportedXmlEdit,
.canonical => return error.UnsupportedCanonicalEdit,
.fig => if (comptime build_options.lang_fig)
try edit_ops.applyToFile(fig.Language.FIG, a, io, input, opts.path, opts.text, op, fig.Language.FIG.default_type)
else
return error.FormatDisabled,
.gron => return error.UnsupportedGronEdit,
}
}
pub fn runCheck(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.CheckOptions) !void {
if (opts.requested_help) {
try Help.check(stdout_term, binary_name);
return;
}
// Validate every file, reporting each independently, so one bad file
// doesn't hide the status of the rest. Success lines go to stdout
// (silenced by `--quiet`); failures always go to stderr. A single
// bad file makes the whole run exit non-zero — the CI contract.
var any_failed = false;
for (opts.files) |file| {
var diag_source: ?[]const u8 = null;
var diag_errors: ?[]const fig.ParseDiagnostic.Rendered = null;
var diag_warnings: ?[]const fig.ParseDiagnostic.Rendered = null;
if (parse_dispatch.checkOne(a, io, file, opts.format, opts.spec, &diag_source, &diag_errors, &diag_warnings)) |fmt| {
if (!opts.quiet) {
try stdout_term.setColor(.green);
try stdout_term.writer.writeAll("ok");
try stdout_term.setColor(.reset);
// Echo the pinned version alongside the format when one
// was requested, so `ok` states exactly what was checked.
if (opts.spec) |spec|
try stdout_term.writer.print(": {s} ({s} {s})\n", .{ file, @tagName(fmt), spec })
else
try stdout_term.writer.print(": {s} ({s})\n", .{ file, @tagName(fmt) });
// Authoring-time lints: the file is valid (still `ok`),
// but likely-mistake lines print right below it. Rendered
// live against the real terminal (not buffered into a
// string — see `printDiag`), then flushed immediately so
// it can't interleave with the next file's logging.
if (diag_warnings) |ws| {
for (ws) |w| try diag_report.printDiag(stderr_term, diag_source.?, file, w.offset, w.end, "warning", .yellow, w.message, w.short_label);
try stderr_term.writer.flush();
}
}
} else |err| {
any_failed = true;
// A covered-language failure renders as a full
// `file:line:col: error: …` teaching report per error
// (recovery collects every error in the file, not just the
// first) instead of the generic `file: ErrorName` line.
if (diag_errors) |errs| {
for (errs) |d| try diag_report.printDiag(stderr_term, diag_source.?, file, d.offset, d.end, "error", .red, d.message, d.short_label);
try stderr_term.writer.flush();
continue;
}
try stderr_term.setColor(.red);
try stderr_term.writer.writeAll("error");
try stderr_term.setColor(.reset);
switch (err) {
// A spec mismatch is a CLI usage error, not a malformed
// document — say so plainly with the offending version.
error.UnsupportedSpec => try stderr_term.writer.print(
": {s}: --spec '{s}' is not valid for this format\n",
.{ file, opts.spec.? },
),
else => try stderr_term.writer.print(": {s}: {s}\n", .{ file, @errorName(err) }),
}
}
}
try stdout_term.writer.flush();
try stderr_term.writer.flush();
if (any_failed) std.process.exit(1);
}
pub fn runFmt(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.FmtOptions) !void {
if (opts.requested_help) {
try Help.fmt(stdout_term, binary_name);
return;
}
// `--diff` is a preview mode just like `--dry-run` — nothing is
// ever written to `file` under either.
const preview_only = opts.dry_run or opts.diff;
const is_stdin = std.mem.eql(u8, opts.file, "-");
if (!preview_only and is_stdin) {
try stderr_term.writer.print(
"error: cannot format stdin in place; pass --dry-run or --diff to print the formatted result instead.\n",
.{},
);
try stderr_term.writer.flush();
std.process.exit(2);
}
// Read-only when only previewing (`--dry-run`/`--diff`): no need to
// open for writing what will never be written. Otherwise read_write,
// like `edit`/`set`/`insert`/`delete` — read the whole file first,
// then splice the same handle in place (never via shell redirection,
// which truncates a `> file` target before this process ever runs).
const input = try fileio.getInput(io, opts.file, if (preview_only) .read_only else .read_write);
defer if (!is_stdin) input.close(io);
const content = try fileio.readAll(a, io, input);
// `--embed`: only the region between the fences is reformatted; the
// rest of the host document is carried through byte-identical.
if (args_mod.resolveEmbedTypeFromContent(content, opts.embed, opts.detect_embed)) |embed_type| {
const region = try fig.Embed.locateRegion(content, embed_type);
const inner = content[region.content.start..region.content.end];
const reformatted_inner = try reformat.reformatSlice(a, stderr_term, opts.file, args_mod.embedFormat(embed_type), inner, opts.serialize, opts.quiet, opts.strict);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(a);
try out.appendSlice(a, content[0..region.content.start]);
try out.appendSlice(a, reformatted_inner);
try out.appendSlice(a, content[region.content.end..]);
const changed = !std.mem.eql(u8, content, out.items);
if (opts.diff) {
try diff.unifiedDiff(a, stdout_term.writer, opts.file, content, out.items, 3);
try stdout_term.writer.flush();
if (changed) std.process.exit(1);
} else if (opts.dry_run) {
try stdout_term.writer.writeAll(out.items);
try stdout_term.writer.flush();
if (changed) std.process.exit(1);
} else if (changed) {
try input.writePositionalAll(io, out.items, 0);
try input.setLength(io, out.items.len);
}
return;
}
var from = opts.from;
if (opts.detect) from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);
const reformatted = try reformat.reformatSlice(a, stderr_term, opts.file, from, content, opts.serialize, opts.quiet, opts.strict);
const changed = !std.mem.eql(u8, content, reformatted);
if (opts.diff) {
try diff.unifiedDiff(a, stdout_term.writer, opts.file, content, reformatted, 3);
try stdout_term.writer.flush();
if (changed) std.process.exit(1);
} else if (opts.dry_run) {
try stdout_term.writer.writeAll(reformatted);
try stdout_term.writer.flush();
if (changed) std.process.exit(1);
} else if (changed) {
// Read-then-splice-same-handle (never shell redirection): the
// in-memory `content` above was read before this write touches
// the file, so there is no truncate-before-read race.
try input.writePositionalAll(io, reformatted, 0);
try input.setLength(io, reformatted.len);
}
}
/// Finish a `convert` invocation given the original `content` and the
/// `result` it converts to: write `result` back to `input` in place when
/// `write` is set (skipped if the bytes are already identical), then print
/// either a unified diff (`show_diff`) or the whole `result` to stdout — the
/// whole result only when neither `write` nor `show_diff` fired, so `--write`
/// alone stays silent (like `fmt`) and `--write --diff` shows what changed
/// without also dumping the full file. Shared by both of `runConvert`'s modes
/// (whole-file and `--to-embed`), which differ only in how `result` is produced.
fn finishConvert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, input: Io.File, file_path: []const u8, content: []const u8, result: []const u8, write: bool, show_diff: bool) !void {
const changed = !std.mem.eql(u8, content, result);
if (write and changed) {
try input.writePositionalAll(io, result, 0);
try input.setLength(io, result.len);
}
if (show_diff) {
try diff.unifiedDiff(a, stdout_term.writer, file_path, content, result, 3);
try stdout_term.writer.flush();
} else if (!write) {
try stdout_term.writer.writeAll(result);
try stdout_term.writer.flush();
}
}
pub fn runConvert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.ConvertOptions) !void {
if (opts.requested_help) {
try Help.convert(stdout_term, binary_name);
return;
}
const is_stdin = std.mem.eql(u8, opts.file, "-");
if (opts.write and is_stdin) {
try stderr_term.writer.print(
"error: cannot write stdin in place; omit --write to print the converted result instead.\n",
.{},
);
try stderr_term.writer.flush();
std.process.exit(2);
}
const input = try fileio.getInput(io, opts.file, if (opts.write) .read_write else .read_only);
defer if (!is_stdin) input.close(io);
const content = try fileio.readAll(a, io, input);
if (opts.to_embed) |to_embed_type| {
// Embed-archetype mode: resolve the SOURCE archetype (--embed,
// else content-sniffed with `Embed.detect` — extension-derived
// defaults were already folded into `opts.embed` at parse time),
// convert the region's inner content, then rehouse it under the
// target archetype's fences, preserving the host prose exactly.
const source_type = opts.embed orelse (if (opts.detect_embed) fig.Embed.detect(content) else null) orelse {
try stderr_term.writer.print(
"error: could not detect an embedded region in `{s}`; pass --embed explicitly.\n",
.{opts.file},
);
try stderr_term.writer.flush();
std.process.exit(2);
};
const region = try fig.Embed.locateRegion(content, source_type);
const inner = content[region.content.start..region.content.end];
const converted_inner = try reformat.convertSlice(
a,
stderr_term,
opts.file,
args_mod.embedFormat(source_type),
args_mod.embedFormat(to_embed_type),
inner,
opts.serialize,
opts.lossless,
opts.lax_tags,
opts.quiet,
opts.strict,
);
const out = try fig.Embed.retype(a, content, region, to_embed_type, converted_inner);
try finishConvert(a, io, stdout_term, input, opts.file, content, out, opts.write, opts.diff);
return;
}
var from = opts.from;
if (opts.detect) from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);
const converted = try reformat.convertSlice(
a,
stderr_term,
opts.file,
from,
opts.to,
content,
opts.serialize,
opts.lossless,
opts.lax_tags,
opts.quiet,
opts.strict,
);
try finishConvert(a, io, stdout_term, input, opts.file, content, converted, opts.write, opts.diff);
}