//! 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 gron = @import("gron.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. Returns null
/// for an unknown name so callers can emit a tailored error.
pub fn parseFormatName(name: []const u8) ?Format {
return std.meta.stringToEnum(Format, name);
}
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;
},
'[' => {
// 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;
},
else => {
const start = i;
// Loop until a dot or open bracket
while (i < path.len and path[i] != '.' and path[i] != '[') : (i += 1) {}
if (i == start) return ArgError.InvalidPath;
const key = path[start..i];
log.debug("key: {s}", .{key});
try path_in_progress.append(allocator, .{ .key = key });
},
}
}
return path_in_progress.toOwnedSlice(allocator);
}
/// 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 {
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 };
}
// `.figl` is the authoring dialect's canonical extension; `.fig` is
// still accepted for back-compat. (The canonical form deliberately owns
// no extension; select it with `--input canonical`.)
if (std.mem.eql(u8, ext, "figl")) return .{ .format = .fig };
if (std.mem.eql(u8, ext, "fig")) return .{ .format = .fig };
const format = std.meta.stringToEnum(Format, ext) orelse return null;
return .{ .format = format };
}
/// 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 {
if (std.mem.eql(u8, name, "frontmatter") or std.mem.eql(u8, name, "frontmatter-yaml"))
return .FrontmatterYaml;
if (std.mem.eql(u8, name, "frontmatter-json")) return .FrontmatterJson;
if (std.mem.eql(u8, name, "frontmatter-fig")) return .FrontmatterFig;
if (std.mem.eql(u8, name, "endmatter") or std.mem.eql(u8, name, "endmatter-yaml"))
return .EndmatterYaml;
return null;
}
/// 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).
pub fn embedFormat(t: fig.Embed.Type) Format {
return switch (fig.Embed.innerFormat(t)) {
.yaml => .yaml,
.json => .json,
.fig => .fig,
};
}
/// 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 .FrontmatterYaml;
}
/// 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 .FrontmatterYaml;
}
pub fn parseConfig(allocator: std.mem.Allocator, args: anytype) ArgError!CliConfig {
const log = std.log.scoped(.parseConfig);
var config = CliConfig{};
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} (frontmatter, frontmatter-json, endmatter)\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} (frontmatter, frontmatter-json, endmatter)\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;
};
if (std.mem.eql(u8, fmt, "json")) {
input_override = .json;
} else if (std.mem.eql(u8, fmt, "jsonc")) {
input_override = .jsonc;
} else if (std.mem.eql(u8, fmt, "json5")) {
input_override = .json5;
} else if (std.mem.eql(u8, fmt, "yaml") or std.mem.eql(u8, fmt, "yml")) {
input_override = .yaml;
} else if (std.mem.eql(u8, fmt, "toml")) {
input_override = .toml;
} else if (std.mem.eql(u8, fmt, "zon")) {
input_override = .zon;
} else if (std.mem.eql(u8, fmt, "xml")) {
input_override = .xml;
} else if (std.mem.eql(u8, fmt, "canonical")) {
input_override = .canonical;
} else if (std.mem.eql(u8, fmt, "fig")) {
input_override = .fig;
} else if (std.mem.eql(u8, fmt, "gron")) {
input_override = .gron;
} else {
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;
};
if (std.mem.eql(u8, fmt, "json")) {
output_override = .json;
} else if (std.mem.eql(u8, fmt, "jsonc")) {
output_override = .jsonc;
} else if (std.mem.eql(u8, fmt, "json5")) {
output_override = .json5;
} else if (std.mem.eql(u8, fmt, "yaml") or std.mem.eql(u8, fmt, "yml")) {
output_override = .yaml;
} else if (std.mem.eql(u8, fmt, "toml")) {
output_override = .toml;
} else if (std.mem.eql(u8, fmt, "zon")) {
output_override = .zon;
} else if (std.mem.eql(u8, fmt, "xml")) {
output_override = .xml;
} else if (std.mem.eql(u8, fmt, "canonical")) {
output_override = .canonical;
} else if (std.mem.eql(u8, fmt, "fig")) {
output_override = .fig;
} else if (std.mem.eql(u8, fmt, "gron")) {
output_override = .gron;
} else {
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} (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.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} (frontmatter, frontmatter-json, frontmatter-fig, endmatter)\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 {
log.err("Action not recognized: {s}", .{action_str});
config.action = .help;
config.options = .{ .help = .{ .requested_help = true } };
}
return config;
}
// 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 "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 "embedTypeFromName maps archetype names" {
const t = std.testing;
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterYaml), embedTypeFromName("frontmatter"));
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterYaml), embedTypeFromName("frontmatter-yaml"));
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterJson), embedTypeFromName("frontmatter-json"));
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterFig), embedTypeFromName("frontmatter-fig"));
try t.expectEqual(@as(?fig.Embed.Type, .EndmatterYaml), embedTypeFromName("endmatter"));
try t.expectEqual(@as(?fig.Embed.Type, null), embedTypeFromName("bogus"));
}
test "detectLanguageFromFileEnding: .md/.markdown defer the archetype to a runtime sniff" {
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 "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, .EndmatterYaml), resolveEmbedTypeFromContent("anything", .EndmatterYaml, 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, .FrontmatterFig),
resolveEmbedTypeFromContent("```fig\ntitle = hi\n```\nbody\n", null, true),
);
try t.expectEqual(
@as(?fig.Embed.Type, .FrontmatterJson),
resolveEmbedTypeFromContent(";;;\n{\"a\":1}\n;;;\nbody\n", null, true),
);
try t.expectEqual(
@as(?fig.Embed.Type, .FrontmatterYaml),
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, .FrontmatterYaml), resolveEmbedTypeFromContent("just prose\n", null, true));
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterYaml), resolveEmbedTypeFromContent("", null, true));
}
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, .EndmatterYaml), 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, .FrontmatterFig), 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, .FrontmatterJson), 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, .EndmatterYaml), ovc.options.convert.embed);
try t.expectEqual(@as(?fig.Embed.Type, .FrontmatterFig), 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);
}