//! Command-line parsing: turns the raw arg iterator into a `CliConfig`
//! (`parseConfig`), plus the small format/path/embed-archetype resolvers that
//! parsing (and later, the action handlers) share.
const std = @import("std");
const fig = @import("fig");
const build_options = @import("build_options");
const gron = @import("gron.zig");
const languages = @import("languages.zig");
const types = @import("types.zig");
const fileio = @import("fileio.zig");
const Format = types.Format;
const CliAction = types.CliAction;
const CliConfig = types.CliConfig;
const ArgError = types.ArgError;
const Detected = types.Detected;
const append_index = types.append_index;
const Io = std.Io;
/// Map a `--input`/`-i` format name to a `Format`. The enum member names cover
/// every accepted token (including `canonical` and `fig`) directly, plus the
/// one alias below. Returns null for an unknown name so callers can emit a
/// tailored error.
pub fn parseFormatName(name: []const u8) ?Format {
// `yml` is an accepted SPELLING of yaml, not a format of its own: it used
// to be a `Format` member, which bought a second `@tagName` echo and a
// duplicated arm in every switch over the enum, and nothing else. It
// collapses here instead, so downstream code only ever sees `.yaml`.
if (std.mem.eql(u8, name, "yml")) return .yaml;
if (std.meta.stringToEnum(Format, name)) |f| return f;
// A language the CLI did not compile in: configured in `languages.figl`
// (spawned and registered on this first ask) or already registered.
return languages.resolveName(name);
}
/// Parse a command-line path — `a.b[2].c` — into segments. A `.` separates
/// keys and a `[n]` is an index; `[-]`/`[$]` is the end sentinel.
///
/// A key that holds a `.` or a `[` is written quoted or escaped:
///
/// * `a."b.c"` — double quotes, with JSON's escapes inside (`\"`, `\\`,
/// `\n`, `\u00e9`, …);
/// * `a.'b.c'` — single quotes, verbatim, as a TOML literal key;
/// * `a["b.c"]` / `a['b.c']` — the same quoting in brackets, which is how
/// `-o gron` prints such a key, so a gron line reads back;
/// * `a.b\.c` — a backslash in a bare key takes the next byte as it is.
///
/// Key bytes are allocated from `allocator` where quoting or escaping
/// changed them, and otherwise borrowed from `path`.
pub fn parsePath(allocator: std.mem.Allocator, path: []const u8) ![]fig.AST.PathSegment {
const log = std.log.scoped(.parsePath);
var path_in_progress: std.ArrayList(fig.AST.PathSegment) = .empty;
var i: usize = 0;
while (i < path.len) {
switch (path[i]) {
'.' => {
// Dot is a separator. Else branch parses the key.
i += 1;
},
'[' => {
// A quoted key in brackets — gron's spelling of a key that
// is not a bare identifier.
if (i + 1 < path.len and (path[i + 1] == '"' or path[i + 1] == '\'')) {
i += 1;
const key = try quotedKey(allocator, path, &i);
if (i >= path.len or path[i] != ']') return ArgError.InvalidPath;
i += 1;
log.debug("key: {s}", .{key});
try path_in_progress.append(allocator, .{ .key = key });
continue;
}
// Skip open bracket
const start = i + 1;
i = start;
// Loop until end or close bracket
while (i < path.len and path[i] != ']') : (i += 1) {}
if (i >= path.len or i == start) return error.InvalidPath;
// `[-]` and `[$]` are the "end" tokens: `insert` reads the
// sentinel as "append", and `delete` reads it as "the last
// item" (`editor.removeSeqItem` special-cases it). Any other
// caller that walks the path literally (e.g. `get`) just
// sees an out-of-range index and surfaces NotFound.
const inner = path[start..i];
log.debug("number: {s}", .{inner});
const seg: fig.AST.PathSegment = if (std.mem.eql(u8, inner, "-") or std.mem.eql(u8, inner, "$"))
.{ .index = append_index }
else
.{ .index = try std.fmt.parseInt(usize, inner, 10) };
try path_in_progress.append(allocator, seg);
// Skip close bracket
i += 1;
},
'"', '\'' => {
const key = try quotedKey(allocator, path, &i);
// A quoted key ends its segment.
if (i < path.len and path[i] != '.' and path[i] != '[') return ArgError.InvalidPath;
log.debug("key: {s}", .{key});
try path_in_progress.append(allocator, .{ .key = key });
},
else => {
const start = i;
var escaped = false;
// Loop until a dot or open bracket; a backslash carries the
// byte after it past both.
while (i < path.len and path[i] != '.' and path[i] != '[') : (i += 1) {
if (path[i] == '\\') {
if (i + 1 >= path.len) return ArgError.InvalidPath;
escaped = true;
i += 1;
}
}
if (i == start) return ArgError.InvalidPath;
const key = if (!escaped) path[start..i] else blk: {
var out: std.ArrayList(u8) = .empty;
var j = start;
while (j < i) : (j += 1) {
if (path[j] == '\\') j += 1;
try out.append(allocator, path[j]);
}
break :blk try out.toOwnedSlice(allocator);
};
log.debug("key: {s}", .{key});
try path_in_progress.append(allocator, .{ .key = key });
},
}
}
return path_in_progress.toOwnedSlice(allocator);
}
/// The key quoted at `path[i.*]` — `'…'` verbatim, `"…"` with JSON's
/// escapes decoded — leaving `i.*` just past the closing quote.
fn quotedKey(allocator: std.mem.Allocator, path: []const u8, i: *usize) ![]const u8 {
const quote = path[i.*];
const start = i.* + 1;
var j = start;
if (quote == '\'') {
while (j < path.len and path[j] != '\'') : (j += 1) {}
if (j >= path.len) return ArgError.InvalidPath;
i.* = j + 1;
return path[start..j];
}
var out: std.ArrayList(u8) = .empty;
while (true) : (j += 1) {
if (j >= path.len) return ArgError.InvalidPath;
const c = path[j];
if (c == '"') break;
if (c != '\\') {
try out.append(allocator, c);
continue;
}
j += 1;
if (j >= path.len) return ArgError.InvalidPath;
switch (path[j]) {
'"', '\\', '/' => |e| try out.append(allocator, e),
'b' => try out.append(allocator, 0x08),
'f' => try out.append(allocator, 0x0c),
'n' => try out.append(allocator, '\n'),
'r' => try out.append(allocator, '\r'),
't' => try out.append(allocator, '\t'),
'u' => {
var cp: u21 = try hex4(path, j + 1);
j += 4;
// A high surrogate takes the `\uXXXX` low half after it.
if (cp >= 0xD800 and cp <= 0xDBFF) {
if (j + 2 >= path.len or path[j + 1] != '\\' or path[j + 2] != 'u') return ArgError.InvalidPath;
const lo = try hex4(path, j + 3);
if (lo < 0xDC00 or lo > 0xDFFF) return ArgError.InvalidPath;
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
j += 6;
} else if (cp >= 0xDC00 and cp <= 0xDFFF) return ArgError.InvalidPath;
var buf: [4]u8 = undefined;
const n = std.unicode.utf8Encode(cp, &buf) catch return ArgError.InvalidPath;
try out.appendSlice(allocator, buf[0..n]);
},
else => return ArgError.InvalidPath,
}
}
i.* = j + 1;
return out.toOwnedSlice(allocator);
}
fn hex4(path: []const u8, at: usize) !u21 {
if (at + 4 > path.len) return ArgError.InvalidPath;
return std.fmt.parseInt(u21, path[at .. at + 4], 16) catch ArgError.InvalidPath;
}
/// Infer the parse strategy from a file's extension, or null when the extension
/// is missing/unrecognized — the caller then falls back to content sniffing
/// (`Language.detect`) rather than failing outright.
pub fn detectLanguageFromFileEnding(file_path: []const u8) ?Detected {
// `--lang <name>` names the language outright, whatever the extension
// says — it is how a file a compiled format owns by extension is read
// through a runtime language instead (`secrets.env --lang lua-dotenv`).
if (languages.langOverride()) |f| return .{ .format = f };
const dot = std.mem.findLast(u8, file_path, ".");
const ext = file_path[(dot orelse 0) + 1 .. file_path.len];
// Markdown conventionally carries an embedded region (frontmatter, or
// endmatter), but which archetype it is still has to be sniffed from the
// actual bytes — a `` ```fig ``` ``-fenced or JSON frontmatter block (or a
// YAML endmatter fence) must not be mistaken for `---` YAML frontmatter
// just because the file ends in `.md`.
if (std.mem.eql(u8, ext, "md") or std.mem.eql(u8, ext, "markdown")) {
return .{ .format = .yaml, .embed_detect = true };
}
// An extension that names a `Format` member IS that format. Tried first so
// every token the enum spells keeps its exact meaning — notably the
// dialect members (`jsonc`, `json5`) that share a language with the
// default spelling, and the format-only members (`canonical`, `gron`) that
// no `Language` declares. `yml` is NOT one of them any more: it falls
// through to `extensionFormat` below, where `Language.YAML.extensions`
// owns it and resolves it to `.yaml` — the same parse it always got.
if (std.meta.stringToEnum(Format, ext)) |format| return .{ .format = format };
// Otherwise ask the languages. Each declares the extensions it owns
// (`Language.extensions`), which is where the ones that DON'T match an enum
// member name live: `.figl` (fig's canonical extension — `.fig` is the
// back-compat spelling, and matches the enum anyway), `.env` (dotenv files
// are conventionally named exactly `.env`, so the last-dot split gives a
// literal `env`), and `.nt` (NestedText's conventional extension). Those
// three used to be hand-written special cases here; they are now the
// formats' own declarations, so a new format's extension arrives with it.
//
// Not recognized here, before or now: multi-suffix variants like
// `.env.production`, whose last-dot extension is `production`. Pass
// `--input dotenv` for those. The canonical form deliberately owns no
// extension at all — select it with `--input canonical`.
if (extensionFormat(ext)) |format| return .{ .format = format };
// Last, the languages `languages.figl` configures: a compiled format's
// extension always wins, so a configured language is only reached by an
// extension nothing compiled in owns (proposal §7.1).
if (languages.resolveExtension(ext)) |format| return .{ .format = format };
return null;
}
/// The `Format` owning file extension `ext`, per the languages' own
/// `extensions` declarations, or null.
///
/// The `Language`→`Format` pairing this walks is per-LANGUAGE while `Format` is
/// per-DIALECT (json/jsonc/json5 are three members over one `Language`), so
/// a language resolves to whichever member is its DEFAULT spelling. That used
/// to be a hand-written table; it is now derived from the format registry —
/// see `ext_pairs`.
fn extensionFormat(ext: []const u8) ?Format {
inline for (ext_pairs) |pair| {
if (pair[0] != void) {
for (pair[0].extensions) |owned| {
if (std.mem.eql(u8, owned, ext)) return pair[1];
}
}
}
return null;
}
/// Each `Language` and the `Format` member that is its default spelling,
/// derived from the format registry: a language's default spelling IS its
/// FIRST entry in `dialects` — `json`, ahead of the `jsonc`/`json5` dialects
/// of the same module; every other language's single entry. That is exactly
/// what the hand-written table this replaces spelled out row by row.
///
/// A gated-out language is `void` here — the registry keeps every entry in
/// every build and collapses `Lang` instead — and `extensionFormat` skips it,
/// exactly as it skipped the `void` rows of the old table.
const ext_pairs = blk: {
const Pair = struct { type, Format };
var pairs: []const Pair = &.{};
outer: for (fig.Language.dialects) |d| {
// First entry per distinct language wins. A gated-out language is
// `void` in EVERY entry, so this test cannot tell JSON's three rows
// apart in a `-Djson=false` build (nor one gated language's row from
// another's) — and it does not need to: `extensionFormat` skips a
// `void` row, so a duplicate of one is a row that is skipped twice.
if (d.Lang != void) {
for (pairs) |p| {
if (p[0] == d.Lang) continue :outer;
}
}
pairs = pairs ++ [_]Pair{.{ d.Lang, @field(Format, d.name) }};
}
break :blk pairs;
};
// A language missing from `ext_pairs` would silently stop resolving by
// extension — the failure would look like "that file just isn't detected",
// which is exactly the kind of quiet gap this work exists to remove. The
// derivation above makes that unreachable rather than merely detectable, so
// this is now a check on the DERIVATION rather than on a hand-written table;
// it costs one comptime loop and it is what would catch a first-entry-per-
// language rule that silently skipped a row.
comptime {
outer: for (fig.Language.compiled) |Lang| {
for (ext_pairs) |pair| {
if (pair[0] == Lang) continue :outer;
}
@compileError("cli/args.zig `ext_pairs` is missing the language '" ++ Lang.name ++
"', so its declared extensions would never resolve");
}
}
/// Map a `--embed <archetype>` flag value to its `Embed.Type`. Lets any
/// embed-capable action target a region explicitly — overriding whatever
/// `resolveEmbedType`/`resolveEmbedTypeFromContent` would otherwise sniff —
/// so endmatter and JSON frontmatter are reachable. Returns null for an
/// unknown name.
pub fn embedTypeFromName(name: []const u8) ?fig.Embed.Type {
const eql = std.mem.eql;
// The four PARAMETRIC families, derived: every embeddable format is
// reachable in every parametric container as `<container>-<format>`. The
// container half is this file's own spelling (there is no container
// registry — `Embed.Type`'s union tags are it); the `<format>` half is the
// registry entry's name, which is also the `InnerFormat` member name, so a
// format that grows an embedded spelling arrives here with all of its
// archetype names at once.
inline for (@typeInfo(fig.Embed.InnerFormat).@"enum".fields) |field| {
const f: fig.Embed.InnerFormat = @enumFromInt(field.value);
// `md-yaml` is deliberately not a spelling: a bare `---` block IS YAML
// frontmatter, so it answers to `frontmatter`/`frontmatter-yaml` below
// — which is what the `--help` prose documents and what `set`'s
// open-or-init default resolves to.
if (comptime !eql(u8, field.name, "yaml")) {
if (eql(u8, name, "md-" ++ field.name)) return .{ .frontmatter = f };
}
if (eql(u8, name, "fenced-" ++ field.name)) return .{ .fenced = f };
if (eql(u8, name, "html-script-" ++ field.name)) return .{ .html_script = f };
if (eql(u8, name, "html-code-" ++ field.name)) return .{ .html_code = f };
}
// The rest are not `<container>-<format>` pairs and do not derive: legacy
// spellings that predate the parametric families, and the blessed presets
// whose delimiter is its own distinct token rather than a language tag.
//
// Markdown `---` frontmatter (bare ⇒ yaml; see the `md-yaml` note above).
if (eql(u8, name, "frontmatter") or eql(u8, name, "frontmatter-yaml")) return .{ .frontmatter = .yaml };
// `frontmatter-fig` is the legacy alias of `fenced-fig` — it names a FENCED
// block, not `---fig` frontmatter, which is why it cannot ride the loop.
if (eql(u8, name, "frontmatter-fig")) return .{ .fenced = .fig };
// Blessed distinct-delimiter presets (`frontmatter-json/-toml` are the
// historical names for the `;;;`/`+++` blocks).
if (eql(u8, name, "semicolons") or eql(u8, name, "frontmatter-json")) return .semicolons_json;
if (eql(u8, name, "plus") or eql(u8, name, "frontmatter-toml")) return .plus_toml;
if (eql(u8, name, "endmatter") or eql(u8, name, "endmatter-yaml")) return .endmatter_yaml;
// The bare container names, whose default format is fig rather than the
// registry's first entry — a deliberate choice this project made, not a
// fact the registry states.
if (eql(u8, name, "html-script")) return .{ .html_script = .fig };
if (eql(u8, name, "html-code")) return .{ .html_code = .fig };
return null;
}
/// The canonical `--embed` spelling of an archetype — the inverse of
/// `embedTypeFromName`, for diagnostics that have a `Type` in hand and need to
/// name it back to the user in the vocabulary they typed. Canonical means: the
/// derived `<container>-<format>` name for the parametric families, and the
/// primary (not legacy-alias) name for the presets, so round-tripping the
/// result back through `embedTypeFromName` returns the same `Type`.
pub fn embedTypeName(t: fig.Embed.Type) []const u8 {
return switch (t) {
// `frontmatter = .yaml` is spelled `frontmatter`, not `md-yaml` — a bare
// `---` block IS YAML frontmatter, which is why `md-yaml` is not a
// spelling at all (see `embedTypeFromName`).
.frontmatter => |f| switch (f) {
.yaml => "frontmatter",
inline else => |g| "md-" ++ @tagName(g),
},
.fenced => |f| switch (f) {
inline else => |g| "fenced-" ++ @tagName(g),
},
.html_script => |f| switch (f) {
inline else => |g| "html-script-" ++ @tagName(g),
},
.html_code => |f| switch (f) {
inline else => |g| "html-code-" ++ @tagName(g),
},
.semicolons_json => "semicolons",
.plus_toml => "plus",
.endmatter_yaml => "endmatter",
};
}
test "embedTypeName round-trips through embedTypeFromName" {
// Every archetype the CLI can name must name itself back to the same Type,
// so a diagnostic never prints a spelling the parser would reject.
for ([_]fig.Embed.Type{
.{ .frontmatter = .yaml }, .{ .frontmatter = .json }, .{ .frontmatter = .toml }, .{ .frontmatter = .fig },
.{ .fenced = .yaml }, .{ .fenced = .json }, .{ .fenced = .toml }, .{ .fenced = .fig },
.{ .html_script = .yaml }, .{ .html_script = .json }, .{ .html_script = .toml }, .{ .html_script = .fig },
.{ .html_code = .yaml }, .{ .html_code = .json }, .{ .html_code = .toml }, .{ .html_code = .fig },
.semicolons_json, .plus_toml, .endmatter_yaml,
}) |t| {
try std.testing.expectEqual(@as(?fig.Embed.Type, t), embedTypeFromName(embedTypeName(t)));
}
}
/// The `--embed <archetype>` names accepted by `embedTypeFromName`, for error
/// messages — one source of truth so a new archetype is listed everywhere.
/// The `<lang>` half is derived from `Embed.InnerFormat`, the set the
/// parametric loop in `embedTypeFromName` spells, so it cannot fall behind it.
pub const embed_archetype_names = blk: {
var langs: []const u8 = "";
for (@typeInfo(fig.Embed.InnerFormat).@"enum".fields, 0..) |f, i|
langs = langs ++ (if (i == 0) "" else ", ") ++ f.name;
break :blk "frontmatter, frontmatter-json (;;;), frontmatter-toml (+++), endmatter, " ++
"md-<lang>, fenced-<lang>, html-script[-<lang>], html-code[-<lang>]; " ++
"<lang> is one of " ++ langs ++ " (there is no md-yaml: that is `frontmatter`)";
};
/// The CLI `Format` an embed archetype's content is written in — the `get`
/// action's `--input`/`--output` twin of `Embed.innerFormat`. Lets an explicit
/// `--embed <archetype>` pick the right parser/printer on its own, without
/// also requiring a redundant `--input`/`--output` (or, worse, silently
/// keeping a same-named-extension guess that doesn't match the archetype —
/// e.g. `--embed frontmatter-fig` on a `.md` file, whose extension alone
/// says nothing about which archetype it actually is).
///
/// A name identity, not a mapping: both enums draw their members from the same
/// format registry, so an `InnerFormat` member and the `Format` member it means
/// are the same string by construction. (`Format` is the larger of the two —
/// every embeddable format is a CLI format, but not every CLI format has an
/// embedded spelling — so `@field` is total in this direction only.)
pub fn embedFormat(t: fig.Embed.Type) Format {
return switch (fig.Embed.innerFormat(t)) {
inline else => |f| @field(Format, @tagName(f)),
};
}
/// Resolve the embed archetype an action should operate on, given
/// already-read `content`. An explicit `embed` (a `--embed` flag, or a format
/// pinned outright some other way) always wins. Otherwise, when the file's
/// extension only implied "there's probably an embedded region here" without
/// saying which archetype (`detect_embed` — today only `.md`/`.markdown`,
/// via `Detected.embed_detect`), sniff the real bytes with `Embed.detect` so
/// a `` ```fig ``` ``-fenced or JSON frontmatter block (or a YAML endmatter
/// fence) isn't silently mistaken for `---` YAML frontmatter. Falls back to
/// the conventional `FrontmatterYaml` default when nothing is found at all —
/// e.g. a brand-new host file with no frontmatter yet — so `set`'s
/// open-or-init still seeds the same archetype it always has. Returns `null`
/// when this isn't an embed operation at all (no override, and the extension
/// implies no embed).
pub fn resolveEmbedTypeFromContent(content: []const u8, embed: ?fig.Embed.Type, detect_embed: bool) ?fig.Embed.Type {
if (embed) |e| return e;
if (!detect_embed) return null;
return fig.Embed.detect(content) orelse .{ .frontmatter = .yaml };
}
/// Same as `resolveEmbedTypeFromContent`, but reads `input` itself first, for
/// call sites that haven't already buffered the file's bytes at the point
/// they need to decide. This only performs that read when a sniff is
/// actually needed (`embed == null and detect_embed`) — which today only
/// happens for a real `.md`/`.markdown` path, never stdin (`-`), so the extra
/// positional read is always safe: it's a second read of a regular, seekable
/// file, not a second (and empty) read of a pipe.
pub fn resolveEmbedType(io: Io, allocator: std.mem.Allocator, input: Io.File, embed: ?fig.Embed.Type, detect_embed: bool) !?fig.Embed.Type {
if (embed) |e| return e;
if (!detect_embed) return null;
const content = try fileio.readAll(allocator, io, input);
defer allocator.free(content);
return fig.Embed.detect(content) orelse .{ .frontmatter = .yaml };
}
/// The argument list with `--lang <name>` taken out: the one flag every
/// action accepts, read here once rather than in each action's loop, and
/// recorded for `detectLanguageFromFileEnding` to answer with.
const Args = struct {
items: []const []const u8,
i: usize = 0,
fn next(self: *Args) ?[]const u8 {
if (self.i >= self.items.len) return null;
defer self.i += 1;
return self.items[self.i];
}
};
pub fn parseConfig(allocator: std.mem.Allocator, args_in: anytype) ArgError!CliConfig {
const log = std.log.scoped(.parseConfig);
var config = CliConfig{};
var collected: std.ArrayList([]const u8) = .empty;
while (args_in.next()) |arg| {
if (std.mem.eql(u8, arg, "--lang")) {
const name = args_in.next() orelse {
log.err("Missing language name after --lang\n", .{});
return ArgError.UnsupportedFileFormat;
};
const format = parseFormatName(name) orelse {
// A refused helper has already said why, through `ensure`.
if (!languages.isConfigured(name)) log.err("No language named `{s}`: not a compiled format, and no languages.figl configures it (see `fig lang --help`)\n", .{name});
return ArgError.UnsupportedFileFormat;
};
languages.setLangOverride(format);
continue;
}
collected.append(allocator, arg) catch return ArgError.OutOfMemory;
}
var args_storage: Args = .{ .items = collected.items };
const args = &args_storage;
config.binary_name = args.next() orelse "fig";
const action_str = args.next() orelse {
config.action = .help;
config.options = .{ .help = .{} };
return config;
};
if (std.mem.eql(u8, action_str, "help") or std.mem.eql(u8, action_str, "--help") or std.mem.eql(u8, action_str, "-h")) {
config.action = .help;
config.options = .{ .help = .{ .requested_help = true } };
} else if (std.mem.eql(u8, action_str, "version") or std.mem.eql(u8, action_str, "--version") or std.mem.eql(u8, action_str, "-v")) {
config.action = .version;
config.options = .{ .version = .{} };
} else if (std.mem.eql(u8, action_str, "edit") or std.mem.eql(u8, action_str, "e")) {
config.action = .edit;
var edit_key = false;
var file_path_arg = args.next();
if (file_path_arg) |arg| {
if (std.mem.eql(u8, arg, "--key")) {
edit_key = true;
file_path_arg = args.next();
}
}
const file_path = file_path_arg orelse {
log.err("No file provided.\n", .{});
return ArgError.MissingEditArgument;
};
const requested_help = std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h");
var path: []fig.AST.PathSegment = &.{};
var replacement: []const u8 = "";
if (!requested_help) {
const path_str = args.next() orelse {
log.err("No path provided.\n", .{});
return ArgError.MissingEditArgument;
};
path = try parsePath(allocator, path_str);
replacement = args.next() orelse {
log.err("No replacement provided.\n", .{});
return ArgError.MissingEditArgument;
};
}
// Skip extension detection when the user only asked for help (the
// "file" is then `--help`, which has no real format). An unrecognized
// extension is not an error here: `detect = true` defers to content
// sniffing in the handler.
const ext = if (requested_help) null else detectLanguageFromFileEnding(file_path);
config.options = .{ .edit = .{
.file = file_path,
.path = path,
.replacement = replacement,
.key = edit_key,
.requested_help = requested_help,
.format = if (ext) |d| d.format else .json,
.detect = !requested_help and ext == null,
.embed = null,
.detect_embed = if (ext) |d| d.embed_detect else false,
} };
} else if (std.mem.eql(u8, action_str, "set") or std.mem.eql(u8, action_str, "s")) {
config.action = .set;
// Leading flags in any order: `--seq`, `--embed <archetype>`, `--help`.
// Positionals follow: file, path, then the value (or, with `--seq`, the
// sequence items).
var seq = false;
var embed_override: ?fig.Embed.Type = null;
var requested_help = false;
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
requested_help = true;
} else if (std.mem.eql(u8, arg, "--seq")) {
seq = true;
} else if (std.mem.eql(u8, arg, "--embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingSetArgument;
};
embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else {
try positionals.append(allocator, arg);
}
}
if (requested_help) {
config.options = .{ .set = .{ .file = "", .path = &.{}, .value = "", .requested_help = true, .format = .json } };
} else {
// Need file, path, and at least one value (a scalar, or one or more
// sequence items with `--seq`).
if (positionals.items.len < 3) {
log.err("set needs a file, a path, and a value (e.g. `fig set f.yaml a.b 1`).\n", .{});
return ArgError.MissingSetArgument;
}
const file_path = positionals.items[0];
const path = try parsePath(allocator, positionals.items[1]);
const ext = detectLanguageFromFileEnding(file_path);
const embed = embed_override;
config.options = .{
.set = .{
.file = file_path,
.path = path,
.value = if (seq) "" else positionals.items[2],
.seq = seq,
.values = if (seq) try allocator.dupe([]const u8, positionals.items[2..]) else &.{},
.requested_help = false,
.format = if (ext) |d| d.format else .json,
// Skip content sniffing when targeting an embed (the inner format
// is fixed by the archetype) or when the extension resolved it.
.detect = ext == null and embed == null,
.embed = embed,
.detect_embed = embed == null and (if (ext) |d| d.embed_detect else false),
},
};
}
} else if (std.mem.eql(u8, action_str, "insert") or std.mem.eql(u8, action_str, "i")) {
config.action = .insert;
const file_path = args.next() orelse {
log.err("No file provided.\n", .{});
return ArgError.MissingInsertArgument;
};
const requested_help = std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h");
var path: []fig.AST.PathSegment = &.{};
var value: []const u8 = "";
if (!requested_help) {
const path_str = args.next() orelse {
log.err("No path provided.\n", .{});
return ArgError.MissingInsertArgument;
};
path = try parsePath(allocator, path_str);
value = args.next() orelse {
log.err("No value provided.\n", .{});
return ArgError.MissingInsertArgument;
};
}
const ext = if (requested_help) null else detectLanguageFromFileEnding(file_path);
config.options = .{ .insert = .{
.file = file_path,
.path = path,
.value = value,
.requested_help = requested_help,
.format = if (ext) |d| d.format else .json,
.detect = !requested_help and ext == null,
.embed = null,
.detect_embed = if (ext) |d| d.embed_detect else false,
} };
} else if (std.mem.eql(u8, action_str, "delete") or std.mem.eql(u8, action_str, "d")) {
config.action = .delete;
const file_path = args.next() orelse {
log.err("No file provided.\n", .{});
return ArgError.MissingDeleteArgument;
};
const requested_help = std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h");
var path: []fig.AST.PathSegment = &.{};
if (!requested_help) {
const path_str = args.next() orelse {
log.err("No path provided.\n", .{});
return ArgError.MissingDeleteArgument;
};
path = try parsePath(allocator, path_str);
}
const ext = if (requested_help) null else detectLanguageFromFileEnding(file_path);
config.options = .{ .delete = .{
.file = file_path,
.path = path,
.requested_help = requested_help,
.format = if (ext) |d| d.format else .json,
.detect = !requested_help and ext == null,
.embed = null,
.detect_embed = if (ext) |d| d.embed_detect else false,
} };
} else if (std.mem.eql(u8, action_str, "comment") or std.mem.eql(u8, action_str, "c")) {
config.action = .comment;
// Leading flags, in any order: `--inline`, `--delete`, `--get`. Consume
// them until the first non-flag token (the file).
var inline_comment = false;
var delete = false;
var get = false;
var file_path_arg = args.next();
while (file_path_arg) |arg| {
if (std.mem.eql(u8, arg, "--inline")) {
inline_comment = true;
} else if (std.mem.eql(u8, arg, "--delete")) {
delete = true;
} else if (std.mem.eql(u8, arg, "--get")) {
get = true;
} else break;
file_path_arg = args.next();
}
const file_path = file_path_arg orelse {
log.err("No file provided.\n", .{});
return ArgError.MissingCommentArgument;
};
const requested_help = std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h");
var path: []fig.AST.PathSegment = &.{};
var text: []const u8 = "";
if (!requested_help) {
const path_str = args.next() orelse {
log.err("No path provided.\n", .{});
return ArgError.MissingCommentArgument;
};
path = try parsePath(allocator, path_str);
// Delete/get need no text; add/set requires it.
if (!delete and !get) {
text = args.next() orelse {
log.err("No comment text provided.\n", .{});
return ArgError.MissingCommentArgument;
};
}
}
const ext = if (requested_help) null else detectLanguageFromFileEnding(file_path);
config.options = .{ .comment = .{
.file = file_path,
.path = path,
.text = text,
.inline_comment = inline_comment,
.delete = delete,
.get = get,
.requested_help = requested_help,
.format = if (ext) |d| d.format else .json,
.detect = !requested_help and ext == null,
.embed = null,
.detect_embed = if (ext) |d| d.embed_detect else false,
} };
} else if (std.mem.eql(u8, action_str, "get") or std.mem.eql(u8, action_str, "g")) {
config.action = .get;
var input_override: ?Format = null;
var output_override: ?Format = null;
var lax_tags = false;
var lossless = false;
var quiet = false;
var strict = false;
// Explicit `--embed <archetype>` override and `--body` projection.
var embed_override: ?fig.Embed.Type = null;
var body = false;
var serialize: fig.AST.SerializeOptions = .{};
// gron projection overrides; null means "keep the gron default".
var gron_root: ?[]const u8 = null;
var gron_sep: ?[]const u8 = null;
var gron_term: ?[]const u8 = null;
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--lax-tags")) {
lax_tags = true;
} else if (std.mem.eql(u8, arg, "--gron-root")) {
gron_root = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
} else if (std.mem.eql(u8, arg, "--gron-sep")) {
gron_sep = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
} else if (std.mem.eql(u8, arg, "--gron-term")) {
// Empty is allowed (drop the terminator entirely).
gron_term = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
} else if (std.mem.eql(u8, arg, "--lossless")) {
lossless = true;
} else if (std.mem.eql(u8, arg, "--lossy")) {
lossless = false;
} else if (std.mem.eql(u8, arg, "--compact")) {
serialize.pretty = false;
} else if (std.mem.eql(u8, arg, "--pretty")) {
serialize.pretty = true;
} else if (std.mem.eql(u8, arg, "--strip-comments")) {
serialize.strip_comments = true;
} else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q") or std.mem.eql(u8, arg, "--no-warnings")) {
quiet = true;
} else if (std.mem.eql(u8, arg, "--strict")) {
strict = true;
} else if (std.mem.eql(u8, arg, "--embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--body")) {
body = true;
} else if (std.mem.eql(u8, arg, "--indent")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
serialize.indent = std.fmt.parseInt(u8, n, 10) catch {
log.err("Invalid --indent value: {s}\n", .{n});
return ArgError.MissingGetArgument;
};
// fig has no `pretty` gate of its own to read `indent`'s value
// against (see `SerializeOptions.fig_indent`'s doc comment), so
// an explicit `--indent` is fig's own on/off signal, independent
// of the numeric width other formats use it for.
serialize.fig_indent = true;
} else if (std.mem.eql(u8, arg, "--width")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
serialize.width = std.fmt.parseInt(u16, n, 10) catch {
log.err("Invalid --width value: {s}\n", .{n});
return ArgError.MissingGetArgument;
};
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const fmt = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
// Delegate to the same enum-driven lookup `check`/`fmt`/`convert`
// use, so this never again drifts out of sync with `Format` as
// formats are added (this used to be its own hand-rolled chain
// of literal comparisons — see git blame — which is exactly how
// it silently fell behind when ini/dotenv/properties were added).
input_override = parseFormatName(fmt) orelse {
log.err("Unsupported format: {s}\n", .{fmt});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--output") or std.mem.eql(u8, arg, "-o")) {
const fmt = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingGetArgument;
};
output_override = parseFormatName(fmt) orelse {
log.err("Unsupported format: {s}\n", .{fmt});
return ArgError.UnsupportedFileFormat;
};
} else {
try positionals.append(allocator, arg);
}
}
const file_path = if (positionals.items.len > 0) positionals.items[0] else {
log.err("No file provided.\n", .{});
return ArgError.MissingGetArgument;
};
const requested_help = std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h");
var path: ?[]fig.AST.PathSegment = null;
if (!requested_help and positionals.items.len > 1) {
path = try parsePath(allocator, positionals.items[1]);
}
const detected_input: ?Detected = if (!requested_help and input_override == null)
detectLanguageFromFileEnding(file_path)
else
null;
// An explicit `--embed` archetype wins outright; otherwise, when the
// extension implies SOME embedded region (`.md`), the handler sniffs
// which archetype it actually is at runtime (`resolveEmbedType`).
const embed = embed_override;
// No `--input` and an unrecognized extension ⇒ sniff the contents in the
// handler. `.json` here is a placeholder `from`/`to`, overwritten once the
// real format is known. An explicit `--embed` is never sniffed: its
// archetype fixes the inner format outright (`embedFormat`), and that
// wins over a same-named extension guess — e.g. `--embed frontmatter-fig`
// on a `.md` file (whose extension alone says nothing about which
// archetype it actually is) must read/render the embed as fig, not YAML.
const needs_detect = !requested_help and input_override == null and embed_override == null and detected_input == null;
const input_format = input_override orelse
(if (embed_override) |et| embedFormat(et) else null) orelse
(if (detected_input) |d| d.format else null) orelse .json;
var gron_projection: gron.Projection = .gron;
if (gron_root) |r| gron_projection.root_name = r;
if (gron_sep) |s| gron_projection.assign = s;
if (gron_term) |t| gron_projection.terminator = t;
config.options = .{ .get = .{
.file = file_path,
.path = path,
.from = input_format,
.to = output_override orelse input_format,
.requested_help = requested_help,
.detect = needs_detect,
.output_explicit = output_override != null,
.lax_tags = lax_tags,
.lossless = lossless,
.embed = embed,
.detect_embed = embed == null and (if (detected_input) |d| d.embed_detect else false),
.body = body,
.serialize = serialize,
.quiet = quiet,
.strict = strict,
.gron_projection = gron_projection,
} };
} else if (std.mem.eql(u8, action_str, "check") or std.mem.eql(u8, action_str, "ck")) {
config.action = .check;
var input_override: ?Format = null;
var spec: ?[]const u8 = null;
var quiet = false;
var requested_help = false;
var files: std.ArrayList([]const u8) = .empty;
defer files.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
requested_help = true;
} else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q") or std.mem.eql(u8, arg, "--no-warnings")) {
quiet = true;
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const fmt = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingCheckArgument;
};
input_override = parseFormatName(fmt) orelse {
log.err("Unsupported format: {s}\n", .{fmt});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--spec") or std.mem.eql(u8, arg, "-s")) {
spec = args.next() orelse {
log.err("Missing version value after {s}\n", .{arg});
return ArgError.MissingCheckArgument;
};
} else {
try files.append(allocator, arg);
}
}
if (!requested_help and files.items.len == 0) {
log.err("No file provided.\n", .{});
return ArgError.MissingCheckArgument;
}
config.options = .{
.check = .{
// toOwnedSlice: the whole slice (allocated in the arena passed to
// parseConfig) outlives this function, unlike `get` which only keeps
// copies of individual positional headers.
.files = try files.toOwnedSlice(allocator),
.format = input_override,
.spec = spec,
.quiet = quiet,
.requested_help = requested_help,
},
};
} else if (std.mem.eql(u8, action_str, "fmt") or std.mem.eql(u8, action_str, "f")) {
config.action = .fmt;
var input_override: ?Format = null;
var quiet = false;
var strict = false;
var dry_run = false;
var diff_mode = false;
var requested_help = false;
var embed_override: ?fig.Embed.Type = null;
var serialize: fig.AST.SerializeOptions = .{};
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
requested_help = true;
} else if (std.mem.eql(u8, arg, "--dry-run")) {
dry_run = true;
} else if (std.mem.eql(u8, arg, "--diff")) {
diff_mode = true;
} else if (std.mem.eql(u8, arg, "--compact")) {
serialize.pretty = false;
} else if (std.mem.eql(u8, arg, "--pretty")) {
serialize.pretty = true;
} else if (std.mem.eql(u8, arg, "--strip-comments")) {
serialize.strip_comments = true;
} else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q") or std.mem.eql(u8, arg, "--no-warnings")) {
quiet = true;
} else if (std.mem.eql(u8, arg, "--strict")) {
strict = true;
} else if (std.mem.eql(u8, arg, "--embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingFmtArgument;
};
embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--indent")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingFmtArgument;
};
serialize.indent = std.fmt.parseInt(u8, n, 10) catch {
log.err("Invalid --indent value: {s}\n", .{n});
return ArgError.MissingFmtArgument;
};
// See the matching comment on `get`'s `--indent` handling above.
serialize.fig_indent = true;
} else if (std.mem.eql(u8, arg, "--width")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingFmtArgument;
};
serialize.width = std.fmt.parseInt(u16, n, 10) catch {
log.err("Invalid --width value: {s}\n", .{n});
return ArgError.MissingFmtArgument;
};
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const fmt_name = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingFmtArgument;
};
input_override = parseFormatName(fmt_name) orelse {
log.err("Unsupported format: {s}\n", .{fmt_name});
return ArgError.UnsupportedFileFormat;
};
} else {
try positionals.append(allocator, arg);
}
}
if (!requested_help and positionals.items.len == 0) {
log.err("No file provided.\n", .{});
return ArgError.MissingFmtArgument;
}
// `fmt` reformats a whole file (or a whole embedded region) — there is no
// sub-document path argument the way `get`/`edit`/etc. take one.
if (!requested_help and positionals.items.len > 1) {
log.err("fmt takes a single file, not a path within it: {s}\n", .{positionals.items[1]});
return ArgError.MissingFmtArgument;
}
if (!requested_help and dry_run and diff_mode) {
log.err("--dry-run and --diff are mutually exclusive.\n", .{});
return ArgError.MissingFmtArgument;
}
const file_path = if (positionals.items.len > 0) positionals.items[0] else "-";
const detected_input: ?Detected = if (!requested_help and input_override == null)
detectLanguageFromFileEnding(file_path)
else
null;
// An explicit `--embed` archetype wins outright; otherwise, when the
// extension implies SOME embedded region (`.md`), the handler sniffs
// which archetype it actually is at runtime.
const embed = embed_override;
const needs_detect = !requested_help and input_override == null and embed_override == null and detected_input == null;
const from = input_override orelse
(if (embed_override) |et| embedFormat(et) else null) orelse
(if (detected_input) |d| d.format else null) orelse .json;
config.options = .{ .fmt = .{
.file = file_path,
.from = from,
.requested_help = requested_help,
.detect = needs_detect,
.serialize = serialize,
.quiet = quiet,
.strict = strict,
.dry_run = dry_run,
.diff = diff_mode,
.embed = embed,
.detect_embed = embed == null and (if (detected_input) |d| d.embed_detect else false),
} };
} else if (std.mem.eql(u8, action_str, "convert") or std.mem.eql(u8, action_str, "cv")) {
config.action = .convert;
var input_override: ?Format = null;
var output_override: ?Format = null;
var embed_override: ?fig.Embed.Type = null;
var to_embed_override: ?fig.Embed.Type = null;
var lax_tags = false;
var lossless = false;
var quiet = false;
var strict = false;
var write = false;
var diff_mode = false;
var requested_help = false;
var serialize: fig.AST.SerializeOptions = .{};
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
requested_help = true;
} else if (std.mem.eql(u8, arg, "--write") or std.mem.eql(u8, arg, "-w")) {
write = true;
} else if (std.mem.eql(u8, arg, "--dry-run")) {
log.err("convert prints to stdout by default; pass --write/-w to write in place instead.\n", .{});
return ArgError.MissingConvertArgument;
} else if (std.mem.eql(u8, arg, "--diff")) {
diff_mode = true;
} else if (std.mem.eql(u8, arg, "--compact")) {
serialize.pretty = false;
} else if (std.mem.eql(u8, arg, "--pretty")) {
serialize.pretty = true;
} else if (std.mem.eql(u8, arg, "--strip-comments")) {
serialize.strip_comments = true;
} else if (std.mem.eql(u8, arg, "--lax-tags")) {
lax_tags = true;
} else if (std.mem.eql(u8, arg, "--lossless")) {
lossless = true;
} else if (std.mem.eql(u8, arg, "--lossy")) {
lossless = false;
} else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q") or std.mem.eql(u8, arg, "--no-warnings")) {
quiet = true;
} else if (std.mem.eql(u8, arg, "--strict")) {
strict = true;
} else if (std.mem.eql(u8, arg, "--embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--to-embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
to_embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --to-embed archetype: {s} (frontmatter, frontmatter-json, frontmatter-fig, endmatter)\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--indent")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
serialize.indent = std.fmt.parseInt(u8, n, 10) catch {
log.err("Invalid --indent value: {s}\n", .{n});
return ArgError.MissingConvertArgument;
};
// See the matching comment on `get`'s `--indent` handling above.
serialize.fig_indent = true;
} else if (std.mem.eql(u8, arg, "--width")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
serialize.width = std.fmt.parseInt(u16, n, 10) catch {
log.err("Invalid --width value: {s}\n", .{n});
return ArgError.MissingConvertArgument;
};
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const fmt_name = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
input_override = parseFormatName(fmt_name) orelse {
log.err("Unsupported format: {s}\n", .{fmt_name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--output") or std.mem.eql(u8, arg, "-o")) {
const fmt_name = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingConvertArgument;
};
output_override = parseFormatName(fmt_name) orelse {
log.err("Unsupported format: {s}\n", .{fmt_name});
return ArgError.UnsupportedFileFormat;
};
} else {
try positionals.append(allocator, arg);
}
}
if (!requested_help and positionals.items.len == 0) {
log.err("No file provided.\n", .{});
return ArgError.MissingConvertArgument;
}
if (!requested_help and positionals.items.len > 1) {
log.err("convert takes a single file, not a path within it: {s}\n", .{positionals.items[1]});
return ArgError.MissingConvertArgument;
}
if (!requested_help and output_override != null and to_embed_override != null) {
log.err("--output and --to-embed are mutually exclusive: whole-file conversion picks the target format directly, embed-archetype conversion picks it via the archetype.\n", .{});
return ArgError.MissingConvertArgument;
}
if (!requested_help and output_override == null and to_embed_override == null) {
log.err("convert needs a target: pass --output <format> to convert the whole file, or --to-embed <archetype> to rehouse an embedded region.\n", .{});
return ArgError.MissingConvertArgument;
}
if (!requested_help and input_override != null and to_embed_override != null) {
log.err("--input is not used with --to-embed: the source archetype (--embed, else detected) fixes the input format.\n", .{});
return ArgError.MissingConvertArgument;
}
if (!requested_help and embed_override != null and to_embed_override == null) {
log.err("--embed requires --to-embed (embed-archetype conversion always changes the archetype); use `fmt --embed` to reformat without changing format.\n", .{});
return ArgError.MissingConvertArgument;
}
const file_path = if (positionals.items.len > 0) positionals.items[0] else "-";
const detected_input: ?Detected = if (!requested_help) detectLanguageFromFileEnding(file_path) else null;
if (!requested_help and to_embed_override != null) {
// Embed-archetype mode: `from`/`to`/`detect` are unused — the
// source and target archetypes fix both formats. The source
// archetype is never pinned by the extension alone (`.md` only
// implies SOME embed, not which one) — an explicit `--embed`
// wins, else `detect_embed` sniffs it from the content at runtime.
const embed = embed_override;
config.options = .{ .convert = .{
.file = file_path,
.requested_help = requested_help,
.to_embed = to_embed_override,
.embed = embed,
.detect_embed = embed == null,
.lax_tags = lax_tags,
.lossless = lossless,
.serialize = serialize,
.quiet = quiet,
.strict = strict,
.write = write,
.diff = diff_mode,
} };
} else {
// Whole-file mode. A host document whose extension implies an
// embed (currently only `.md`/`.markdown`) can't be converted
// whole without either destroying its prose or guessing at a
// fence convention for `--output`'s format — point the user at
// `--to-embed` instead, unless they passed an explicit `--input`
// that overrides the extension's guess entirely.
if (!requested_help and input_override == null) {
if (detected_input) |d| if (d.embed_detect) {
log.err("{s} is a host document (embedded config detected); use --to-embed <archetype> to convert its embedded region, or pass --input explicitly to force whole-file conversion.\n", .{file_path});
return ArgError.MissingConvertArgument;
};
}
const needs_detect = !requested_help and input_override == null and detected_input == null;
const from = input_override orelse (if (detected_input) |d| d.format else null) orelse .json;
config.options = .{ .convert = .{
.file = file_path,
.requested_help = requested_help,
.from = from,
.to = output_override orelse .json,
.detect = needs_detect,
.lax_tags = lax_tags,
.lossless = lossless,
.serialize = serialize,
.quiet = quiet,
.strict = strict,
.write = write,
.diff = diff_mode,
} };
}
} else if (std.mem.eql(u8, action_str, "patch") or std.mem.eql(u8, action_str, "p")) {
config.action = .patch;
var input_override: ?Format = null;
var patch_input_override: ?Format = null;
var embed_override: ?fig.Embed.Type = null;
var patch_embed_override: ?fig.Embed.Type = null;
var at: []fig.AST.PathSegment = &.{};
var from: []fig.AST.PathSegment = &.{};
var deletes: std.ArrayList(fig.Patch.Deletion) = .empty;
defer deletes.deinit(allocator);
var patch_options: fig.Patch.Options = .{};
var lossless = false;
var dry_run = false;
var diff_mode = false;
var quiet = false;
var requested_help = false;
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
requested_help = true;
} else if (std.mem.eql(u8, arg, "--dry-run")) {
dry_run = true;
} else if (std.mem.eql(u8, arg, "--diff")) {
diff_mode = true;
} else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) {
quiet = true;
} else if (std.mem.eql(u8, arg, "--lossless")) {
lossless = true;
} else if (std.mem.eql(u8, arg, "--lossy")) {
lossless = false;
} else if (std.mem.eql(u8, arg, "--compact")) {
patch_options.serialize.pretty = false;
} else if (std.mem.eql(u8, arg, "--pretty")) {
patch_options.serialize.pretty = true;
} else if (std.mem.eql(u8, arg, "--at")) {
const p = args.next() orelse {
log.err("Missing path after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
at = try parsePath(allocator, p);
} else if (std.mem.eql(u8, arg, "--from")) {
const p = args.next() orelse {
log.err("Missing path after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
from = try parsePath(allocator, p);
} else if (std.mem.eql(u8, arg, "--delete")) {
const p = args.next() orelse {
log.err("Missing path after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
const parsed_path = try parsePath(allocator, p);
if (parsed_path.len == 0) {
log.err("--delete needs a path within the document; the root cannot be deleted.\n", .{});
return ArgError.MissingPatchArgument;
}
try deletes.append(allocator, parsed_path);
} else if (std.mem.eql(u8, arg, "--seq")) {
const name = args.next() orelse {
log.err("Missing strategy after {s} (replace, append, union)\n", .{arg});
return ArgError.MissingPatchArgument;
};
// `union` is a Zig keyword, so the enum member is `unite`;
// the CLI keeps the word a user would reach for.
patch_options.seq = if (std.mem.eql(u8, name, "union"))
.unite
else
std.meta.stringToEnum(fig.Patch.SeqStrategy, name) orelse {
log.err("Unknown --seq strategy: {s} (replace, append, union)\n", .{name});
return ArgError.MissingPatchArgument;
};
} else if (std.mem.eql(u8, arg, "--comments")) {
const name = args.next() orelse {
log.err("Missing strategy after {s} (ours, theirs, none)\n", .{arg});
return ArgError.MissingPatchArgument;
};
patch_options.comments = std.meta.stringToEnum(fig.Patch.CommentStrategy, name) orelse {
log.err("Unknown --comments strategy: {s} (ours, theirs, none)\n", .{name});
return ArgError.MissingPatchArgument;
};
} else if (std.mem.eql(u8, arg, "--indent")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
patch_options.serialize.indent = std.fmt.parseInt(u8, n, 10) catch {
log.err("Invalid --indent value: {s}\n", .{n});
return ArgError.MissingPatchArgument;
};
patch_options.serialize.fig_indent = true;
} else if (std.mem.eql(u8, arg, "--width")) {
const n = args.next() orelse {
log.err("Missing value after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
patch_options.serialize.width = std.fmt.parseInt(u16, n, 10) catch {
log.err("Invalid --width value: {s}\n", .{n});
return ArgError.MissingPatchArgument;
};
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const fmt_name = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
input_override = parseFormatName(fmt_name) orelse {
log.err("Unsupported format: {s}\n", .{fmt_name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--patch-input")) {
const fmt_name = args.next() orelse {
log.err("Missing format value after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
patch_input_override = parseFormatName(fmt_name) orelse {
log.err("Unsupported format: {s}\n", .{fmt_name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--patch-embed")) {
const name = args.next() orelse {
log.err("Missing archetype after {s}\n", .{arg});
return ArgError.MissingPatchArgument;
};
patch_embed_override = embedTypeFromName(name) orelse {
log.err("Unknown --patch-embed archetype: {s} (" ++ embed_archetype_names ++ ")\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else {
try positionals.append(allocator, arg);
}
}
if (!requested_help and positionals.items.len < 2) {
log.err("patch takes two files: the document to change, then the one supplying the change.\n", .{});
return ArgError.MissingPatchArgument;
}
if (!requested_help and positionals.items.len > 2) {
log.err("patch takes two files; use --at/--from to name a path within one: {s}\n", .{positionals.items[2]});
return ArgError.MissingPatchArgument;
}
// Both from a pipe would mean reading one stream twice and getting
// half of each; the second read comes back empty and the failure is
// an empty patch, which is silent. Refuse it up front.
const target_path = if (positionals.items.len > 0) positionals.items[0] else "-";
const source_path = if (positionals.items.len > 1) positionals.items[1] else "-";
if (!requested_help and std.mem.eql(u8, target_path, "-") and std.mem.eql(u8, source_path, "-")) {
log.err("only one of the two files can be stdin.\n", .{});
return ArgError.MissingPatchArgument;
}
const target_ext: ?Detected = if (!requested_help) detectLanguageFromFileEnding(target_path) else null;
const source_ext: ?Detected = if (!requested_help) detectLanguageFromFileEnding(source_path) else null;
config.options = .{ .patch = .{
.file = target_path,
.patch_file = source_path,
.at = at,
.from = from,
.deletes = try deletes.toOwnedSlice(allocator),
.patch_options = patch_options,
.requested_help = requested_help,
.format = input_override orelse (if (target_ext) |d| d.format else null) orelse .json,
.detect = !requested_help and input_override == null and target_ext == null,
.patch_format = patch_input_override orelse (if (source_ext) |d| d.format else null) orelse .json,
.detect_patch = !requested_help and patch_input_override == null and source_ext == null,
.embed = embed_override,
.detect_embed = embed_override == null and if (target_ext) |d| d.embed_detect else false,
.patch_embed = patch_embed_override,
.detect_patch_embed = patch_embed_override == null and if (source_ext) |d| d.embed_detect else false,
.lossless = lossless,
.dry_run = dry_run,
.diff = diff_mode,
.quiet = quiet,
} };
} else if (std.mem.eql(u8, action_str, "lang")) {
config.action = .lang;
var opts: types.LangOptions = .{};
var positionals: std.ArrayList([]const u8) = .empty;
defer positionals.deinit(allocator);
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
opts.requested_help = true;
} else if (std.mem.eql(u8, arg, "--against")) {
opts.against = args.next() orelse {
log.err("Missing format after --against\n", .{});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--input") or std.mem.eql(u8, arg, "-i")) {
const name = args.next() orelse {
log.err("Missing format after --input\n", .{});
return ArgError.UnsupportedFileFormat;
};
opts.input = parseFormatName(name) orelse {
log.err("Unsupported input format: {s}\n", .{name});
return ArgError.UnsupportedFileFormat;
};
} else if (std.mem.eql(u8, arg, "--spec") or std.mem.eql(u8, arg, "-s")) {
opts.spec = args.next() orelse {
log.err("Missing version value after {s}\n", .{arg});
return ArgError.MissingCheckArgument;
};
} else {
try positionals.append(allocator, arg);
}
}
if (positionals.items.len == 0 or std.mem.eql(u8, positionals.items[0], "list")) {
opts.verb = .list;
} else if (std.mem.eql(u8, positionals.items[0], "check")) {
opts.verb = .check;
if (positionals.items.len < 2) {
if (!opts.requested_help) log.err("lang check needs a language name (e.g. `fig lang check lua-dotenv --against dotenv`).\n", .{});
opts.requested_help = true;
} else {
opts.name = positionals.items[1];
opts.files = try allocator.dupe([]const u8, positionals.items[2..]);
}
} else if (std.mem.eql(u8, positionals.items[0], "table")) {
opts.verb = .table;
if (positionals.items.len < 2) {
if (!opts.requested_help) log.err("lang table needs a file (e.g. `fig lang table secrets.env`).\n", .{});
opts.requested_help = true;
} else {
opts.name = positionals.items[1];
}
} else {
log.err("Unknown lang verb: {s} (list, check, table)\n", .{positionals.items[0]});
opts.requested_help = true;
}
config.options = .{ .lang = opts };
} else if (externalCommandName(action_str)) |name| {
// Git's fallback, and the reason fig-schema can grow a CLI without
// fig growing a `schema` action: a word fig has no verb for is handed
// to a `fig-<word>` executable. Everything after the word is passed
// through untouched — see `ExternalOptions.argv` — so this branch
// consumes the rest of the iterator and parses none of it.
config.action = .external;
const program = try std.fmt.allocPrint(allocator, "fig-{s}", .{name});
var argv: std.ArrayList([]const u8) = .empty;
try argv.append(allocator, program);
while (args.next()) |arg| try argv.append(allocator, arg);
config.options = .{ .external = .{
.name = name,
.program = program,
.argv = try argv.toOwnedSlice(allocator),
} };
} else {
// Not one of fig's verbs, and not a word that could name a program
// either (`externalCommandName` says which shapes those are). Still
// an `.external`: a null `program` means the handoff was never on the
// table, so one place gets to say "no such action" for both halves of
// the unrecognized case rather than two places saying it differently.
config.action = .external;
config.options = .{ .external = .{ .name = action_str, .program = null, .argv = &.{} } };
}
return config;
}
/// The `<name>` for a `fig-<name>` handoff, or null if this word can't be one
/// — in which case it stays an ordinary unrecognized action.
///
/// The rule is deliberately narrower than "whatever `execvp` would take".
/// Restricting it to ASCII letters, digits, `-` and `_` keeps two shapes out:
/// a leading `-`, so a mistyped flag (`fig --colour`) reports itself as a flag
/// instead of hunting for `fig---colour`; and anything containing a path
/// separator or a `.`, so a forgotten verb (`fig config.toml`, `fig ../x`)
/// can't reach the filesystem as a program name. That second one is the load-
/// bearing half: `argv[0]` with a separator in it bypasses PATH lookup
/// entirely and runs a file at that path, which is not something a typo should
/// be able to do.
fn externalCommandName(word: []const u8) ?[]const u8 {
if (word.len == 0 or word[0] == '-') return null;
for (word) |c| switch (c) {
'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {},
else => return null,
};
return word;
}
// A slice-backed stand-in for the process arg iterator `parseConfig` consumes.
const TestArgs = struct {
items: []const []const u8,
i: usize = 0,
fn next(self: *TestArgs) ?[]const u8 {
if (self.i >= self.items.len) return null;
defer self.i += 1;
return self.items[self.i];
}
};
test "parsePath reads the [-]/[$] append sentinel and literal indices" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
const dash = try parsePath(a, "list[-]");
try t.expectEqual(@as(usize, 2), dash.len);
try t.expectEqualStrings("list", dash[0].key);
try t.expectEqual(append_index, dash[1].index);
const dollar = try parsePath(a, "list[$]");
try t.expectEqual(append_index, dollar[1].index);
const literal = try parsePath(a, "a.b[2]");
try t.expectEqual(@as(usize, 2), literal[2].index);
}
test "parsePath takes a key with a `.` or `[` quoted, bracketed or escaped" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
for ([_][]const u8{ "a.\"b.c\"", "a.'b.c'", "a[\"b.c\"]", "a['b.c']", "a.b\\.c" }) |spelling| {
const p = try parsePath(a, spelling);
try t.expectEqual(@as(usize, 2), p.len);
try t.expectEqualStrings("a", p[0].key);
try t.expectEqualStrings("b.c", p[1].key);
}
// A quoted segment is followed by more path, and holds a `[` too.
const deeper = try parsePath(a, "\"x[0]\".y[1]");
try t.expectEqualStrings("x[0]", deeper[0].key);
try t.expectEqualStrings("y", deeper[1].key);
try t.expectEqual(@as(usize, 1), deeper[2].index);
// Double quotes decode JSON's escapes, which is how gron prints a key.
const esc = try parsePath(a, "[\"q\\\"\\u00e9\\ud83d\\ude00\"]");
try t.expectEqualStrings("q\"\u{e9}\u{1f600}", esc[0].key);
// Single quotes are verbatim.
const lit = try parsePath(a, "'a\\n'");
try t.expectEqualStrings("a\\n", lit[0].key);
// An unclosed quote, a quote run into more key, and a trailing
// backslash are refused.
for ([_][]const u8{ "a.\"b", "a.'b", "a.\"b\"c", "a\\", "[\"b\"", "[\"\\x\"]" }) |bad|
try t.expectError(ArgError.InvalidPath, parsePath(a, bad));
}
test "parseConfig routes insert/delete to the right action and path tail" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// insert into a mapping: trailing key, value captured.
var ins = TestArgs{ .items = &.{ "fig", "insert", "f.yaml", "a.newkey", "42" } };
const ic = try parseConfig(a, &ins);
try t.expectEqual(CliAction.insert, ic.action);
try t.expectEqualStrings("newkey", ic.options.insert.path[1].key);
try t.expectEqualStrings("42", ic.options.insert.value);
try t.expectEqual(Format.yaml, ic.options.insert.format);
// insert append onto a sequence: trailing sentinel index.
var app = TestArgs{ .items = &.{ "fig", "insert", "f.yaml", "list[-]", "z" } };
const ac = try parseConfig(a, &app);
try t.expectEqual(append_index, ac.options.insert.path[1].index);
// delete by index: format sniffed later, path tail is an index.
var del = TestArgs{ .items = &.{ "fig", "delete", "f.toml", "list[1]" } };
const dc = try parseConfig(a, &del);
try t.expectEqual(CliAction.delete, dc.action);
try t.expectEqual(@as(usize, 1), dc.options.delete.path[1].index);
try t.expectEqual(Format.toml, dc.options.delete.format);
}
test "parseTarget names the file a failed action is re-parsed from, and its format" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// An in-place edit: the file and the format its extension named.
var set = TestArgs{ .items = &.{ "fig", "set", "f.yaml", "a", "1" } };
const st = types.parseTarget(try parseConfig(a, &set)).?;
try t.expectEqualStrings("f.yaml", st.file);
try t.expectEqual(@as(?Format, .yaml), st.format);
// A read: its `from` format.
var get = TestArgs{ .items = &.{ "fig", "get", "f.toml" } };
try t.expectEqual(@as(?Format, .toml), types.parseTarget(try parseConfig(a, &get)).?.format);
// A host document: the region is the extension's to find, so no format
// is forced onto the whole file.
var md = TestArgs{ .items = &.{ "fig", "set", "notes.md", "a", "1" } };
try t.expectEqual(@as(?Format, null), types.parseTarget(try parseConfig(a, &md)).?.format);
// stdin has been read once already, and `check` reports for itself.
var stdin = TestArgs{ .items = &.{ "fig", "get", "-", "-i", "json" } };
try t.expect(types.parseTarget(try parseConfig(a, &stdin)) == null);
var check = TestArgs{ .items = &.{ "fig", "check", "f.yaml" } };
try t.expect(types.parseTarget(try parseConfig(a, &check)) == null);
}
test "parseConfig hands an unknown action to fig-<action> with its arguments untouched" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// The case this exists for. Note `--help` and `check`: fig owns both
// words, and neither is parsed here — everything after `schema` is the
// other tool's to read.
var ext = TestArgs{ .items = &.{ "fig", "schema", "check", "--help", "-" } };
const ec = try parseConfig(a, &ext);
try t.expectEqual(CliAction.external, ec.action);
try t.expectEqualStrings("schema", ec.options.external.name);
try t.expectEqualStrings("fig-schema", ec.options.external.program.?);
try t.expectEqual(@as(usize, 4), ec.options.external.argv.len);
try t.expectEqualStrings("fig-schema", ec.options.external.argv[0]);
try t.expectEqualStrings("check", ec.options.external.argv[1]);
try t.expectEqualStrings("--help", ec.options.external.argv[2]);
try t.expectEqualStrings("-", ec.options.external.argv[3]);
// The program name is `fig-`, not `argv[0]-`: what fig was invoked as
// says nothing about what its siblings are installed as.
var qualified = TestArgs{ .items = &.{ "./zig-out/bin/fig", "schema" } };
const qc = try parseConfig(a, &qualified);
try t.expectEqualStrings("fig-schema", qc.options.external.program.?);
// A word that can't name a program is still `.external` — with a null
// `program`, so the report knows PATH was never searched.
for ([_][]const u8{ "config.toml", "--colour", "../evil", "a/b" }) |word| {
var bad = TestArgs{ .items = &.{ "fig", word } };
const bc = try parseConfig(a, &bad);
try t.expectEqual(CliAction.external, bc.action);
try t.expectEqualStrings(word, bc.options.external.name);
try t.expectEqual(@as(?[]const u8, null), bc.options.external.program);
}
// ...and an action fig does own never gets there, alias included.
var owned = TestArgs{ .items = &.{ "fig", "ck", "f.yaml" } };
try t.expectEqual(CliAction.check, (try parseConfig(a, &owned)).action);
}
test "externalCommandName accepts only words that could name a program" {
const t = std.testing;
try t.expectEqualStrings("schema", externalCommandName("schema").?);
try t.expectEqualStrings("two-words_9", externalCommandName("two-words_9").?);
// A leading `-` is a mistyped flag, not a subcommand.
try t.expectEqual(@as(?[]const u8, null), externalCommandName("-x"));
try t.expectEqual(@as(?[]const u8, null), externalCommandName("--colour"));
// Anything with a separator or a dot is a path or a filename the user
// meant to give an action. A separator in argv[0] would skip PATH lookup
// and execute that file, which no typo should be able to do.
try t.expectEqual(@as(?[]const u8, null), externalCommandName("./x"));
try t.expectEqual(@as(?[]const u8, null), externalCommandName("../../tmp/x"));
try t.expectEqual(@as(?[]const u8, null), externalCommandName("a\\b"));
try t.expectEqual(@as(?[]const u8, null), externalCommandName("config.toml"));
try t.expectEqual(@as(?[]const u8, null), externalCommandName(""));
}
test "embedTypeFromName maps archetype names" {
const t = std.testing;
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .yaml }), embedTypeFromName("frontmatter"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .yaml }), embedTypeFromName("frontmatter-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, .semicolons_json), embedTypeFromName("frontmatter-json"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .fig }), embedTypeFromName("frontmatter-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .toml }), embedTypeFromName("md-toml"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .yaml }), embedTypeFromName("fenced-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, .plus_toml), embedTypeFromName("frontmatter-toml"));
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), embedTypeFromName("endmatter"));
try t.expectEqual(@as(?fig.Embed.Type, null), embedTypeFromName("bogus"));
// The parametric families are derived over the format registry, so the
// ACCEPTED SET is what needs pinning rather than any one row: all four
// formats in each of the three `<container>-<format>` families, plus the
// three-of-four `md-<format>` one, and nothing else.
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .json }), embedTypeFromName("fenced-json"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .toml }), embedTypeFromName("fenced-toml"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .fig }), embedTypeFromName("fenced-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .json }), embedTypeFromName("md-json"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .fig }), embedTypeFromName("md-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_script = .fig }), embedTypeFromName("html-script"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_script = .fig }), embedTypeFromName("html-script-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_script = .yaml }), embedTypeFromName("html-script-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_script = .json }), embedTypeFromName("html-script-json"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_script = .toml }), embedTypeFromName("html-script-toml"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_code = .fig }), embedTypeFromName("html-code"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_code = .fig }), embedTypeFromName("html-code-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_code = .yaml }), embedTypeFromName("html-code-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_code = .json }), embedTypeFromName("html-code-json"));
try t.expectEqual(@as(?fig.Embed.Type, .{ .html_code = .toml }), embedTypeFromName("html-code-toml"));
try t.expectEqual(@as(?fig.Embed.Type, .semicolons_json), embedTypeFromName("semicolons"));
try t.expectEqual(@as(?fig.Embed.Type, .plus_toml), embedTypeFromName("plus"));
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), embedTypeFromName("endmatter-yaml"));
// Deliberately NOT spellings: `md-yaml` (a bare `---` block IS YAML
// frontmatter — `frontmatter`/`frontmatter-yaml` name it), and the
// container names that never had a bare form.
try t.expectEqual(@as(?fig.Embed.Type, null), embedTypeFromName("md-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, null), embedTypeFromName("fenced"));
try t.expectEqual(@as(?fig.Embed.Type, null), embedTypeFromName("md"));
}
test "detectLanguageFromFileEnding: .md/.markdown defer the archetype to a runtime sniff" {
// The extension table is derived from each compiled-in language's own
// `extensions`, so a gated-out language contributes none — `.yaml`/`.figl`
// below simply have no entry in a build without YAML/fig.
if (comptime !(build_options.lang_yaml and build_options.lang_fig)) return error.SkipZigTest;
const t = std.testing;
const md = detectLanguageFromFileEnding("post.md").?;
try t.expectEqual(Format.yaml, md.format);
try t.expect(md.embed_detect);
const markdown = detectLanguageFromFileEnding("post.markdown").?;
try t.expect(markdown.embed_detect);
// Other extensions imply no embed at all.
const yaml = detectLanguageFromFileEnding("f.yaml").?;
try t.expect(!yaml.embed_detect);
const figl_ext = detectLanguageFromFileEnding("f.figl").?;
try t.expectEqual(Format.fig, figl_ext.format);
try t.expect(!figl_ext.embed_detect);
// `.fig` remains accepted for back-compat.
const fig_ext = detectLanguageFromFileEnding("f.fig").?;
try t.expectEqual(Format.fig, fig_ext.format);
try t.expect(!fig_ext.embed_detect);
}
test "yml is an accepted spelling of yaml, as a --input value and as an extension" {
const t = std.testing;
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// `--input yml`: the alias line above, not an enum member of its own.
try t.expectEqual(@as(?Format, .yaml), parseFormatName("yml"));
try t.expectEqual(@as(?Format, .yaml), parseFormatName("yaml"));
// `.yml` files: `stringToEnum` no longer answers for the extension, so it
// falls through to `Language.YAML.extensions`, which owns both spellings —
// the same `.yaml` parse either way.
const yml_ext = detectLanguageFromFileEnding("f.yml").?;
try t.expectEqual(Format.yaml, yml_ext.format);
try t.expect(!yml_ext.embed_detect);
try t.expectEqual(Format.yaml, detectLanguageFromFileEnding("f.yaml").?.format);
// An unknown token is still unknown (the alias must not widen the lookup).
try t.expectEqual(@as(?Format, null), parseFormatName("yamll"));
}
test "resolveEmbedTypeFromContent: explicit override wins, else sniffs, else falls back to YAML" {
const t = std.testing;
// An explicit override always wins, regardless of content.
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), resolveEmbedTypeFromContent("anything", .endmatter_yaml, true));
// Not a detect_embed case at all (e.g. a plain .json file): no embed.
try t.expectEqual(@as(?fig.Embed.Type, null), resolveEmbedTypeFromContent("{}", null, false));
// detect_embed sniffs the real archetype from the bytes — this is the
// fig-frontmatter regression: a `.md` file whose actual content is a
// ```fig fenced block must resolve to FrontmatterFig, not be assumed to
// be YAML just because the extension is `.md`.
try t.expectEqual(
@as(?fig.Embed.Type, .{ .fenced = .fig }),
resolveEmbedTypeFromContent("```fig\ntitle = hi\n```\nbody\n", null, true),
);
try t.expectEqual(
@as(?fig.Embed.Type, .semicolons_json),
resolveEmbedTypeFromContent(";;;\n{\"a\":1}\n;;;\nbody\n", null, true),
);
try t.expectEqual(
@as(?fig.Embed.Type, .{ .frontmatter = .yaml }),
resolveEmbedTypeFromContent("---\na: 1\n---\nbody\n", null, true),
);
// Nothing detected at all (e.g. a brand-new/plain host file): falls back
// to the historical FrontmatterYaml default rather than `null`, so `set`'s
// open-or-init still seeds the same archetype it always has.
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .yaml }), resolveEmbedTypeFromContent("just prose\n", null, true));
try t.expectEqual(@as(?fig.Embed.Type, .{ .frontmatter = .yaml }), resolveEmbedTypeFromContent("", null, true));
}
test "parseConfig routes patch: both files, both sides' formats, and the merge strategies" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// Two positionals, in order: the file being changed, then the one
// supplying the change. Each side resolves its own format from its own
// extension, so a cross-format patch needs no flags at all.
var basic = TestArgs{ .items = &.{ "fig", "patch", "config.yaml", "overlay.toml" } };
const b = try parseConfig(a, &basic);
try t.expectEqual(CliAction.patch, b.action);
try t.expectEqualStrings("config.yaml", b.options.patch.file);
try t.expectEqualStrings("overlay.toml", b.options.patch.patch_file);
try t.expectEqual(Format.yaml, b.options.patch.format);
try t.expectEqual(Format.toml, b.options.patch.patch_format);
try t.expect(!b.options.patch.detect);
try t.expect(!b.options.patch.detect_patch);
// The defaults are the conservative ones: the target's comments stand and
// a sequence is taken whole.
try t.expectEqual(fig.Patch.SeqStrategy.replace, b.options.patch.patch_options.seq);
try t.expectEqual(fig.Patch.CommentStrategy.ours, b.options.patch.patch_options.comments);
// `--at`/`--from` are separate paths into separate documents.
var paths = TestArgs{ .items = &.{ "fig", "patch", "a.yaml", "b.yaml", "--at", "service.db", "--from", "db" } };
const p = try parseConfig(a, &paths);
try t.expectEqual(@as(usize, 2), p.options.patch.at.len);
try t.expectEqualStrings("service", p.options.patch.at[0].key);
try t.expectEqualStrings("db", p.options.patch.at[1].key);
try t.expectEqual(@as(usize, 1), p.options.patch.from.len);
try t.expectEqualStrings("db", p.options.patch.from[0].key);
// `--delete` accumulates; `union` is the CLI spelling of `.unite` (the
// enum can't be called `union`).
var strat = TestArgs{ .items = &.{
"fig", "patch", "a.yaml", "b.yaml",
"--seq", "union", "--comments", "theirs",
"--delete", "x.y", "--delete", "z[2]",
} };
const s = try parseConfig(a, &strat);
try t.expectEqual(fig.Patch.SeqStrategy.unite, s.options.patch.patch_options.seq);
try t.expectEqual(fig.Patch.CommentStrategy.theirs, s.options.patch.patch_options.comments);
try t.expectEqual(@as(usize, 2), s.options.patch.deletes.len);
try t.expectEqualStrings("x", s.options.patch.deletes[0][0].key);
try t.expectEqual(@as(usize, 2), s.options.patch.deletes[1][1].index);
// A `.md` on either side implies SOME embedded region but never which
// archetype, so each side defers to its own runtime sniff — the same rule
// the other actions follow.
var md = TestArgs{ .items = &.{ "fig", "patch", "post.md", "meta.md" } };
const m = try parseConfig(a, &md);
try t.expect(m.options.patch.detect_embed);
try t.expect(m.options.patch.detect_patch_embed);
try t.expectEqual(@as(?fig.Embed.Type, null), m.options.patch.embed);
// An explicit archetype pins one side without touching the other.
var pinned = TestArgs{ .items = &.{ "fig", "patch", "post.md", "meta.md", "--embed", "endmatter" } };
const pin = try parseConfig(a, &pinned);
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), pin.options.patch.embed);
try t.expect(!pin.options.patch.detect_embed);
try t.expect(pin.options.patch.detect_patch_embed);
// Unknown extensions on both sides defer both formats to a content sniff.
var sniff = TestArgs{ .items = &.{ "fig", "patch", "a.weird", "-" } };
const sn = try parseConfig(a, &sniff);
try t.expect(sn.options.patch.detect);
try t.expect(sn.options.patch.detect_patch);
// The rejections (fewer or more than two files, both files stdin, an
// unknown --seq/--comments strategy, a rootless --delete) all return
// `ArgError.MissingPatchArgument` after a `log.err`, which this test
// binary's runner counts as a failure regardless — see the note in the
// `convert` test above.
}
test "parseConfig routes set, --seq, and --embed" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// Scalar upsert: path + value captured, format from extension.
var s = TestArgs{ .items = &.{ "fig", "set", "f.yaml", "a.b", "1" } };
const sc = try parseConfig(a, &s);
try t.expectEqual(CliAction.set, sc.action);
try t.expectEqualStrings("b", sc.options.set.path[1].key);
try t.expectEqualStrings("1", sc.options.set.value);
try t.expect(!sc.options.set.seq);
try t.expectEqual(Format.yaml, sc.options.set.format);
// --seq collects the trailing items into `values`.
var sq = TestArgs{ .items = &.{ "fig", "set", "--seq", "f.yaml", "tags", "x", "y", "z" } };
const sqc = try parseConfig(a, &sq);
try t.expect(sqc.options.set.seq);
try t.expectEqual(@as(usize, 3), sqc.options.set.values.len);
try t.expectEqualStrings("z", sqc.options.set.values[2]);
// --embed selects the archetype explicitly (endmatter here).
var em = TestArgs{ .items = &.{ "fig", "set", "--embed", "endmatter", "post.md", "k", "v" } };
const emc = try parseConfig(a, &em);
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), emc.options.set.embed);
// --embed frontmatter-fig routes to the fig-fenced archetype.
var fm = TestArgs{ .items = &.{ "fig", "set", "--embed", "frontmatter-fig", "post.md", "k", "v" } };
const fmc = try parseConfig(a, &fm);
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .fig }), fmc.options.set.embed);
// No --embed on a `.md` file: the fix for the fig-frontmatter
// autodetection bug — `embed` stays null and `detect_embed` fires, so
// the handler sniffs the actual archetype from the file's bytes at
// runtime instead of the extension alone assuming YAML frontmatter.
var md = TestArgs{ .items = &.{ "fig", "set", "post.md", "k", "v" } };
const mdc = try parseConfig(a, &md);
try t.expectEqual(@as(?fig.Embed.Type, null), mdc.options.set.embed);
try t.expect(mdc.options.set.detect_embed);
}
test "parseConfig routes convert: whole-file mode, embed mode, and their guards" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
// Whole-file mode: --input/--output resolve `from`/`to` directly.
var wf = TestArgs{ .items = &.{ "fig", "convert", "-i", "yaml", "-o", "toml", "f.yaml" } };
const wfc = try parseConfig(a, &wf);
try t.expectEqual(CliAction.convert, wfc.action);
try t.expectEqual(Format.yaml, wfc.options.convert.from);
try t.expectEqual(Format.toml, wfc.options.convert.to);
try t.expectEqual(@as(?fig.Embed.Type, null), wfc.options.convert.to_embed);
try t.expect(!wfc.options.convert.detect);
// Whole-file mode with an unrecognized extension: `--output` alone still
// needs `from` sniffed at runtime.
var det = TestArgs{ .items = &.{ "fig", "convert", "-o", "json", "f.weirdext" } };
const detc = try parseConfig(a, &det);
try t.expect(detc.options.convert.detect);
// Embed mode: --to-embed alone (no --embed) defers source detection to
// the handler (`detect_embed`); the file extension doesn't imply an
// archetype here (not .md), so `embed` stays null.
var em = TestArgs{ .items = &.{ "fig", "convert", "--to-embed", "frontmatter-json", "f.txt" } };
const emc = try parseConfig(a, &em);
try t.expectEqual(@as(?fig.Embed.Type, .semicolons_json), emc.options.convert.to_embed);
try t.expectEqual(@as(?fig.Embed.Type, null), emc.options.convert.embed);
try t.expect(emc.options.convert.detect_embed);
// Embed mode on a `.md` file: the extension alone only implies SOME
// embedded region, never which archetype — `embed` stays null and
// `detect_embed` fires so the handler sniffs the actual fences at
// runtime instead of assuming YAML frontmatter outright.
var md = TestArgs{ .items = &.{ "fig", "convert", "--to-embed", "frontmatter-json", "post.md" } };
const mdc = try parseConfig(a, &md);
try t.expectEqual(@as(?fig.Embed.Type, null), mdc.options.convert.embed);
try t.expect(mdc.options.convert.detect_embed);
// Embed mode: explicit --embed overrides the extension default.
var ov = TestArgs{ .items = &.{ "fig", "convert", "--embed", "endmatter", "--to-embed", "frontmatter-fig", "post.md" } };
const ovc = try parseConfig(a, &ov);
try t.expectEqual(@as(?fig.Embed.Type, .endmatter_yaml), ovc.options.convert.embed);
try t.expectEqual(@as(?fig.Embed.Type, .{ .fenced = .fig }), ovc.options.convert.to_embed);
// The four guard rejections (no target at all; --output+--to-embed
// together; --embed without --to-embed; whole-file --output on a `.md`
// host document without an explicit --input) all return
// `ArgError.MissingConvertArgument` after a `log.err` — verified manually
// against the built CLI rather than here, since this test binary's
// default runner (Zig 0.16) fails any test that logs at `.err`
// regardless of whether the returned error was expected (see
// `test_runner.zig`'s `log_err_count`), the same reason no other
// `parseConfig` error path in this file is exercised as a unit test.
// An explicit --input forces whole-file conversion on a `.md` file anyway.
var mdforced = TestArgs{ .items = &.{ "fig", "convert", "-i", "yaml", "-o", "toml", "post.md" } };
const mdforcedc = try parseConfig(a, &mdforced);
try t.expectEqual(Format.yaml, mdforcedc.options.convert.from);
try t.expect(!mdforcedc.options.convert.detect);
}
test "parseConfig: --lang is taken from anywhere in the line and names the format" {
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 args = TestArgs{ .items = &.{ "fig", "get", "secrets.env", "--lang", "json" } };
const config = try parseConfig(a, &args);
try t.expectEqual(types.CliAction.get, config.action);
try t.expectEqualStrings("secrets.env", config.options.get.file);
try t.expectEqual(Format.json, languages.langOverride().?);
// The override is what the extension would otherwise decide.
try t.expectEqual(Format.json, detectLanguageFromFileEnding("secrets.env").?.format);
languages.setLangOverride(.yaml);
try t.expectEqual(Format.yaml, detectLanguageFromFileEnding("secrets.env").?.format);
// `--lang nosuch` and a bare `--lang` return `UnsupportedFileFormat`
// after a `log.err` — the test runner counts a logged error as a
// failure, so that path is proven by `tools/cli-lang-check.sh` instead.
languages.setLangOverride(null);
}
test "parseConfig routes lang: list by default, check with a name, --against and files, table with -i" {
const t = std.testing;
var arena = std.heap.ArenaAllocator.init(t.allocator);
defer arena.deinit();
const a = arena.allocator();
var bare = TestArgs{ .items = &.{ "fig", "lang" } };
const c1 = try parseConfig(a, &bare);
try t.expectEqual(types.CliAction.lang, c1.action);
try t.expectEqual(types.LangOptions.Verb.list, c1.options.lang.verb);
var check = TestArgs{ .items = &.{ "fig", "lang", "check", "lua-dotenv", "--against", "dotenv", "a.env", "b.env" } };
const c2 = try parseConfig(a, &check);
try t.expectEqual(types.LangOptions.Verb.check, c2.options.lang.verb);
try t.expectEqualStrings("lua-dotenv", c2.options.lang.name);
try t.expectEqualStrings("dotenv", c2.options.lang.against.?);
try t.expectEqual(@as(usize, 2), c2.options.lang.files.len);
try t.expectEqualStrings("b.env", c2.options.lang.files[1]);
var table = TestArgs{ .items = &.{ "fig", "lang", "table", "secrets.env", "-i", "json" } };
const ct = try parseConfig(a, &table);
try t.expectEqual(types.LangOptions.Verb.table, ct.options.lang.verb);
try t.expectEqualStrings("secrets.env", ct.options.lang.name);
try t.expectEqual(Format.json, ct.options.lang.input.?);
try t.expect(ct.options.lang.spec == null);
var versioned = TestArgs{ .items = &.{ "fig", "lang", "table", "a.yaml", "--spec", "1.1" } };
const cv = try parseConfig(a, &versioned);
try t.expectEqualStrings("1.1", cv.options.lang.spec.?);
// `check --help` asks for the help text without a name.
var helpful = TestArgs{ .items = &.{ "fig", "lang", "check", "--help" } };
const c3 = try parseConfig(a, &helpful);
try t.expect(c3.options.lang.requested_help);
}