//! fig authoring dialect — block-layer + flow-mode parser. See DESIGN.md for
//! the full grammar; this file implements the "Implied implementation shape":
//! a line splitter (markers/key/value dispatch, inline here rather than a
//! separate pass, since committed values can span many lines), a stack-based
//! block builder (depth counts + header baselines), a recursive-descent flow
//! sub-parser, and the literal-else-string value resolver (`tokenizer.zig`).
//!
//! Construction goes through an intermediate, mutable tree (`PendingContainer`
//! et al.) allocated in a per-parse arena, then converted bottom-up into the
//! immutable `AST` via `AST.Builder` at the end. This makes "navigate to an
//! arbitrary existing path and add a child" (dotted keys, index addressing,
//! header re-entry) tractable without fighting the frozen `Node` array other
//! parsers build directly into.
//!
//! Scope cuts (documented, not silent):
//! * Of DESIGN.md's WARN diagnostics, the comment-depth-mismatch lint is not
//! implemented (needs offsets threaded through `PendingComment`), and the
//! inline-`#`-truncation warn is deliberately skipped (indistinguishable
//! from an ordinary trailing comment). The rest — the coercion warns, the
//! indent/marker-count lint, flow-like strings, missing flow commas — are
//! collected into `Report.warnings` (see `Warning`).
//! * Comment attachment honors a comment line's own marker depth when
//! containers close: a comment at (or below) a closing container's child
//! depth becomes its dangling run; a shallower one stays pending and binds
//! to the next sibling line as its leading run.
//! * Line terminators are LF or CRLF: a `\r` immediately before `\n` (or at
//! EOF) is trivia everywhere — see `atCrlf` — so CRLF documents parse
//! identically to LF ones. A `\r` elsewhere stays ordinary content bytes.
//!
//! Every built node also carries a source `Span` (see "AST assembly" below) —
//! the foundation `Editor(fig.Language.FIG)` needs to splice edits in place
//! (`edit`/`set`/`insert`/`delete`/`comment`; see `editor_helper.zig`).
//! Whole-container structural ops (rename/move/reorder a header, analogous to
//! TOML's `renameTable`/`moveTable`) are not implemented yet.
pub const Parser = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const AST = @import("../../ast/ast.zig");
const Document = @import("../../document.zig");
const Span = @import("../../util/span.zig");
const datetime = @import("../../util/datetime.zig");
const tok = @import("tokenizer.zig");
const Type = @import("fig.zig").Type;
pub const Error = error{
/// A container ended up with children of both shapes (`key = v` AND `*`).
FigMixedContainerChildren,
/// A sequence mixed `[]`/`[i]` addressing with `*` elements.
FigMixedSequenceAddressing,
/// A dotted/index path stepped into an existing scalar key.
FigKeyNotContainer,
/// An assignment or header re-defined an existing leaf key.
FigDuplicateKey,
/// `xs[i]` skipped ahead of the sequence's current length.
FigIndexSkipped,
/// A non-final `[]` referenced an empty sequence.
FigEmptyAppendTarget,
/// An assignment-final `[]` (`xs[] = v`) — `[]` appends only as a header
/// (or as a mid-path "last element" step), never as an assignment target.
FigAppendAssignment,
/// An assignment addressed an index that was already set.
FigIndexAlreadySet,
/// A header/element-opener container closed with zero children.
FigEmptyContainer,
/// A `>` marker with no open header above it.
FigRootMarker,
/// A marker run jumped more than one level deeper than the last.
FigSkippedLevel,
/// A marker run with no whitespace before the key that follows it.
FigBadMarkerSeparator,
/// A key was empty, or a bare key began with a structural character.
FigBadKey,
/// `key: value` (YAML/JSON habit) — `:` introduces a type, not a value.
FigForeignSyntaxColon,
/// `[section]`/`[[x]]` (TOML habit) at line start.
FigForeignSyntaxBracket,
/// A `-` element line (`- v`, `>-`, `> -`) — YAML habit; fig elements are `*`.
FigForeignSyntaxDash,
/// `* key = value` — an element's RHS looked like an inline field.
FigElementInlineField,
/// An assignment/element had no value at all.
FigInvalidValue,
/// A typed value (`: int`, `: bool`, ...) didn't match its annotation.
FigTypeMismatch,
/// An unrecognized `: type` name.
FigUnknownType,
/// Non-comment content survived after a value/header on its line.
FigTrailingContent,
/// A single-line quoted string closed at its matching quote, but more
/// non-comment content followed on the same line — the classic
/// wrapped-a-bare-string-in-unescaped-quotes slip (`k = "She said, "Hi""`).
/// A quote-specific `FigTrailingContent` that names the bare-string fix.
FigQuotedTrailingContent,
/// Non-comment content followed a `'''`/`"""` opening delimiter on its own
/// line (`x = '''abc'''`) — multiline content begins on the NEXT line, so
/// silently ignoring the rest of the opener line would lose data.
FigMultilineOpenerContent,
/// A `+` continuation line with no `[]` append header (or `+`) as the most
/// recent zero-marker structural line — nothing to re-run.
FigDanglingContinuation,
/// A dotted path / header stepped into a container written as a flow value.
/// Flow values are closed (TOML inline-table rule): extending `k = {…}`
/// with a later `k.x = …` reads as mutation of a value that presented
/// itself as complete.
FigClosedFlowValue,
/// A `[`/`{` flow region never found its matching close.
FigUnclosedFlow,
/// One flow object mixed `=` (fig) and `:` (JSON) pair separators.
FigMixedFlowSeparators,
/// A bare key before `:` in a flow object (`{x: 1}`) — JSON pairs require
/// quoted keys; fig pairs use `=`.
FigFlowBareKeyColon,
/// The input bytes are not valid UTF-8 (checked once, up front, before any
/// tokenizing/key parsing — mirrors TOML's `InvalidUtf8`).
FigInvalidUtf8,
} || tok.ScanError;
/// The teaching message for `code` — DESIGN.md's "every diagnostic names the
/// fix": the format teaches its own conventions at the point of failure,
/// because the target user is editing over SSH with no docs open. One sentence,
/// always spelling the valid fig form.
pub fn describe(code: Error) []const u8 {
return switch (code) {
error.FigForeignSyntaxColon => "`:` introduces a type, not a value; write `key = value`, or `key: type = value`",
error.FigFlowBareKeyColon => "a bare key cannot take a `:` pair; write `key = 1` (fig) or `\"key\": 1` (JSON)",
error.FigForeignSyntaxDash => "`-` is YAML's element marker; fig elements are `*` — write `> *` then `> > host = a.com` (a scalar element is `* value`)",
error.FigForeignSyntaxBracket => "`[section]` / `[[x]]` is TOML; fig section headers are bare dotted paths — write `section`, or `x[]` to append an element",
error.FigElementInlineField => "an element's fields go on following lines; write `> *` then `> > host = a.com`, not `* host = a.com`",
error.FigRootMarker => "root keys carry zero markers; remove the `>` (a marker line needs a parent header above it)",
error.FigSkippedLevel => "this line skips a nesting level; depth may only grow one `>` at a time — add the missing parent line, or drop the extra `>`",
error.FigBadMarkerSeparator => "put a space between the marker run and what follows: `> key`, not `>key`",
error.FigBadKey => "empty or malformed key; a bare key cannot contain `.` `:` `=` `[` or whitespace, or begin with `>`/`-` — quote it: `\"my.key\" = x`",
error.FigDuplicateKey => "duplicate key: this key already has a value here; remove one of the definitions (re-enter a header only to add NEW keys)",
error.FigMixedContainerChildren => "a container holds either `key = value` entries or `*` elements, never both",
error.FigMixedSequenceAddressing => "one sequence cannot mix `[]`/`[i]` addressing with `*` element lines; pick one spelling",
error.FigKeyNotContainer => "this path steps into an existing non-container value; remove the extra path segment, or restructure the earlier value",
error.FigIndexSkipped => "sequence indices cannot skip ahead; write the earlier element first (even `[n] = null`)",
error.FigIndexAlreadySet => "this index already has a value; address a new index, or use a header-final `[]` to append",
error.FigEmptyAppendTarget => "a non-final `[]` means \"the last element\", but this sequence is empty; append one first with a header-final `[]`",
error.FigAppendAssignment => "a final `[]` appends a container via a header, never a `= value`; write `key[]` on its own line with `> field = value` lines below, or spell the next index out: `key[N] = value`",
error.FigEmptyContainer => "this container has no children; write an inline empty value instead: `key = {}` (map) or `key = []` (sequence)",
error.FigInvalidValue => "missing value; write one after `=`, or `{}`/`[]` for an empty container",
error.FigTypeMismatch => "the value does not satisfy its `: type` annotation; fix the value, or drop/correct the annotation",
error.FigUnknownType => "unknown type name; the built-in types are int, float, bool, string, enum, char, datetime, date, time",
error.FigTrailingContent => "unexpected content after this line's value or header; quote the whole value if it is one string (a `#` comment needs a space before it)",
error.FigQuotedTrailingContent => "this string ends at its matching quote, and the rest of the line is stray content; fig bare strings need no outer quotes — write `key = She said, \"Hey there!\"`, or escape the inner quotes: `\"She said, \\\"Hey there!\\\"\"`",
error.FigMultilineOpenerContent => "a multiline string's content begins on the line AFTER the opening `'''`/`\"\"\"`; move this text down a line (only a `# comment` may share the opener line)",
error.FigDanglingContinuation => "`+` has no `[]` append header to re-run; move it directly after its `a.b[]` group, or repeat the header",
error.FigClosedFlowValue => "a value written inline as `[…]`/`{…}` is closed and cannot be extended later; write the block or header form if it needs to grow",
error.FigUnclosedFlow => "this `[`/`{` value never finds its matching close; close it, or quote the whole value to make it a string",
error.FigMixedFlowSeparators => "a flow object is fig (`=` pairs) or JSON (`:` pairs), never both in one object",
error.FigUnclosedString => "unclosed string; add the closing quote (a single-line quote cannot span lines — use `'''` for multi-line)",
error.FigBadEscape => "invalid escape; double quotes support \\n \\t \\r \\\\ \\\" \\uXXXX — use single quotes ('…') for raw text with literal backslashes",
error.FigInvalidUtf8 => "this file is not valid UTF-8; fig documents must be UTF-8 encoded",
error.OutOfMemory => "out of memory",
};
}
/// A short (few-word) noun phrase for `code` — distinct from `describe`'s full
/// teaching sentence. Meant to sit next to a caret (`^ duplicate key`), the
/// way rustc annotates a span, so a report scanned at a glance still names the
/// problem even with the longer message above it collapsed/scrolled past.
pub fn shortLabel(code: Error) []const u8 {
return switch (code) {
error.FigForeignSyntaxColon => "not a value assignment",
error.FigFlowBareKeyColon => "bare key needs `=`",
error.FigForeignSyntaxDash => "not a fig element marker",
error.FigForeignSyntaxBracket => "not a fig header",
error.FigElementInlineField => "field on element line",
error.FigRootMarker => "stray `>` marker",
error.FigSkippedLevel => "skipped nesting level",
error.FigBadMarkerSeparator => "missing space after marker",
error.FigBadKey => "invalid key",
error.FigDuplicateKey => "duplicate key",
error.FigMixedContainerChildren => "mixed container children",
error.FigMixedSequenceAddressing => "mixed sequence addressing",
error.FigKeyNotContainer => "not a container",
error.FigIndexSkipped => "skipped index",
error.FigIndexAlreadySet => "index already set",
error.FigEmptyAppendTarget => "append target is empty",
error.FigAppendAssignment => "cannot assign to `[]`",
error.FigEmptyContainer => "empty container",
error.FigInvalidValue => "missing value",
error.FigTypeMismatch => "type mismatch",
error.FigUnknownType => "unknown type",
error.FigTrailingContent => "trailing content",
error.FigQuotedTrailingContent => "trailing content after quote",
error.FigMultilineOpenerContent => "content on multiline opener line",
error.FigDanglingContinuation => "dangling continuation",
error.FigClosedFlowValue => "value is closed",
error.FigUnclosedFlow => "unclosed flow",
error.FigMixedFlowSeparators => "mixed `=`/`:` separators",
error.FigUnclosedString => "unclosed string",
error.FigBadEscape => "invalid escape",
error.FigInvalidUtf8 => "invalid UTF-8",
error.OutOfMemory => "out of memory",
};
}
/// Shared byte-offset → line/col + `file:line:col:` report rendering — the
/// half of the diagnostic system that has nothing to do with fig's own error
/// codes. Every other language's parser (`json`, and `toml`/`yaml` to come)
/// reuses this same module rather than each growing its own copy — see
/// `src/parse_diagnostic.zig`'s doc comment. `Location`/`locateOffset` are
/// re-exported under their original names so existing call sites
/// (`Parser.locateOffset`, `Parser.Location`, in `main.zig`/`lsp/main.zig`)
/// keep working unchanged.
const parse_diagnostic = @import("../../parse_diagnostic.zig");
pub const Location = parse_diagnostic.Location;
pub const locateOffset = parse_diagnostic.locateOffset;
/// The compiler-style report both severities share: `file:line:col: <label>:
/// <message>`, then the offending source line and a caret marking the column.
/// Caller owns the returned bytes.
fn renderReportAlloc(allocator: Allocator, source: []const u8, offset: usize, file: []const u8, label: []const u8, message: []const u8) Allocator.Error![]u8 {
return parse_diagnostic.renderReportAlloc(allocator, source, offset, file, label, message);
}
/// A parse failure plus the byte position where the parser stopped. The parser
/// is single-pass, so when an error is returned its cursor sits on (or just
/// after) the offending token — precise enough for `file:line:col` without
/// threading a location through every error site.
pub const Diagnostic = struct {
code: Error,
/// Byte offset into the source where parsing stopped — the caret anchor.
offset: usize,
/// Byte offset just past the offending token, when the parser knows its
/// exact extent (e.g. a `: type` value that failed its annotation). Lets an
/// editor squiggle precisely that token instead of the whole rest of the
/// line (which would swallow a trailing comment); null means "no known end,
/// fall back to end-of-line". Always on `offset`'s line — never spans one.
end: ?usize = null,
/// 1-based line/column of `offset`, plus the full offending line.
pub fn locate(self: Diagnostic, source: []const u8) Parser.Location {
return locateOffset(source, self.offset);
}
/// Render `file:line:col: error: <message>` + source line + caret.
pub fn renderAlloc(self: Diagnostic, allocator: Allocator, source: []const u8, file: []const u8) Allocator.Error![]u8 {
return renderReportAlloc(allocator, source, self.offset, file, "error", describe(self.code));
}
};
/// An authoring-time lint (DESIGN.md "Authoring-time diagnostics", warn
/// severity): the tree is well-defined, but the line is a likely mistake.
/// Collected during the parse and returned via `Report` — the caller decides
/// presentation (`--quiet` silences, `--strict` promotes to failure).
pub const Warning = struct {
code: Code,
/// Byte offset of the surprising token (see `locateOffset`).
offset: usize,
/// Byte offset just past the surprising token, when the extent is known
/// (almost always — a warn fires on a specific value/token). Mirrors
/// `Diagnostic.end`: lets an editor squiggle exactly that token instead of
/// running to end-of-line. Always on `offset`'s line; null means "no known
/// end, fall back to end-of-line".
end: ?usize = null,
pub const Code = enum {
/// A bare token spelling a bool/null from another config language
/// (`Yes`, `ON`, `TRUE`, `Null`) fell to a plain string — the Norway
/// class, surfaced instead of silently coerced either way.
string_looks_like_literal,
/// A token that would be a valid number but for a leading zero on its
/// integer part (`007`, `-07`, `01.5`) kept its padding as a string
/// (the TOML leading-zero rule).
string_leading_zero,
/// A number-shaped token that no valid number spelling accepts — a
/// trailing-dot float (`1.`, `42.`) or a leading-dot float (`.5`,
/// `.25`) — fell to a string. Narrow by design (only a clean integer +
/// a single trailing `.`, or a single leading `.` + clean digits), so
/// version strings (`1.2.3`), prose (`12 monkeys`), and `.e5`/`..`
/// never warn. Write the missing zero (`0.5`, `1.0`) for the number,
/// or quote to affirm the string.
string_looks_like_number,
/// A bare clock time with no date (`10:30`) sniffed to a datetime
/// value. Narrower than it first looks: a bare *date* (`2026-07-01`)
/// no longer warns — that shape is the overwhelmingly common,
/// deliberate hand-authored case (frontmatter dates, deadlines,
/// changelog entries), so warning on it was noise, not signal. A bare
/// time has no such safe majority: `HH:MM[:SS]` is exactly the shape
/// of a duration, a ratio, or a sports score too, and neither seconds
/// nor fractional precision discriminate a clock time from those —
/// there is no unambiguous subset to carve out the way `T`/zone does
/// for dates. Any value with a `T`/space date-time separator
/// (`local_datetime`) or a zone (`offset_datetime`) already stays
/// silent — those separators aren't things prose/durations produce by
/// coincidence.
ambiguous_datetime,
/// A balanced, well-formed `[…]`/`{…}` separated from trailing content
/// by whitespace (`[80, 443] x`) fell to a bare string. Glued shapes
/// (`[Blog](/x)`, `[a-z]*.md`) are the intended bare strings and never
/// warn.
flow_like_string,
/// A bare flow value contains ` = ` (`{x = 1 y = 2}` → `x = "1 y =
/// 2"`) — almost always a missing comma between pairs.
flow_missing_comma,
/// A line's visual indentation disagrees with its `>` marker count
/// (convention: 2 spaces per level, no tabs). Meaning follows the
/// count; the indent is the redundant cross-check that just failed.
indent_marker_mismatch,
};
/// The teaching message — same name-the-fix contract as `describe`.
pub fn describeWarning(code: Code) []const u8 {
return switch (code) {
.string_looks_like_literal => "this spells a boolean/null in other config languages, but fig literals are lowercase-only, so it stays the string it spells; quote it to make the string explicit, or lowercase it for the literal",
.string_leading_zero => "a leading zero is not a number in fig, so the padding was kept as a string; quote it to make that explicit, or drop the zero for a number",
.string_looks_like_number => "a bare leading or trailing dot is not a valid number in fig, so this was kept as a string; write the missing zero (`0.5`, `1.0`) for a number, or quote it to affirm the string",
.ambiguous_datetime => "a bare clock time becomes a datetime value, not text (also a duration/ratio shape); quote it if you meant a string",
.flow_like_string => "this looks like a `[…]`/`{…}` flow value with trailing content, so the whole line was read as one bare string; remove the trailing content for a collection, or quote the value to affirm the string",
.flow_missing_comma => "a bare flow value containing ` = ` usually means a missing comma between pairs; add the comma, or quote the value if it really is text",
.indent_marker_mismatch => "indentation disagrees with the `>` marker count (convention: 2 spaces per level); meaning follows the count — fix whichever signal is wrong",
};
}
/// A short (few-word) noun phrase for `code` — distinct from
/// `describeWarning`'s full teaching sentence. See the top-level
/// `shortLabel` (for `Diagnostic`'s `Error` codes) for why this exists
/// alongside it: a caret annotation, rustc-style.
pub fn shortLabel(code: Code) []const u8 {
return switch (code) {
.string_looks_like_literal => "looks like a boolean/null",
.string_leading_zero => "leading zero",
.string_looks_like_number => "leading/trailing dot",
.ambiguous_datetime => "ambiguous datetime",
.flow_like_string => "looks like a flow value",
.flow_missing_comma => "missing comma",
.indent_marker_mismatch => "indent/marker mismatch",
};
}
/// Render `file:line:col: warning: <message>` + source line + caret.
pub fn renderAlloc(self: Warning, allocator: Allocator, source: []const u8, file: []const u8) Allocator.Error![]u8 {
return renderReportAlloc(allocator, source, self.offset, file, "warning", describeWarning(self.code));
}
};
/// Everything a parse reports besides the tree: the failure diagnostic (set
/// only on error) and the authoring-time warnings (valid on success AND on
/// failure — warns collected before the error still stand). `warnings` is
/// allocated with the allocator passed to `parseWithReport`; the caller owns it.
pub const Report = struct {
diag: ?Diagnostic = null,
/// Every parse error, in source order — populated ONLY by the recovering
/// entry point (`parseCollecting`), which skips past each failure to the
/// next line and keeps going, so a language server can squiggle a whole
/// file's mistakes at once (and `check` can list them in one pass). The
/// single-shot `parse`/`parseWithReport` stop at the first error and leave
/// this empty, setting `diag` alone. When non-empty, `diag` mirrors
/// `errors[0]`. Allocated with the caller's allocator; the caller owns it.
errors: []const Diagnostic = &.{},
warnings: []const Warning = &.{},
};
/// Arena for the whole intermediate tree — freed in one shot after the final
/// AST is built, so nothing here needs manual `deinit`.
allocator: Allocator,
source: []const u8 = "",
pos: usize = 0,
root: PendingContainer = .{},
root_dangling: std.ArrayList(AST.Comment) = .empty,
pending_leading: std.ArrayList(PendingComment) = .empty,
stack: std.ArrayList(Frame) = .empty,
/// The path of the most recent zero-marker `[]` append header, kept while only
/// deeper lines / comments / blanks / `+` lines follow — the target a `+`
/// continuation line re-runs. Any other zero-marker structural line clears it.
last_append_steps: ?[]const Step = null,
/// Overrides `pos` as the `Diagnostic` offset when set — for error sites where
/// the cursor has already moved past the token that actually offends (set via
/// `failAt`). `pos` is the right answer everywhere else.
fail_offset: ?usize = null,
/// The offending token's end offset, paired with `fail_offset` (set via
/// `failSpan`) — populates `Diagnostic.end` so an editor can squiggle exactly
/// that token. Null for the point-only error sites (caret alone).
fail_end: ?usize = null,
/// Byte offset of the start of the line currently being processed (set once
/// per iteration of the main loop in `run`). The fallback span anchor for a
/// container whose opener carries no more precise position of its own (a
/// bare `*` element-opener, an `[]`-append-created element, …) — always a
/// valid position on that container's own opening line, which is all
/// `Editor(Fig)`'s line-based splicing needs (see `lineStartBefore`).
cur_line_start: usize = 0,
/// Authoring-time lints collected during the pass (arena-backed; duped out to
/// the caller by `parseWithReport`). Empty unless a warn site fired.
warnings: std.ArrayList(Warning) = .empty,
/// Error-recovery mode: when set, `run` catches each per-line failure into
/// `diagnostics` and resyncs to the next line instead of returning the first
/// error. Off by default — `parse`/`parseWithReport` keep their single-shot
/// contract; `parseCollecting` turns it on. `error.OutOfMemory` is never
/// recovered (it is not a document defect).
recover: bool = false,
/// Recoverable parse errors, in source order (arena-backed; duped out to the
/// caller's `Report.errors` by `parseImpl`). Populated only in `recover` mode;
/// empty otherwise.
diagnostics: std.ArrayList(Diagnostic) = .empty,
/// `PendingContainer.reentries` resolved to built node ids during AST
/// assembly (arena-backed; duped out to `Document.reentry_headers`). Empty
/// unless some header re-opened an existing container.
built_reentries: std.ArrayList(Document.ReentryHeader) = .empty,
// ── Intermediate tree types ─────────────────────────────────────────────────
/// A container whose shape (map vs. sequence) is frozen the moment its first
/// child is added — root and every header/element-opener target start here.
const PendingContainer = struct {
kind: enum { undecided, mapping, sequence } = .undecided,
/// Created by a flow value (`[…]`/`{…}`) — closed to later extension via
/// dotted paths, headers, or indexing (the TOML inline-table rule).
closed: bool = false,
/// Created by a header-final `[]` append (or a `+` re-run of one). Those
/// elements are pre-committed to map shape at birth, which would bypass
/// the `.undecided` → `FigEmptyContainer` check at frame close — this flag
/// lets the close path apply the same "no empty block containers" rule.
born_of_append: bool = false,
mapping: Mapping = .{},
sequence: Sequence = .{},
/// Byte offsets (source order) of every LATER header line whose final path
/// segment re-OPENED this container (DESIGN.md "re-entering a path to add
/// new keys is fine") — the header-line positions the owning `TNode.span`
/// cannot carry, since it is stamped once at creation. Only header-FINAL
/// re-opens are recorded: a header/assignment whose final segment CREATES
/// something new anchors its own line through that new node's span, and a
/// mid-path re-open always rides a line anchored by its final segment one
/// way or the other. Threaded out to `Document.reentry_headers` (keyed by
/// built node id) during AST assembly, so `Editor(Fig)`'s region gather
/// can remove/relocate every physical header occurrence.
reentries: std.ArrayList(usize) = .empty,
fn open(self: *PendingContainer) Error!*PendingContainer {
return if (self.closed) error.FigClosedFlowValue else self;
}
fn asMapping(self: *PendingContainer) Error!*Mapping {
switch (self.kind) {
.undecided => {
self.kind = .mapping;
return &self.mapping;
},
.mapping => return &self.mapping,
.sequence => return error.FigMixedContainerChildren,
}
}
fn asSequence(self: *PendingContainer) Error!*Sequence {
switch (self.kind) {
.undecided => {
self.kind = .sequence;
return &self.sequence;
},
.sequence => return &self.sequence,
.mapping => return error.FigMixedContainerChildren,
}
}
};
const Mapping = struct {
/// Entries in source order — the authoritative list the builder walks.
entries: std.ArrayList(*MEntry) = .empty,
/// Key → entry index, kept in sync with `entries` by `addEntry`, so
/// duplicate-key checks and dotted-path navigation are O(1) instead of a
/// linear scan of `entries` (which made building an n-key table O(n²)).
/// Arena-backed like the rest of the pending tree, so it needs no deinit.
index: std.StringHashMapUnmanaged(*MEntry) = .empty,
/// Append `entry` in source order and register it in `index`. Keys are
/// unique (duplicates are rejected before they reach here), so the put
/// never collides with a different entry.
fn addEntry(self: *Mapping, allocator: Allocator, entry: *MEntry) Error!void {
try self.entries.append(allocator, entry);
try self.index.put(allocator, entry.key, entry);
}
};
const MEntry = struct {
key: []const u8,
/// Source span of the key token itself (quotes included when quoted) —
/// mirrors every other parser's `keyvalue.key` span convention, so
/// `Editor(Fig)` can splice a rename in place.
key_span: Span = Span.init(0, 0),
/// Leading comments bind to the KEY (matches `AST.leadingCommentAnchor` for
/// a `keyvalue`).
key_leading: std.ArrayList(AST.Comment) = .empty,
value: TNode,
};
const Sequence = struct {
elements: std.ArrayList(*TNode) = .empty,
style: enum { undecided, element, addressed } = .undecided,
fn markElement(self: *Sequence) Error!void {
switch (self.style) {
.undecided => self.style = .element,
.element => {},
.addressed => return error.FigMixedSequenceAddressing,
}
}
fn markAddressed(self: *Sequence) Error!void {
switch (self.style) {
.undecided => self.style = .addressed,
.addressed => {},
.element => return error.FigMixedSequenceAddressing,
}
}
};
const TValue = union(enum) {
null_,
boolean: bool,
string: []const u8,
number: tok.NumberResult,
extended: struct { kind: tok.ExtKind, text: []const u8 },
container: *PendingContainer,
};
const TNode = struct {
value: TValue,
/// Source span of this value. For a scalar, the token's own tight span
/// (quotes/brackets included where the value opens with one). For a
/// container, the position right after its opening marker/bracket at
/// creation time (`buildNode` widens `.end` to the container's full
/// extent once every child's own span is known — see "AST assembly").
span: Span = Span.init(0, 0),
/// `AST.trailingCommentAnchor`-equivalent: the value itself for a
/// scalar/container, always applied directly to this node's built id.
trailing: ?AST.Comment = null,
/// The explicit `: type =` annotation, recorded as a cross-format type tag
/// (`ast.node_tags`) so the annotation round-trips through `fig fmt`. Set by
/// `applyKnownType`/`parseAssignedValue`; applied in `buildNode`. Null for an
/// untyped value (`key = value`).
tag: ?AST.Tag = null,
/// Own leading comments — meaningful only when this TNode is NOT a map
/// entry's value (sequence elements and root have no wrapping key to bind
/// to instead).
leading: std.ArrayList(AST.Comment) = .empty,
/// Orphan comments at the end of this node's body (container-only).
dangling: std.ArrayList(AST.Comment) = .empty,
};
/// A buffered comment line plus its own marker depth. The depth decides where
/// the comment lands when containers close: a comment at (or below) a closing
/// frame's child depth was written inside that container (its dangling run); a
/// shallower one belongs to whatever sibling line comes next (its leading
/// run). This is DESIGN.md's "a depth-prefixed comment attaches at that depth
/// to the next sibling".
const PendingComment = struct { comment: AST.Comment, depth: u32 };
const Frame = struct {
container: *PendingContainer,
/// The depth (relative to the active baseline) that this frame's children
/// must be written at.
child_depth: u32,
/// The TNode wrapping `container` — where trailing/dangling comments land.
owner: *TNode,
};
const IndexKind = union(enum) { literal: usize, append_or_last };
/// A dotted-path key segment plus its own source span (quotes included when
/// quoted) — carried through path navigation so every container the path
/// touches (intermediate dotted segments included) can stamp an accurate
/// `key_span` on the `MEntry` it creates or reuses.
const KeySeg = struct { name: []const u8, span: Span };
const Step = union(enum) { key: KeySeg, index: IndexKind };
/// The outcome of resolving a HEADER line's final path segment: the container
/// to push as the next frame, its owner TNode (trailing/dangling target), and
/// — when reached via a map key — the entry (leading-comment target; a
/// sequence-index resolution has no entry, so leading binds to `owner` itself).
const Resolved = struct { container: *PendingContainer, owner: *TNode, entry: ?*MEntry };
// ── Entry points ─────────────────────────────────────────────────────────────
pub fn parseAbstract(allocator: Allocator, input: []const u8, format: Type) !AST {
const parsed = try parse(allocator, input, format);
allocator.free(parsed.node_spans);
allocator.free(parsed.reentry_headers);
return parsed.ast;
}
pub fn parse(allocator: Allocator, input: []const u8, format: Type) Error!Document {
return parseImpl(allocator, input, format, null, false);
}
/// `parse`, but also fills `out`: `diag` on failure (error code + byte offset,
/// for `file:line:col` teaching messages), `warnings` always (authoring-time
/// lints, allocated with `allocator` — the caller owns/frees them). The hook
/// the CLI (and eventually the C ABI) renders reports from.
pub fn parseWithReport(allocator: Allocator, input: []const u8, format: Type, out: *Report) Error!Document {
return parseImpl(allocator, input, format, out, false);
}
/// `parseWithReport`, but recovers past each error to collect the WHOLE file's
/// diagnostics in one pass (`out.errors`, source order) rather than stopping at
/// the first — the entry a language server wants, so every mistake squiggles at
/// once. On any error the return value is still the first error code and the
/// tree is NOT built (both consumers discard it); a clean parse returns the
/// Document exactly as `parseWithReport` would. `out.diag` mirrors `errors[0]`.
pub fn parseCollecting(allocator: Allocator, input: []const u8, format: Type, out: *Report) Error!Document {
return parseImpl(allocator, input, format, out, true);
}
fn parseImpl(allocator: Allocator, input: []const u8, format: Type, out: ?*Report, recover: bool) Error!Document {
// `Type` has a single member (`.Fig`) — the fig dialect is unversioned — so
// there is nothing to branch on. The parameter is kept only so this parser
// matches the uniform `Language.parse(parser, input, format)` signature the
// multi-format dispatch relies on (other languages select a version here).
_ = format;
// A fig document must be valid UTF-8 (checked once, up front — mirrors
// TOML's `parseOnce`).
if (!std.unicode.utf8ValidateSlice(input)) return error.FigInvalidUtf8;
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
var self: Parser = .{ .allocator = arena_state.allocator(), .source = input, .recover = recover };
// A leading UTF-8 BOM is permitted and ignored (mirrors TOML's tokenizer /
// JSON's tokenizer) — skip it before any tokenizing/key parsing happens.
if (std.mem.startsWith(u8, input, "\xEF\xBB\xBF")) self.pos = 3;
// Warnings are duped out on every exit path (they are valid alongside a
// failure too). Best-effort under OOM — the tree, not the lint list, is
// the load-bearing result. Runs before the arena defer (LIFO), while
// `self.warnings` is still alive.
defer if (out) |o| {
o.warnings = allocator.dupe(Warning, self.warnings.items) catch &.{};
};
self.run() catch |err| {
// Non-recover mode (or an unrecoverable OOM in recover mode): the first
// error is the whole story — report it and stop.
if (out) |o| o.diag = .{ .code = err, .offset = self.fail_offset orelse self.pos, .end = self.fail_end };
return err;
};
// Recover mode collected its errors into `self.diagnostics` instead of
// returning them. If any fired, the tree may be malformed, so skip building
// it: surface every diagnostic (source order) and return the first code, so
// callers that branch on "did it parse?" behave exactly as before.
if (self.diagnostics.items.len > 0) {
if (out) |o| {
o.diag = self.diagnostics.items[0];
o.errors = allocator.dupe(Diagnostic, self.diagnostics.items) catch &.{};
}
return self.diagnostics.items[0].code;
}
var b = AST.Builder.init(allocator);
// `finish` only moves `nodes`/`owned_strings` out; the comment side-table's
// outer spines (`comments`/`view_comments`) are never consumed by it, so
// `deinit` is still required on the success path too (mirrors every
// `Builder` call site, including its own tests).
defer b.deinit();
const root_id = try self.buildRoot(&b);
var ast = try b.finish(root_id);
errdefer ast.deinit();
const node_spans = try b.takeSpans();
errdefer allocator.free(node_spans);
const reentry_headers = try allocator.dupe(Document.ReentryHeader, self.built_reentries.items);
return .{ .source = input, .ast = ast, .node_spans = node_spans, .reentry_headers = reentry_headers };
}
/// Return `err` with the diagnostic caret pinned to `offset` — for the sites
/// where the cursor has already scanned past the token that actually offends
/// (e.g. the `:` of `key: value`, consumed before the missing `=` is noticed).
fn failAt(self: *Parser, offset: usize, err: Error) Error {
self.fail_offset = offset;
return err;
}
/// Like `failAt`, but also pins the offending token's end (`start..end`) so the
/// diagnostic carries a tight range — an editor squiggles exactly that token
/// instead of running to end-of-line and swallowing a trailing comment.
fn failSpan(self: *Parser, start: usize, end: usize, err: Error) Error {
self.fail_offset = start;
self.fail_end = end;
return err;
}
/// Record an authoring-time lint anchored at `offset`, with no known token end
/// (the squiggle falls back to end-of-line). Prefer `warnAt` when the offending
/// token's extent is known — most warn sites know it.
fn warn(self: *Parser, code: Warning.Code, offset: usize) Error!void {
try self.warnings.append(self.allocator, .{ .code = code, .offset = offset });
}
/// Record an authoring-time lint spanning `[start, end)` — a precise token
/// extent so an editor squiggles exactly that token instead of the whole line.
fn warnAt(self: *Parser, code: Warning.Code, start: usize, end: usize) Error!void {
try self.warnings.append(self.allocator, .{ .code = code, .offset = start, .end = end });
}
// ── Main line loop ──────────────────────────────────────────────────────────
fn run(self: *Parser) Error!void {
while (self.pos < self.source.len) {
const line_start = self.pos;
self.cur_line_start = line_start;
self.processLine(line_start) catch |err| {
// In recover mode, a per-line failure is recorded and the scanner
// resyncs to the next line so the rest of the file still reports.
// OOM is never a document defect — always propagate it. Outside
// recover mode nothing changes: the first error stops the parse.
if (!self.recover or err == error.OutOfMemory) return err;
try self.recordRecoverable(err, line_start);
};
}
// Container-close and comment-drain that trail the last line. A failure
// here (e.g. an empty container at EOF) recovers the same way, but with no
// line left to resync to — recording it is enough, and `parseImpl` will
// bail before building the (now-known-malformed) tree.
self.finishRun() catch |err| {
if (!self.recover or err == error.OutOfMemory) return err;
try self.recordRecoverable(err, self.source.len);
};
}
/// One physical line: indent lint, blank/comment shortcuts, then the content
/// dispatch. Factored out of `run` so the loop can wrap exactly one line in the
/// recovery catch (the blank/comment "skip this line" paths just `return`).
fn processLine(self: *Parser, line_start: usize) Error!void {
self.skipSpacesTabs(); // cosmetic indent — never load-bearing (linted below)
const indent = self.source[line_start..self.pos];
const m = try self.scanMarkers();
// A TRULY blank line (nothing at all, not even a comment) is skipped
// outright — before the indent lint (trailing whitespace on an empty
// line is not a depth signal). This must NOT reuse `atEndOfContent`
// (which also treats a leading `#` as "content is over") — a
// comment-only line has to fall through to the `#` handling below, or
// it would never be consumed and the main loop would spin forever at
// the same position.
if (!m.star and self.atTrueLineEnd()) {
self.skipToNextLine();
return;
}
// Indent/count lint (DESIGN.md "Depth diagnostics"): meaning lives in
// the `>` count alone, but indentation — when present — is the
// redundant second signal, and this is the only check that can catch a
// deeper-by-one / same-depth miscount. Convention: `2 × depth` spaces,
// no tabs. Bare and spaced-marker files (zero indent) stay lint-clean.
// Comment lines are linted too: their depth is load-bearing for
// attachment.
if (indent.len > 0) {
const tabbed = std.mem.indexOfScalar(u8, indent, '\t') != null;
// Anchored at the first char AFTER the indent (the marker run /
// key), not at the line start: a line-start offset sits right past
// the previous newline, which `locateOffset` would step back over.
// Squiggle from the first post-indent char through the marker run
// (`self.pos` sits past it after `scanMarkers`) — the two signals that
// disagree — instead of running to end-of-line.
if (tabbed or indent.len != 2 * m.depth) try self.warnAt(.indent_marker_mismatch, line_start + indent.len, self.pos);
}
// A `*` element marker already committed this to an element line (even
// a bare `*` opener with no value, or one carrying only a trailing
// comment), so skip the comment-only shortcut below.
if (!m.star) {
if (self.peek() == '#') {
self.advance();
var j = self.pos;
while (j < self.source.len and self.source[j] != '\n') j += 1;
const text = std.mem.trim(u8, self.source[self.pos..j], " \t\r");
try self.pending_leading.append(self.allocator, .{ .comment = .{ .text = text, .style = .line }, .depth = m.depth });
self.pos = j;
self.skipToNextLine();
return;
}
}
try self.processContentLine(m.depth, m.star);
}
/// Trailing work after the last line is consumed: close every still-open frame
/// back to root, and flush any dangling comments to the root.
fn finishRun(self: *Parser) Error!void {
try self.closeFramesAbove(0);
for (self.pending_leading.items) |pc| {
try self.root_dangling.append(self.allocator, pc.comment);
}
self.pending_leading.clearRetainingCapacity();
}
/// Record a recoverable error at its diagnostic offset, then resync the scanner
/// past the offending line so the loop keeps making progress.
fn recordRecoverable(self: *Parser, err: Error, line_start: usize) Error!void {
try self.diagnostics.append(self.allocator, .{ .code = err, .offset = self.fail_offset orelse self.pos, .end = self.fail_end });
// Consumed — do not leak this failure's anchors into the next line's report.
self.fail_offset = null;
self.fail_end = null;
self.recoverToNextLine(line_start);
}
/// Resync to the start of the line after the one the failure began on, so the
/// loop keeps reporting. `line_start` (the current line's true start) anchors
/// this — NOT the raw cursor, which for a late-detected error (a duplicate key
/// noticed only after its line's newline was consumed) already sits at the next
/// line's start; scanning from there would swallow that still-unread line.
fn recoverToNextLine(self: *Parser, line_start: usize) void {
// End (then start-of-next) of the line the failure began on.
var eol = line_start;
while (eol < self.source.len and self.source[eol] != '\n') eol += 1;
const next = if (eol < self.source.len) eol + 1 else eol;
if (self.pos <= next) {
// A single-line failure — the cursor is still on this line, or resting
// exactly at the next line's start (the late-detection case above).
// Resume at `next`: the failed line is skipped, the following one kept.
self.pos = next;
} else {
// The failing construct (a `'''` string / `[`…`]` flow) already
// consumed past this line — resume after wherever it stopped, so its
// body isn't re-scanned as if each physical line were a fresh entry.
var at = self.pos;
while (at < self.source.len and self.source[at] != '\n') at += 1;
self.pos = if (at < self.source.len) at + 1 else at;
}
}
fn processContentLine(self: *Parser, depth: u32, star: bool) Error!void {
try self.closeFramesAbove(depth);
const required: u32 = if (self.stack.items.len == 0) 0 else self.stack.items[self.stack.items.len - 1].child_depth;
if (depth != required) return if (required == 0) error.FigRootMarker else error.FigSkippedLevel;
const target: *PendingContainer = if (self.stack.items.len == 0) &self.root else self.stack.items[self.stack.items.len - 1].container;
// A lone `+` (optionally with a trailing comment) is a continuation line:
// it re-runs the most recent zero-marker `[]` append header. Any other
// zero-marker structural line breaks the chain (comments, blanks, and
// deeper body lines do not — they never reach this point at depth 0).
const is_plus = !star and self.peek() == '+' and self.isPlusLine();
if (depth == 0 and !is_plus) self.last_append_steps = null;
if (is_plus) return self.parseContinuationLine(depth);
// `star` = the prefix carried a `*` element marker (scanMarkers consumed
// it, along with the separator).
if (star) return self.parseElementLine(target, depth);
switch (self.peek().?) {
// A `-` element line is the YAML habit (and fig's own pre-`*`
// spelling) — hard error naming the `*` form.
'-' => return error.FigForeignSyntaxDash,
'[' => return error.FigForeignSyntaxBracket,
else => try self.parseKeyLine(target, depth),
}
}
/// `pos` sits on a `+`: is this a continuation line (`+` alone, or followed by
/// whitespace / a comment / end-of-line)? Anything glued to the `+` falls
/// through to the key path (where `+` is not a bare-key char → `FigBadKey`).
fn isPlusLine(self: *Parser) bool {
const j = self.pos + 1;
if (j >= self.source.len) return true;
return switch (self.source[j]) {
' ', '\t', '\r', '\n', '#' => true,
else => false,
};
}
/// A `+` continuation: append another element to the sequence of the most
/// recent `[]` append header and re-anchor the baseline to it, exactly as if
/// the header line had been written again.
fn parseContinuationLine(self: *Parser, depth: u32) Error!void {
self.advance(); // '+'
if (depth != 0) return error.FigDanglingContinuation;
const steps = self.last_append_steps orelse return error.FigDanglingContinuation;
self.skipSpacesTabs();
if (!self.atEndOfContent()) return error.FigTrailingContent;
const leading = try self.drainPendingLeading();
const parent = try self.navigateIntermediate(&self.root, steps);
const resolved = try self.resolveHeaderFinal(parent, steps[steps.len - 1]);
try self.appendComments(&resolved.owner.leading, leading);
if (try self.scanTrailingCommentOnly()) |cm| resolved.owner.trailing = .{ .text = cm };
self.consumeLineEnd();
try self.stack.append(self.allocator, .{ .container = resolved.container, .child_depth = 1, .owner = resolved.owner });
}
/// Pop frames deeper than `depth`, validating each closes non-empty. Buffered
/// comments written at (or below) a closing frame's child depth were inside
/// that container — they become its dangling run; shallower ones stay pending
/// for the next sibling line (its leading run).
fn closeFramesAbove(self: *Parser, depth: u32) Error!void {
while (self.stack.items.len > 0 and self.stack.items[self.stack.items.len - 1].child_depth > depth) {
const frame = self.stack.pop().?;
if (frame.container.kind == .undecided) return error.FigEmptyContainer;
// A `[]`-appended element was pre-committed to map shape at birth, so
// the `.undecided` check above never sees it — but one that closes
// with zero entries is the same authoring mistake, and errors the same.
if (frame.container.born_of_append and frame.container.mapping.entries.items.len == 0) return error.FigEmptyContainer;
if (self.pending_leading.items.len > 0) {
// Partition in place: comments at/below the closing frame's child
// depth drain to its dangling run; the rest compact to the front of
// the existing buffer (retaining capacity, no per-close allocation).
var write: usize = 0;
for (self.pending_leading.items) |pc| {
if (pc.depth >= frame.child_depth) {
try frame.owner.dangling.append(self.allocator, pc.comment);
} else {
self.pending_leading.items[write] = pc;
write += 1;
}
}
self.pending_leading.items.len = write;
}
}
}
fn drainPendingLeading(self: *Parser) Error!std.ArrayList(AST.Comment) {
var result: std.ArrayList(AST.Comment) = .empty;
try result.ensureTotalCapacityPrecise(self.allocator, self.pending_leading.items.len);
for (self.pending_leading.items) |pc| result.appendAssumeCapacity(pc.comment);
self.pending_leading.clearRetainingCapacity();
return result;
}
fn appendComments(self: *Parser, dst: *std.ArrayList(AST.Comment), src: std.ArrayList(AST.Comment)) Error!void {
if (src.items.len == 0) return;
try dst.appendSlice(self.allocator, src.items);
}
// ── Markers ──────────────────────────────────────────────────────────────────
const Markers = struct {
depth: u32,
/// A `*` element marker ended the prefix — spaced (`> *`, the normalized
/// form), glued (`>*`), or, at root, a bare leading `*` (a zero-marker
/// sequence element). scanMarkers consumed it and its separator.
star: bool,
};
/// Count a run of `>` markers (spaced runs like `> > >` count the same as
/// `>>>`), consume an optional `*` element marker ending the run (`> *`, `>*`,
/// or a bare `*` at root), then require exactly one whitespace separator
/// before the body — unless the rest of the line is empty/a comment (a
/// marker-only line: `>>` is blank, `*` is a map-element opener). The `*` is a
/// role suffix, not a counted mark: depth is the `>` count alone. A `-` where
/// the element marker would sit is the YAML habit — a hard error naming `*`.
fn scanMarkers(self: *Parser) Error!Markers {
var count: u32 = 0;
scan: while (self.peek() == '>') {
count += 1;
self.advance();
while (self.peek() == ' ' or self.peek() == '\t') {
const save = self.pos;
self.skipSpacesTabs();
if (self.peek() == '*') break :scan; // spaced `> *` ends the run
if (self.peek() != '>') {
self.pos = save;
break;
}
}
}
// `>-` glued to the run — the old dash element spelling.
if (count > 0 and self.peek() == '-') return error.FigForeignSyntaxDash;
if (self.peek() == '*') {
self.advance();
if (self.peek() == ' ' or self.peek() == '\t') {
self.skipSpacesTabs();
} else if (self.peek() == ':') {
// Typed element `*: type = v`: the `:` binds to the positional-key
// `*` (mirrors `key: type`), so no separator space is required —
// leave `pos` on the `:` for `parseElementLine`.
} else if (!self.atEndOfContent()) {
return error.FigBadMarkerSeparator;
}
return .{ .depth = count, .star = true };
}
if (count > 0) {
if (self.peek() == ' ' or self.peek() == '\t') {
self.skipSpacesTabs();
} else if (!self.atEndOfContent()) {
return error.FigBadMarkerSeparator;
}
}
return .{ .depth = count, .star = false };
}
// ── Header / assignment lines ────────────────────────────────────────────────
fn parseKeyLine(self: *Parser, target: *PendingContainer, depth: u32) Error!void {
const steps = try self.scanKeyPath();
self.skipSpacesTabs();
if (self.peek() == ':') {
const colon_pos = self.pos;
self.advance();
self.skipSpacesTabs();
const type_start = self.pos;
while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
const type_name = self.source[type_start..self.pos];
if (type_name.len == 0) return error.FigBadKey;
self.skipSpacesTabs();
// `key: value` (the YAML habit) — the `:` is the offending token, not
// the spot where the missing `=` was noticed, so pin the caret there.
if (self.peek() != '=') return self.failAt(colon_pos, error.FigForeignSyntaxColon);
self.advance();
try self.finishAssignment(target, steps, type_name);
} else if (self.peek() == '=') {
self.advance();
try self.finishAssignment(target, steps, null);
} else {
try self.finishHeader(target, steps, depth);
}
}
fn finishHeader(self: *Parser, target: *PendingContainer, steps: []const Step, depth: u32) Error!void {
if (!self.atEndOfContent()) return error.FigTrailingContent;
const leading = try self.drainPendingLeading();
const parent = try self.navigateIntermediate(target, steps);
const last = steps[steps.len - 1];
const resolved = try self.resolveHeaderFinal(parent, last);
if (resolved.entry) |e| {
try self.appendComments(&e.key_leading, leading);
} else {
try self.appendComments(&resolved.owner.leading, leading);
}
if (try self.scanTrailingCommentOnly()) |cm| resolved.owner.trailing = .{ .text = cm };
self.consumeLineEnd();
try self.stack.append(self.allocator, .{ .container = resolved.container, .child_depth = depth + 1, .owner = resolved.owner });
// A zero-marker header whose FINAL step is `[]` arms the `+` continuation
// (processContentLine already cleared any previous chain for this line).
if (depth == 0) switch (last) {
.index => |idx| {
if (idx == .append_or_last) self.last_append_steps = steps;
},
.key => {},
};
}
fn finishAssignment(self: *Parser, target: *PendingContainer, steps: []const Step, type_name: ?[]const u8) Error!void {
self.skipSpacesTabs();
const leading = try self.drainPendingLeading();
const parent = try self.navigateIntermediate(target, steps);
const last = steps[steps.len - 1];
var value_node = try self.parseAssignedValue(type_name);
self.consumeLineEnd();
switch (last) {
.key => |k| {
const m = try parent.asMapping();
// `consumeLineEnd` above has already advanced `self.pos` past this
// line's newline (onto the START of the next line) — the default
// "current cursor" anchor would misattribute the diagnostic to
// whatever follows. Pin it to the actual conflicting key's own
// span instead.
if (self.findEntry(m, k.name) != null) return self.failSpan(k.span.start, k.span.end, error.FigDuplicateKey);
const entry = try self.allocator.create(MEntry);
entry.* = .{ .key = k.name, .key_span = k.span, .value = value_node };
try self.appendComments(&entry.key_leading, leading);
try m.addEntry(self.allocator, entry);
},
.index => |idx| {
const s = try parent.asSequence();
try s.markAddressed();
try self.appendComments(&value_node.leading, leading);
switch (idx) {
.literal => |n| {
if (n < s.elements.items.len) return error.FigIndexAlreadySet;
if (n > s.elements.items.len) return error.FigIndexSkipped;
try s.elements.append(self.allocator, try self.boxNode(value_node));
},
// `xs[] = v` is not assignable: `[]` appends only as a header
// (or as a mid-path "last element" step) — a `= value` on it
// has no defined target, so it is a teaching error whether the
// sequence is empty or not. (`xs[N] = v` with N == the current
// length is the explicit-append spelling; see `.literal` above.)
.append_or_last => return error.FigAppendAssignment,
}
},
}
}
// ── Element (`-`) lines ──────────────────────────────────────────────────────
/// The element `*` marker (and its separator) has already been consumed by
/// `scanMarkers`; `pos` sits on the element body (or a typing `:`).
fn parseElementLine(self: *Parser, target: *PendingContainer, depth: u32) Error!void {
// The position right after the `> *` marker prefix — before any trailing
// whitespace is skipped — so a bare `*` opener (no value on its own line)
// still anchors its container's span at a real, reconstructable position
// on its own line (`Editor(Fig)` copies this prefix verbatim for a new
// sibling element).
const body_start = self.pos;
self.skipSpacesTabs();
const leading = try self.drainPendingLeading();
const seq = try target.asSequence();
try seq.markElement();
if (self.atEndOfContent()) {
const child = try self.allocator.create(PendingContainer);
child.* = .{};
const el = try self.allocator.create(TNode);
el.* = .{ .value = .{ .container = child }, .span = Span.init(body_start, body_start) };
try self.appendComments(&el.leading, leading);
try seq.elements.append(self.allocator, el);
if (try self.scanTrailingCommentOnly()) |cm| el.trailing = .{ .text = cm };
self.consumeLineEnd();
try self.stack.append(self.allocator, .{ .container = child, .child_depth = depth + 1, .owner = el });
return;
}
if (self.peek() == ':') {
const colon_pos = self.pos;
self.advance();
self.skipSpacesTabs();
const type_start = self.pos;
while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
const type_name = self.source[type_start..self.pos];
if (type_name.len == 0) return error.FigBadKey;
self.skipSpacesTabs();
// Same caret pin as `parseKeyLine`: the `:` is the offending token.
if (self.peek() != '=') return self.failAt(colon_pos, error.FigForeignSyntaxColon);
self.advance();
var node = try self.parseAssignedValue(type_name);
self.consumeLineEnd();
try self.appendComments(&node.leading, leading);
try seq.elements.append(self.allocator, try self.boxNode(node));
return;
}
const c = self.peek().?;
if (c == '\'' or c == '"' or c == '[' or c == '{') {
var node = try self.parseUntypedValue(false);
self.consumeLineEnd();
try self.appendComments(&node.leading, leading);
try seq.elements.append(self.allocator, try self.boxNode(node));
return;
}
// Bare value: the "no inline field on an element" guardrail runs BEFORE
// sniffing, on the raw (untrimmed) rest of the line.
const start = self.pos;
var k = start;
while (k < self.source.len and self.source[k] != '\n') k += 1;
if (std.mem.indexOf(u8, self.source[start..k], " = ") != null) return error.FigElementInlineField;
var node = try self.parseUntypedValue(false);
self.consumeLineEnd();
try self.appendComments(&node.leading, leading);
try seq.elements.append(self.allocator, try self.boxNode(node));
}
// ── Key-path scanning (shared by headers and assignments) ───────────────────
fn scanKeyPath(self: *Parser) Error![]Step {
var steps: std.ArrayList(Step) = .empty;
// Paths are short (usually 1-3 segments); seed enough to skip the mid-loop
// growth reallocs before the final `toOwnedSlice` shrink.
try steps.ensureTotalCapacity(self.allocator, 4);
while (true) {
const key = try self.scanKeySeg();
try steps.append(self.allocator, .{ .key = key });
while (self.peek() == '[') {
try steps.append(self.allocator, .{ .index = try self.scanIndexSeg() });
}
if (self.peek() == '.') {
self.advance();
continue;
}
break;
}
return steps.toOwnedSlice(self.allocator);
}
fn scanKeySeg(self: *Parser) Error!KeySeg {
const c = self.peek() orelse return error.FigBadKey;
const start = self.pos;
if (c == '"') {
const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .name = r.text, .span = Span.init(start, r.end) };
}
if (c == '\'') {
const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .name = r.text, .span = Span.init(start, r.end) };
}
while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
if (self.pos == start) return error.FigBadKey;
const text = self.source[start..self.pos];
if (text[0] == '-' or text[0] == '>') return error.FigBadKey;
return .{ .name = text, .span = Span.init(start, self.pos) };
}
fn scanIndexSeg(self: *Parser) Error!IndexKind {
std.debug.assert(self.peek() == '[');
self.advance();
if (self.peek() == ']') {
self.advance();
return .append_or_last;
}
const start = self.pos;
while (self.pos < self.source.len and std.ascii.isDigit(self.source[self.pos])) self.pos += 1;
if (self.pos == start or self.peek() != ']') return error.FigBadKey;
const n = std.fmt.parseInt(usize, self.source[start..self.pos], 10) catch return error.FigBadKey;
self.advance(); // ']'
return .{ .literal = n };
}
// ── Path navigation ──────────────────────────────────────────────────────────
fn findEntry(self: *Parser, m: *Mapping, key: []const u8) ?*MEntry {
_ = self;
return m.index.get(key);
}
/// Resolve every step except the last, creating/reusing intermediate
/// containers. `[]`/`[i]` here always mean "the last existing element" (never
/// creates) — only a HEADER line's OVERALL final `[]` appends.
fn navigateIntermediate(self: *Parser, start: *PendingContainer, steps: []const Step) Error!*PendingContainer {
var cur = start;
for (steps[0 .. steps.len - 1]) |step| {
switch (step) {
.key => |k| {
const m = try cur.asMapping();
cur = try self.getOrCreateMapContainer(m, k);
},
.index => |idx| {
const s = try cur.asSequence();
try s.markAddressed();
cur = try self.getOrCreateSeqContainer(s, idx);
},
}
}
return cur;
}
fn getOrCreateMapContainer(self: *Parser, m: *Mapping, seg: KeySeg) Error!*PendingContainer {
if (self.findEntry(m, seg.name)) |e| {
return switch (e.value.value) {
.container => |c| c.open(),
else => error.FigKeyNotContainer,
};
}
const child = try self.allocator.create(PendingContainer);
child.* = .{};
const entry = try self.allocator.create(MEntry);
entry.* = .{ .key = seg.name, .key_span = seg.span, .value = .{ .value = .{ .container = child }, .span = seg.span } };
try m.addEntry(self.allocator, entry);
return child;
}
fn getOrCreateSeqContainer(self: *Parser, s: *Sequence, idx: IndexKind) Error!*PendingContainer {
switch (idx) {
.literal => |n| {
if (n < s.elements.items.len) {
return switch (s.elements.items[n].value) {
.container => |c| c.open(),
else => error.FigKeyNotContainer,
};
} else if (n == s.elements.items.len) {
const child = try self.allocator.create(PendingContainer);
child.* = .{};
const el = try self.allocator.create(TNode);
el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
try s.elements.append(self.allocator, el);
return child;
} else return error.FigIndexSkipped;
},
.append_or_last => {
if (s.elements.items.len == 0) return error.FigEmptyAppendTarget;
const last = s.elements.items[s.elements.items.len - 1];
return switch (last.value) {
.container => |c| c.open(),
else => error.FigKeyNotContainer,
};
},
}
}
/// Resolve a HEADER line's final path segment: get-or-create the container to
/// push as the next frame. A plain key may be re-entered (fine, per
/// DESIGN.md); a header-final `[]` always appends a fresh, forced-mapping
/// element.
fn resolveHeaderFinal(self: *Parser, parent: *PendingContainer, last: Step) Error!Resolved {
switch (last) {
.key => |k| {
const m = try parent.asMapping();
if (self.findEntry(m, k.name)) |e| {
const c = switch (e.value.value) {
.container => |cc| try cc.open(),
else => return error.FigDuplicateKey,
};
// A header re-opening an existing container: record this
// line's position on it (see `PendingContainer.reentries`).
try c.reentries.append(self.allocator, k.span.start);
return .{ .container = c, .owner = &e.value, .entry = e };
}
const child = try self.allocator.create(PendingContainer);
child.* = .{};
const entry = try self.allocator.create(MEntry);
entry.* = .{ .key = k.name, .key_span = k.span, .value = .{ .value = .{ .container = child }, .span = k.span } };
try m.addEntry(self.allocator, entry);
return .{ .container = child, .owner = &entry.value, .entry = entry };
},
.index => |idx| {
const s = try parent.asSequence();
try s.markAddressed();
switch (idx) {
.literal => |n| {
if (n < s.elements.items.len) {
const el = s.elements.items[n];
const c = switch (el.value) {
.container => |cc| try cc.open(),
else => return error.FigKeyNotContainer,
};
// An `xs[i]` header re-opening an existing element —
// the index-addressed twin of the `.key` re-open
// above. No key span exists for an index step; the
// line start is the same-line anchor the region
// gather needs.
try c.reentries.append(self.allocator, self.cur_line_start);
return .{ .container = c, .owner = el, .entry = null };
} else if (n == s.elements.items.len) {
const child = try self.allocator.create(PendingContainer);
child.* = .{};
const el = try self.allocator.create(TNode);
el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
try s.elements.append(self.allocator, el);
return .{ .container = child, .owner = el, .entry = null };
} else return error.FigIndexSkipped;
},
.append_or_last => {
const child = try self.allocator.create(PendingContainer);
child.* = .{ .born_of_append = true };
_ = try child.asMapping(); // append-created elements are always map-shaped
const el = try self.allocator.create(TNode);
el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
try s.elements.append(self.allocator, el);
return .{ .container = child, .owner = el, .entry = null };
},
}
},
}
}
fn boxNode(self: *Parser, node: TNode) Error!*TNode {
const p = try self.allocator.create(TNode);
p.* = node;
return p;
}
// ── Values ───────────────────────────────────────────────────────────────────
fn parseAssignedValue(self: *Parser, type_name: ?[]const u8) Error!TNode {
self.skipSpacesTabs();
if (type_name) |t| {
if (std.mem.eql(u8, t, "string")) {
// `: string =` selects the raw-text sub-parser AND records the tag, so
// the annotation round-trips and `fig fmt` can re-emit the value bare
// (the annotation turns sniffing off on re-read — no re-quoting needed).
var node = try self.parseUntypedValue(true);
node.tag = .{ .kind = .string };
return node;
}
const res = self.scanBareRestOfLine();
if (res.text.len == 0) return error.FigInvalidValue;
// `scanBareRestOfLine` has parked `pos` at end-of-line (past any trailing
// comment), so a raw `pos` anchor would point the caret INTO the comment.
// Pin the diagnostic to the value's own span instead — that is what
// failed its annotation, and what an editor should squiggle.
const vspan = self.spanOf(res.text);
var node = self.applyKnownType(t, res.text) catch |err| return self.failSpan(vspan.start, vspan.end, err);
node.span = vspan;
if (res.comment) |cm| node.trailing = .{ .text = cm };
return node;
}
return self.parseUntypedValue(false);
}
/// `force_string_bare`: set only for `: string =`, where the ENTIRE RHS is one
/// raw text run — the annotation is a TOTAL SINK that selects the raw-text
/// sub-parser (DESIGN.md "annotation selects the sub-parser"), turning off
/// sniffing, flow commitment AND the quote forms. Quote characters are literal
/// content (`x: string = "hi"` is the four-character string `"hi"`),
/// backslashes are literal (no escape processing), and a `'''`/`"""` opener is
/// content too — an author who wants escapes or multiline drops the annotation
/// and quotes normally. The trailing-`#`-comment rule is exactly the bare
/// rule (`scanBareRestOfLine`): a quote-led RHS behaves identically to a bare
/// one.
fn parseUntypedValue(self: *Parser, force_string_bare: bool) Error!TNode {
if (force_string_bare) {
// So `x: string = [ 1 + 2 ]` is the string `[ 1 + 2 ]`, never a
// sequence — the quote-free escape hatch that needs no interior
// escaping. (`fig fmt` re-emits the value bare so the form round-trips,
// dropping the tag when the value has no bare spelling.)
const res = self.scanBareRestOfLine();
if (res.text.len == 0) return error.FigInvalidValue;
var node: TNode = .{ .value = .{ .string = res.text }, .span = self.spanOf(res.text) };
if (res.comment) |cm| node.trailing = .{ .text = cm };
return node;
}
const c = self.peek() orelse return error.FigInvalidValue;
switch (c) {
'\'' => return self.parseQuotedOrTriple('\''),
'"' => return self.parseQuotedOrTriple('"'),
'[', '{' => {
// Commitment is decided by the shape of the WHOLE RHS, not just the
// first char (DESIGN.md "Committed values"): a balanced `[…]`/`{…}`
// with trailing content — a markdown link, glob, regex — was never
// flow, so it is a bare string, left unquoted.
if (tok.classifyBracketCommit(self.source, self.pos) == .bare_trailing) {
const vstart = self.pos;
if (try self.flowLikePrefix(vstart)) |close| {
try self.warnAt(.flow_like_string, vstart, close + 1);
}
const res = self.scanBareRestOfLine();
if (res.text.len == 0) return error.FigInvalidValue;
// Opened with a delimiter → sniffing is off; it is a string.
var node: TNode = .{ .value = .{ .string = res.text }, .span = self.spanOf(res.text) };
if (res.comment) |cm| node.trailing = .{ .text = cm };
return node;
}
// `.flow` (terminal close) and `.unclosed` (multi-line / truncation)
// both commit: the flow parser succeeds or raises a hard error.
//
// A `# …` after the opening `[`/`{`, with nothing else on the line,
// is a block-layer trailing comment on the flow value — the
// multiline-string opener rule's flow twin (DESIGN.md "Multiline
// strings"): the opener line is still block-layer; flow content
// begins at the next newline. Captured here, then skipped by the
// flow parser's `skipFlowWs` as interior trivia. An opener comment
// wins over a close-line one, mirroring the `'''` rule.
const opener_comment = self.scanFlowOpenerComment();
var node = try self.parseFlowValue();
const trailing = try self.scanTrailingCommentOnly();
node.trailing = if (opener_comment) |oc|
.{ .text = oc }
else if (trailing) |t|
.{ .text = t }
else
null;
return node;
},
else => {
const vstart = self.pos;
const res = self.scanBareRestOfLine();
if (res.text.len == 0) return error.FigInvalidValue;
var node: TNode = try self.sniffToNodeWarned(res.text, vstart);
if (res.comment) |cm| node.trailing = .{ .text = cm };
return node;
},
}
}
fn parseQuotedOrTriple(self: *Parser, q: u8) Error!TNode {
const start = self.pos;
if (self.isTripleAt(self.pos, q)) {
// The opener line is still block-layer: only whitespace and an optional
// `# comment` may follow the opening delimiter — content begins on the
// NEXT line, so anything else here would otherwise be silently lost
// (`x = '''abc'''` is NOT a one-line multiline string).
var j = self.pos + 3;
while (j < self.source.len and (self.source[j] == ' ' or self.source[j] == '\t')) j += 1;
if (j < self.source.len and self.source[j] != '\n' and self.source[j] != '#' and !self.isCrlfAt(j))
return self.failAt(j, error.FigMultilineOpenerContent);
const r = if (q == '\'')
try tok.scanTripleSingle(self.allocator, self.source, self.pos)
else
try tok.scanTripleDouble(self.allocator, self.source, self.pos);
self.pos = r.end;
var node: TNode = .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
const trailing = try self.scanTrailingCommentOnly();
node.trailing = if (r.opener_comment) |oc| .{ .text = oc } else if (trailing) |t| .{ .text = t } else null;
return node;
}
const r = if (q == '\'')
try tok.scanSingleQuoted(self.allocator, self.source, self.pos)
else
try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
var node: TNode = .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
// A single-line quote that closed early with more content on the line is
// almost always a bare string wrapped in unescaped quotes (`k = "a "b""`).
// Remap the generic trailing-content error to the quote-specific message
// that names the bare-string fix; `scanTrailingCommentOnly` has already
// parked `self.pos` on the stray content, so the caret lands there.
const t = self.scanTrailingCommentOnly() catch |err| switch (err) {
error.FigTrailingContent => return self.failAt(self.pos, error.FigQuotedTrailingContent),
else => |e| return e,
};
if (t) |c| node.trailing = .{ .text = c };
return node;
}
fn sniffToNode(self: *Parser, text: []const u8) TNode {
const span = self.spanOf(text);
return switch (tok.sniffBare(text)) {
.null_ => .{ .value = .null_, .span = span },
.boolean => |b| .{ .value = .{ .boolean = b }, .span = span },
.number => |n| .{ .value = .{ .number = n }, .span = span },
.datetime => |d| .{ .value = .{ .extended = .{ .kind = d.kind, .text = d.raw } }, .span = span },
.string => .{ .value = .{ .string = text }, .span = span },
};
}
/// `sniffToNode` plus the coercion warns (DESIGN.md "Coercion diagnostics") —
/// the sniffing cost surfaced rather than removed. `offset` anchors the warn
/// at the value's first byte. Both bare-value positions (block RHS, flow
/// element) route through here; explicitly typed values never do (the
/// annotation is the author saying "I know").
fn sniffToNodeWarned(self: *Parser, text: []const u8, offset: usize) Error!TNode {
const node = self.sniffToNode(text);
const end = offset + text.len; // the exact token extent, for a tight squiggle
switch (node.value) {
.string => {
if (looksLikeLiteral(text)) {
try self.warnAt(.string_looks_like_literal, offset, end);
} else if (looksLikeLeadingZero(text)) {
try self.warnAt(.string_leading_zero, offset, end);
} else if (looksLikeTrailingDotNumber(text) or looksLikeLeadingDotNumber(text)) {
try self.warnAt(.string_looks_like_number, offset, end);
}
},
// Quietly, and only when ambiguous: a bare clock time reads as a
// duration/ratio/score as easily as a time-of-day. A bare *date*
// (`local_date`) does not warn — in hand-authored config it is
// overwhelmingly a deliberate date (frontmatter, deadlines, changelog
// entries), so flagging it was warn-fatigue on the common case, not a
// catch on the rare one. A `T`/zone-carrying timestamp
// (`local_datetime`/`offset_datetime`) is unambiguous (nobody types
// `T` or `+HH:MM` mid-sentence) and stays silent too.
.extended => |e| switch (e.kind) {
.local_time => try self.warnAt(.ambiguous_datetime, offset, end),
else => {},
},
else => {},
}
return node;
}
/// Case-variant bool/null spellings from other config languages (`Yes`, `ON`,
/// `TRUE`, `Null`) — never literals in fig (the Norway fix), but surprising
/// enough as silent strings to earn a warn. Exact lowercase `true`/`false`/
/// `null` never reach here (they sniff to literals first).
fn looksLikeLiteral(text: []const u8) bool {
const words = [_][]const u8{ "yes", "no", "on", "off", "true", "false", "null" };
for (words) |w| {
if (std.ascii.eqlIgnoreCase(text, w)) return true;
}
return false;
}
/// Does a `bare_trailing` value at `start` *look* like it wanted to be flow?
/// The index of the closing bracket when the balanced bracket prefix at `start`
/// (a) detaches from the trailing content by whitespace and (b) is itself
/// well-formed flow — i.e. the `flow_like_string` warning should fire spanning
/// `start..=close`. Null otherwise: `[Blog](/x)` / `[a-z]*.md` / `[b]x[/b]`
/// (glued: the intended bare-string shapes, DESIGN.md's warn-fatigue guard) and
/// `[80,, 443] x` (prefix malformed: never parsed as flow in the first place).
///
/// Returning the close index lets both call sites warn without recomputing it.
/// Well-formedness is decided by a speculative sub-parse of the prefix alone
/// (which reuses the real flow grammar rather than re-deriving it here); its
/// containers land in the shared arena and its warnings are discarded with the
/// sub-parser.
fn flowLikePrefix(self: *Parser, start: usize) Error!?usize {
const close = tok.bracketCloseIndex(self.source, start) orelse return null;
if (close + 1 >= self.source.len) return null;
const sep = self.source[close + 1];
if (sep != ' ' and sep != '\t') return null; // glued trailing → intended string
const prefix = self.source[start .. close + 1];
var sub: Parser = .{ .allocator = self.allocator, .source = prefix };
_ = sub.parseFlowValue() catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return null,
};
return if (sub.pos == prefix.len) close else null;
}
/// `007`-style: a token (after an optional sign) that would be a valid number
/// but for a leading zero on its integer part — digit-only (`007`, `-07`) or a
/// float shape (`01.5`, `00.5`, `-01e2`) — kept as a string by the TOML
/// leading-zero rule. Decided by re-sniffing with the extra zeros stripped, so
/// anything that still isn't a number (`0x`, `01.5.6`, `007a`) never warns.
fn looksLikeLeadingZero(text: []const u8) bool {
var s = text;
if (s.len > 0 and (s[0] == '+' or s[0] == '-')) s = s[1..];
if (s.len < 2 or s[0] != '0' or !std.ascii.isDigit(s[1])) return false;
var k: usize = 0;
while (k + 1 < s.len and s[k] == '0' and std.ascii.isDigit(s[k + 1])) k += 1;
return tok.sniffNumber(s[k..]) != null;
}
/// `1.`-style: a clean integer followed by a single trailing dot (`1.`, `42.`,
/// `-3.`) — a number that fell to a string because no fig float spelling has a
/// bare trailing dot. Deliberately NARROW: the part before the dot must sniff
/// as a *decimal integer*, so a version string (`1.2.3`), a leading-zero token
/// (`09.`, already number-ish but not a clean int), and prose (`12 monkeys`)
/// never warn — only the "sig fig" trailing-dot case the coerce-with-`: float`
/// message addresses.
fn looksLikeTrailingDotNumber(text: []const u8) bool {
if (text.len < 2 or text[text.len - 1] != '.') return false;
const head = text[0 .. text.len - 1];
const n = tok.sniffNumber(head) orelse return false;
return n.kind == .integer;
}
/// `.5`-style: a single leading dot followed by clean digits only (`.5`,
/// `.25`) — the shorthand-float habit no fig number spelling accepts (and
/// `: float` does not coerce; the fix is writing the zero: `0.5`). As
/// deliberately NARROW as the trailing-dot twin above: `.5.6`, `.e5`, `..`, a
/// lone `.`, and a signed `-.5` never warn.
fn looksLikeLeadingDotNumber(text: []const u8) bool {
if (text.len < 2 or text[0] != '.') return false;
for (text[1..]) |c| {
if (!std.ascii.isDigit(c)) return false;
}
return true;
}
/// Explicit typing (`key: type = value`): the annotation is checked and
/// coercing, and now also STORED as a cross-format type tag (`ast.node_tags`)
/// so it round-trips (DESIGN.md "Explicit typing"). The value's VERBATIM lexeme
/// is kept as the node's `raw`/`text` — the coercion normalizers below run only
/// to *validate* (a genuine mismatch still errors), never to rewrite the bytes,
/// so `1.`/`09` survive exactly. `enum`/`datetime` map to distinct `extended`
/// node kinds that self-annotate on print, so they carry no tag.
fn applyKnownType(self: *Parser, type_name: []const u8, text: []const u8) Error!TNode {
// Resolve the annotation name once (an unknown name is `FigUnknownType`),
// then switch — instead of a chain of `mem.eql` per candidate.
const TypeName = enum { int, float, bool, @"enum", char, datetime, date, time };
const which = std.meta.stringToEnum(TypeName, type_name) orelse return error.FigUnknownType;
switch (which) {
.int => {
if (tok.sniffNumber(text)) |n| {
if (n.kind != .integer) return error.FigTypeMismatch;
return .{ .value = .{ .number = .{ .raw = text, .kind = .integer } }, .tag = .{ .kind = .integer } };
}
// Coercion opt-in: a leading-zero decimal (`09`, `007`) is a *string*
// when bare (the Leading-zero rule), but `: int` is the author
// overriding that default. Validate the shape (null → mismatch); keep
// the authored lexeme verbatim so the padding round-trips.
if ((try self.normalizeDecimal(text)) != null)
return .{ .value = .{ .number = .{ .raw = text, .kind = .integer } }, .tag = .{ .kind = .integer } };
return error.FigTypeMismatch;
},
.float => {
if (std.mem.eql(u8, text, "inf") or std.mem.eql(u8, text, "-inf") or std.mem.eql(u8, text, "nan")) {
return .{ .value = .{ .extended = .{ .kind = .number_special, .text = text } } };
}
if (tok.sniffNumber(text)) |_|
return .{ .value = .{ .number = .{ .raw = text, .kind = .float } }, .tag = .{ .kind = .float } };
// Coercion opt-in: a trailing-dot (`1.`) or leading-zero (`09`) token
// is a string when bare, but `: float` overrides. Validate the shape
// (null → mismatch); keep the authored lexeme verbatim (`1.` stays `1.`).
if ((try self.normalizeCoercedFloat(text)) != null)
return .{ .value = .{ .number = .{ .raw = text, .kind = .float } }, .tag = .{ .kind = .float } };
return error.FigTypeMismatch;
},
.bool => {
if (std.mem.eql(u8, text, "true")) return .{ .value = .{ .boolean = true }, .tag = .{ .kind = .boolean } };
if (std.mem.eql(u8, text, "false")) return .{ .value = .{ .boolean = false }, .tag = .{ .kind = .boolean } };
return error.FigTypeMismatch;
},
.@"enum" => {
if (text.len == 0) return error.FigTypeMismatch;
return .{ .value = .{ .extended = .{ .kind = .enum_literal, .text = text } } };
},
.char => {
// `: char = 'A'` — a single ZON-style char literal. The annotation is
// the disambiguator (like `enum`/`float`): it turns a `'…'` RHS, which
// is a length-1 string bare, into a `char_literal` extended scalar.
// Reusing the Zig char codec keeps escapes (`'\n'`, `'\u{1F600}'`)
// working for free. Stored as the decimal codepoint — the cross-format
// `char_literal` invariant (see AST `ExtKind.char_literal`) — allocated
// in the parse arena like `normalizeDecimal`, so the builder dupes it
// into the AST's strings.
const cp: u21 = switch (std.zig.string_literal.parseCharLiteral(text)) {
.success => |c| c,
.failure => return error.FigTypeMismatch,
};
const raw = try std.fmt.allocPrint(self.allocator, "{d}", .{cp});
return .{ .value = .{ .extended = .{ .kind = .char_literal, .text = raw } } };
},
.datetime, .date, .time => {
const k = datetime.classify(text, .{}) catch return error.FigTypeMismatch;
const ext: tok.ExtKind = switch (k) {
.offset_datetime => .offset_datetime,
.local_datetime => .local_datetime,
.local_date => .local_date,
.local_time => .local_time,
};
const ok = switch (which) {
.date => ext == .local_date,
.time => ext == .local_time,
else => true, // "datetime" accepts any of the four shapes
};
if (!ok) return error.FigTypeMismatch;
return .{ .value = .{ .extended = .{ .kind = ext, .text = text } } };
},
}
}
/// Normalize a decimal-integer token that `sniffNumber` rejected only for its
/// leading zero (`09`, `007`, `-00`) into a valid integer raw: sign preserved
/// (`+` dropped), leading zeros stripped to at least one digit. Returns null if
/// the post-sign body isn't purely decimal digits (so a genuine mismatch still
/// errors). Arena-allocated; the builder dupes it into the AST's owned strings.
fn normalizeDecimal(self: *Parser, text: []const u8) Error!?[]const u8 {
var i: usize = 0;
const negative = text.len > 0 and text[0] == '-';
if (text.len > 0 and (text[0] == '+' or text[0] == '-')) i = 1;
const body = text[i..];
if (body.len == 0) return null;
for (body) |ch| if (!std.ascii.isDigit(ch)) return null;
var start: usize = 0;
while (start + 1 < body.len and body[start] == '0') start += 1; // keep ≥1 digit
const digits = body[start..];
return try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ if (negative) "-" else "", digits });
}
/// Normalize a token `: float` accepts by coercion but `sniffNumber` rejects:
/// a trailing-dot integer (`1.` → `1.0`) or a leading-zero decimal (`09` →
/// `9.0`). Returns null when neither shape applies (a real mismatch → error).
fn normalizeCoercedFloat(self: *Parser, text: []const u8) Error!?[]const u8 {
// Trailing dot: `1.` — the head must be a clean decimal integer body.
if (text.len >= 2 and text[text.len - 1] == '.') {
if (try self.normalizeDecimal(text[0 .. text.len - 1])) |digits|
return try std.fmt.allocPrint(self.allocator, "{s}.0", .{digits});
return null;
}
// Leading-zero decimal in float position: `09` → `9.0`.
if (try self.normalizeDecimal(text)) |digits|
return try std.fmt.allocPrint(self.allocator, "{s}.0", .{digits});
return null;
}
const BareScan = struct { text: []const u8, comment: ?[]const u8 };
/// Capture a bare RHS to end of line, honoring the `#`-after-whitespace rule
/// (a `#` glued to non-whitespace, e.g. a URL fragment, stays literal).
/// Leaves `self.pos` at the newline/EOF (never past it).
fn scanBareRestOfLine(self: *Parser) BareScan {
const start = self.pos;
var i = start;
var prev_space = true; // the separator space before the value was already skipped
var comment_start: ?usize = null;
while (i < self.source.len and self.source[i] != '\n') : (i += 1) {
const ch = self.source[i];
if (ch == '#' and prev_space) {
comment_start = i;
break;
}
prev_space = (ch == ' ' or ch == '\t');
}
const value_end = comment_start orelse i;
const text = std.mem.trim(u8, self.source[start..value_end], " \t\r");
var comment: ?[]const u8 = null;
var line_end = value_end;
if (comment_start) |cs| {
var j = cs + 1;
while (j < self.source.len and self.source[j] != '\n') j += 1;
comment = std.mem.trim(u8, self.source[cs + 1 .. j], " \t\r");
line_end = j;
}
self.pos = line_end;
return .{ .text = text, .comment = comment };
}
/// After a committed value (quote/flow/multiline) ends, the rest of the line
/// may only be whitespace and an optional `# comment` — anything else is
/// `FigTrailingContent`. Leaves `self.pos` at the newline/EOF.
fn scanTrailingCommentOnly(self: *Parser) Error!?[]const u8 {
self.skipSpacesTabs();
if (self.pos >= self.source.len or self.source[self.pos] == '\n' or self.atCrlf()) return null;
if (self.source[self.pos] == '#') {
const cs = self.pos + 1;
var j = cs;
while (j < self.source.len and self.source[j] != '\n') j += 1;
const c = std.mem.trim(u8, self.source[cs..j], " \t\r");
self.pos = j;
return c;
}
return error.FigTrailingContent;
}
// ── Flow mode (recursive descent; fig-inline ∪ JSON5) ────────────────────────
/// Peek (without consuming) a `# …` comment that is the only thing after the
/// opening `[`/`{` on its line. Called with `self.pos` on the opening bracket.
/// Returns the trimmed comment text, or null when the line carries flow
/// content instead. Not consumed on purpose: `skipFlowWs` discards the same
/// span during the flow parse, so capture and parse stay independent.
fn scanFlowOpenerComment(self: *const Parser) ?[]const u8 {
var i = self.pos + 1; // past the opening bracket
while (i < self.source.len and (self.source[i] == ' ' or self.source[i] == '\t')) i += 1;
if (i >= self.source.len or self.source[i] != '#') return null;
var j = i + 1;
while (j < self.source.len and self.source[j] != '\n') j += 1;
return std.mem.trim(u8, self.source[i + 1 .. j], " \t\r");
}
fn parseFlowValue(self: *Parser) Error!TNode {
return switch (self.peek().?) {
'[' => self.parseFlowArray(),
'{' => self.parseFlowObject(),
else => unreachable,
};
}
/// Skip whitespace, newlines, and `#` comments between flow tokens. Comments are
/// discarded (a documented scope cut: flow-interior comments are not attached to
/// the AST). Every call site is a structural boundary — after `[`/`{`/`,`/`:`/`=`
/// or a completed value — so a `#` here is unambiguously a comment, not the
/// fragment of a bare value (which `scanFlowBareValue` handles via the
/// `#`-after-whitespace rule).
fn skipFlowWs(self: *Parser) void {
while (self.pos < self.source.len) {
switch (self.source[self.pos]) {
' ', '\t', '\r', '\n' => self.pos += 1,
'#' => while (self.pos < self.source.len and self.source[self.pos] != '\n') : (self.pos += 1) {},
else => return,
}
}
}
fn parseFlowArray(self: *Parser) Error!TNode {
const open = self.pos;
self.advance(); // '['
const seq = try self.allocator.create(PendingContainer);
seq.* = .{ .closed = true };
const s = try seq.asSequence();
self.skipFlowWs();
if (self.peek() == ']') {
self.advance();
return .{ .value = .{ .container = seq }, .span = Span.init(open, self.pos) };
}
while (true) {
const v = try self.parseFlowScalarOrNested();
try s.elements.append(self.allocator, try self.boxNode(v));
self.skipFlowWs();
const p = self.peek() orelse return error.FigUnclosedFlow;
if (p == ',') {
self.advance();
self.skipFlowWs();
if (self.peek() == ']') {
self.advance();
break;
}
continue;
}
if (p == ']') {
self.advance();
break;
}
return error.FigUnclosedFlow;
}
return .{ .value = .{ .container = seq }, .span = Span.init(open, self.pos) };
}
fn parseFlowObject(self: *Parser) Error!TNode {
const open = self.pos;
self.advance(); // '{'
const map = try self.allocator.create(PendingContainer);
map.* = .{ .closed = true };
const m = try map.asMapping();
self.skipFlowWs();
if (self.peek() == '}') {
self.advance();
return .{ .value = .{ .container = map }, .span = Span.init(open, self.pos) };
}
// A flow object is EITHER fig-inline (`=` pairs, keys bare or quoted) OR
// JSON (`:` pairs, keys quoted) — the pair separator selects the spelling,
// and it may not change within one object. `:` with a bare key is the
// YAML/JSON5 habit; its own error code so the message can name both fixes
// (`key = 1` fig / `"key": 1` JSON).
var mode: ?enum { fig, json } = null;
while (true) {
const key = try self.parseFlowKey();
self.skipFlowWs();
const sep = self.peek() orelse return error.FigUnclosedFlow;
const this_mode: @TypeOf(mode) = switch (sep) {
'=' => .fig,
':' => if (key.quoted) .json else return error.FigFlowBareKeyColon,
else => return error.FigUnclosedFlow,
};
if (mode) |mm| {
if (mm != this_mode) return error.FigMixedFlowSeparators;
} else mode = this_mode;
self.advance();
self.skipFlowWs();
const v = try self.parseFlowScalarOrNested();
if (self.findEntry(m, key.text) != null) return error.FigDuplicateKey;
const entry = try self.allocator.create(MEntry);
entry.* = .{ .key = key.text, .key_span = key.span, .value = v };
try m.addEntry(self.allocator, entry);
self.skipFlowWs();
const p = self.peek() orelse return error.FigUnclosedFlow;
if (p == ',') {
self.advance();
self.skipFlowWs();
if (self.peek() == '}') {
self.advance();
break;
}
continue;
}
if (p == '}') {
self.advance();
break;
}
return error.FigUnclosedFlow;
}
return .{ .value = .{ .container = map }, .span = Span.init(open, self.pos) };
}
const FlowKey = struct { text: []const u8, quoted: bool, span: Span };
fn parseFlowKey(self: *Parser) Error!FlowKey {
const c = self.peek() orelse return error.FigBadKey;
const start = self.pos;
if (c == '"') {
const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .text = r.text, .quoted = true, .span = Span.init(start, r.end) };
}
if (c == '\'') {
const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .text = r.text, .quoted = true, .span = Span.init(start, r.end) };
}
while (self.pos < self.source.len and !isFlowKeyStop(self.source[self.pos])) self.pos += 1;
if (self.pos == start) return error.FigBadKey;
return .{ .text = self.source[start..self.pos], .quoted = false, .span = Span.init(start, self.pos) };
}
/// A bare flow KEY ends at the separator/space/close set (keys never contain
/// whitespace). A bare flow VALUE is different — see `scanFlowBareValue`.
fn isFlowKeyStop(c: u8) bool {
return switch (c) {
',', ']', '}', ':', '=', ' ', '\t', '\n', '\r' => true,
else => false,
};
}
fn parseFlowScalarOrNested(self: *Parser) Error!TNode {
self.skipFlowWs();
const c = self.peek() orelse return error.FigUnclosedFlow;
if (c == '[' or c == '{') {
// A balanced bracket group with trailing content — a markdown link, a
// glob, a regex — is a bare-string element, not a nested collection:
// the flow twin of the block layer's balanced-then-trailing rule. This
// is what lets `[Blog](/Blog.md)` sit unquoted in a flow list.
if (tok.classifyFlowBracket(self.source, self.pos) == .bare_trailing) {
const vstart = self.pos;
if (try self.flowLikePrefix(vstart)) |close| {
try self.warnAt(.flow_like_string, vstart, close + 1);
}
const text = self.scanFlowBareBracket();
if (text.len == 0) return error.FigInvalidValue;
// Opened with a delimiter → sniffing is off; it is a string.
return .{ .value = .{ .string = text }, .span = self.spanOf(text) };
}
return self.parseFlowValue();
}
if (c == '"') {
const start = self.pos;
const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
}
if (c == '\'') {
const start = self.pos;
const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
self.pos = r.end;
return .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
}
const vstart = self.pos;
const text = self.scanFlowBareValue();
if (text.len == 0) return error.FigInvalidValue;
// The missing-comma smell: a bare flow value swallowing a ` = ` almost
// always means `{x = 1 y = 2}` — the pair separator of a *next* pair read
// as value text. The scariest silent behavior bare flow values allow, so
// it warns (quote the value to affirm genuine ` = ` text).
if (std.mem.indexOf(u8, text, " = ")) |i| try self.warnAt(.flow_missing_comma, vstart + i + 1, vstart + i + 2);
// `Infinity`/`NaN` are NOT recognized here (JSON5 is dropped): they sniff to
// plain strings, exactly as bare `inf`/`nan` do in the block layer.
return try self.sniffToNodeWarned(text, vstart);
}
/// A bare flow value runs to the next `,`/`]`/`}`/newline — spaces included, so
/// `[Adam Harris, Makena Harris]` yields two-word strings (DESIGN.md
/// `BAREVAL ::= flow bare token up to , ] }`). A `#` after whitespace ends the
/// value and opens a comment (skipped by the following `skipFlowWs`); a `#`
/// glued to non-whitespace stays literal. Trailing whitespace is trimmed.
fn scanFlowBareValue(self: *Parser) []const u8 {
const start = self.pos;
var prev_space = false;
while (self.pos < self.source.len) : (self.pos += 1) {
const ch = self.source[self.pos];
switch (ch) {
',', ']', '}', '\n' => break,
'#' => if (prev_space) break,
else => {},
}
prev_space = (ch == ' ' or ch == '\t');
}
return std.mem.trim(u8, self.source[start..self.pos], " \t\r");
}
/// Bracket-aware bare flow value for a balanced-then-trailing element (one whose
/// first char is `[`/`{`, e.g. a markdown link). `[`/`{` raise a nesting depth
/// and `]`/`}` lower it, so an interior `]` (the `]` of `[text]`) does NOT
/// terminate the value — only a `,`/`]`/`}` at depth 0, a newline, or a `#`
/// after whitespace does. Trailing whitespace is trimmed.
fn scanFlowBareBracket(self: *Parser) []const u8 {
const start = self.pos;
var depth: usize = 0;
var prev_space = false;
while (self.pos < self.source.len) : (self.pos += 1) {
const ch = self.source[self.pos];
switch (ch) {
'[', '{' => depth += 1,
']', '}' => {
if (depth == 0) break;
depth -= 1;
},
',', '\n' => if (depth == 0) break,
'#' => if (prev_space and depth == 0) break,
else => {},
}
prev_space = (ch == ' ' or ch == '\t');
}
return std.mem.trim(u8, self.source[start..self.pos], " \t\r");
}
// ── AST assembly (bottom-up, via AST.Builder) ────────────────────────────────
fn buildRoot(self: *Parser, b: *AST.Builder) Error!AST.Node.Id {
// An empty (or comments-only) document leaves the root `undecided`. Fig has
// no bare-scalar root, and an empty mapping is the natural seed for a fresh
// file (`fig set new.figl k v` lands its first key into it), so coerce the
// root to an empty map rather than raising the `FigEmptyContainer` error a
// childless *nested* container still (correctly) does — the map/sequence
// ambiguity that error guards against only bites once a key or `*` names a
// real child, which the root here has none of.
if (self.root.kind == .undecided) self.root.kind = .mapping;
var root_wrap: TNode = .{ .value = .{ .container = &self.root }, .span = Span.init(0, 0) };
root_wrap.dangling = self.root_dangling;
return self.buildNode(b, &root_wrap);
}
/// Build `node` into `b`, returning its id. For a container, `node.span.end`
/// is widened here to the full extent of its subtree — every child's own span
/// is already final by the time it's read back (`buildNode` recurses
/// bottom-up), so no separate position-tracking pass is needed during the
/// original line scan; only each container's OPENING position (stamped at
/// creation time — see `TNode.span`'s doc comment) had to be recorded there.
fn buildNode(self: *Parser, b: *AST.Builder, node: *TNode) Error!AST.Node.Id {
const id: AST.Node.Id = switch (node.value) {
.null_ => try b.addNull(),
.boolean => |v| try b.addBool(v),
.string => |s| try b.addString(s),
.number => |n| try b.addNumberRaw(n.raw, n.kind == .float),
.extended => |e| try b.addExtended(e.kind, e.text),
.container => |c| blk: {
const built = try self.buildContainer(b, c);
node.span.end = @max(node.span.end, built.end);
break :blk built.id;
},
};
b.setSpan(id, node.span);
if (node.tag) |tag| try b.setTag(id, tag);
if (node.trailing) |t| try b.setTrailingComment(id, t);
for (node.dangling.items) |c| try b.addDanglingComment(id, c);
return id;
}
const BuiltContainer = struct { id: AST.Node.Id, end: usize };
fn buildContainer(self: *Parser, b: *AST.Builder, c: *PendingContainer) Error!BuiltContainer {
switch (c.kind) {
.undecided => return error.FigEmptyContainer,
.mapping => {
var kv_ids: std.ArrayList(AST.Node.Id) = .empty;
try kv_ids.ensureTotalCapacityPrecise(self.allocator, c.mapping.entries.items.len);
var end: usize = 0;
for (c.mapping.entries.items) |e| {
const key_id = try b.addString(e.key);
b.setSpan(key_id, e.key_span);
if (e.key_leading.items.len > 0) try b.setComments(key_id, .{ .leading = e.key_leading.items });
const value_id = try self.buildNode(b, &e.value);
const kv_id = try b.addKeyValue(key_id, value_id);
b.setSpan(kv_id, Span.init(e.key_span.start, e.value.span.end));
end = @max(end, e.value.span.end);
kv_ids.appendAssumeCapacity(kv_id);
}
const id = try b.addMappingFromEntries(kv_ids.items);
try self.recordReentries(c, id);
return .{ .id = id, .end = end };
},
.sequence => {
var ids: std.ArrayList(AST.Node.Id) = .empty;
try ids.ensureTotalCapacityPrecise(self.allocator, c.sequence.elements.items.len);
var end: usize = 0;
for (c.sequence.elements.items) |el| {
const el_id = try self.buildNode(b, el);
end = @max(end, el.span.end);
for (el.leading.items) |lc| try b.addLeadingComment(el_id, lc);
ids.appendAssumeCapacity(el_id);
}
const id = try b.addSequence(ids.items);
try self.recordReentries(c, id);
return .{ .id = id, .end = end };
},
}
}
/// Resolve `c`'s recorded re-entry header-line offsets (if any) to the built
/// node id `id` — the `PendingContainer` → `Document.reentry_headers` bridge.
fn recordReentries(self: *Parser, c: *const PendingContainer, id: AST.Node.Id) Error!void {
for (c.reentries.items) |off| {
try self.built_reentries.append(self.allocator, .{ .node_id = id, .content_start = off });
}
}
// ── Char-level helpers ───────────────────────────────────────────────────────
/// Span of `text` within `self.source`, valid only when `text` is literally a
/// subslice of it — true for every bare/trimmed token (`scanBareRestOfLine`,
/// `scanFlowBareValue`/`scanFlowBareBracket`, a bare key segment) but NOT for
/// a quoted/decoded string (those track their own `(start, r.end)` at the call
/// site, since escape decoding allocates a new buffer).
fn spanOf(self: *const Parser, text: []const u8) Span {
const base = @intFromPtr(self.source.ptr);
const start = @intFromPtr(text.ptr) - base;
return Span.init(start, start + text.len);
}
fn peek(self: *Parser) ?u8 {
return if (self.pos < self.source.len) self.source[self.pos] else null;
}
fn advance(self: *Parser) void {
if (self.pos < self.source.len) self.pos += 1;
}
fn skipSpacesTabs(self: *Parser) void {
while (self.pos < self.source.len and (self.source[self.pos] == ' ' or self.source[self.pos] == '\t')) self.pos += 1;
}
fn atEndOfContent(self: *Parser) bool {
const p = self.peek();
return p == null or p.? == '\n' or p.? == '#' or self.atCrlf();
}
/// Stricter than `atEndOfContent`: true only for real end-of-line/EOF, never
/// for a `#` — used where a comment must NOT be swallowed as "blank".
fn atTrueLineEnd(self: *Parser) bool {
const p = self.peek();
return p == null or p.? == '\n' or self.atCrlf();
}
/// A `\r` that terminates its line — immediately before `\n` or at EOF — is
/// trivia, so CRLF documents parse identically to their LF counterparts
/// (spec § 3.1). A `\r` anywhere else stays ordinary content bytes.
fn atCrlf(self: *const Parser) bool {
return self.isCrlfAt(self.pos);
}
fn isCrlfAt(self: *const Parser, pos: usize) bool {
return pos < self.source.len and self.source[pos] == '\r' and
(pos + 1 >= self.source.len or self.source[pos + 1] == '\n');
}
fn skipToNextLine(self: *Parser) void {
if (self.atCrlf()) self.pos += 1; // a line-terminating `\r` is trivia (§ 3.1)
if (self.pos < self.source.len and self.source[self.pos] == '\n') self.pos += 1;
}
fn consumeLineEnd(self: *Parser) void {
while (self.pos < self.source.len and self.source[self.pos] != '\n') self.pos += 1;
self.skipToNextLine();
}
fn isTripleAt(self: *Parser, pos: usize, q: u8) bool {
return pos + 3 <= self.source.len and self.source[pos] == q and self.source[pos + 1] == q and self.source[pos + 2] == q;
}
// =========
// TESTS
// =========
const testing = std.testing;
fn expectParse(input: []const u8, expected: AST) !void {
var ast = try parseAbstract(testing.allocator, input, .Fig);
defer ast.deinit();
try testing.expect(expected.eql(ast));
}
test "empty document is an empty root map (seedable for a from-scratch `set`)" {
// No bare-scalar root and no map/sequence ambiguity to resolve (there are no
// children), so an empty file is the empty map `set new.figl k v` seeds into.
try expectParse("", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
.{ .id = 0, .kind = .{ .mapping = null } },
} });
// A comments-only / whitespace-only file is likewise an empty root map, not
// the `FigEmptyContainer` a childless *nested* container still raises.
try expectParse("# just a note\n", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
.{ .id = 0, .kind = .{ .mapping = null } },
} });
try expectParse("\n\n \n", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
.{ .id = 0, .kind = .{ .mapping = null } },
} });
}
test "root map with a scalar assignment" {
try expectParse("x = 1\n", .{ .allocator = testing.allocator, .root = 3, .nodes = &.{
.{ .id = 0, .kind = .{ .string = "x" } },
.{ .id = 1, .kind = .{ .number = .{ .raw = "1", .kind = .integer } } },
.{ .id = 2, .kind = .{ .keyvalue = .{ .key = 0, .value = 1 } } },
.{ .id = 3, .kind = .{ .mapping = 2 } },
} });
}
test "literal-else-string sniffing" {
var ast = try parseAbstract(testing.allocator,
\\a = true
\\b = Yes
\\c = 007
\\d = 12 monkeys
\\e = null
, .Fig);
defer ast.deinit();
try testing.expect((try ast.getValByPath(&.{.{ .key = "a" }})).kind.boolean == true);
try testing.expectEqualStrings("Yes", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
try testing.expectEqualStrings("007", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
try testing.expectEqualStrings("12 monkeys", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.string);
try testing.expect((try ast.getValByPath(&.{.{ .key = "e" }})).kind == .null_);
}
test "nested containers via markers" {
var ast = try parseAbstract(testing.allocator,
\\database
\\> host = localhost
\\> pool
\\>> size = 10
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("localhost", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "host" } })).kind.string);
try testing.expectEqualStrings("10", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "pool" }, .{ .key = "size" } })).kind.number.raw);
}
test "reentry_headers records every header-final re-open, keyed by node id" {
const src = "database\n> x = 1\nother = 1\ndatabase\n> y = 2\n";
const parsed = try parse(testing.allocator, src, .Fig);
defer parsed.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), parsed.reentry_headers.len);
const rh = parsed.reentry_headers[0];
const db = try parsed.ast.getValByPath(&.{.{ .key = "database" }});
try testing.expectEqual(db.id, rh.node_id);
// Anchored at the SECOND "database" header line (the re-open), which the
// creating line's span cannot carry.
try testing.expectEqual(std.mem.lastIndexOf(u8, src, "database").?, rh.content_start);
}
test "reentry_headers is empty without re-entry" {
const parsed = try parse(testing.allocator, "database\n> x = 1\ndatabase.pool\n> y = 2\n", .Fig);
defer parsed.deinit(testing.allocator);
// `database.pool` CREATES pool (anchored by pool's own span) — a deeper
// dotted path is not a re-open of `database` itself.
try testing.expectEqual(@as(usize, 0), parsed.reentry_headers.len);
}
test "dotted key flattener" {
var ast = try parseAbstract(testing.allocator,
\\cache
\\> redis.host = 127.0.0.1
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("127.0.0.1", (try ast.getValByPath(&.{ .{ .key = "cache" }, .{ .key = "redis" }, .{ .key = "host" } })).kind.string);
}
test "sequence via star elements" {
var ast = try parseAbstract(testing.allocator,
\\servers
\\> *
\\>> host = a.com
\\> *
\\>> host = b.com
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("a.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } })).kind.string);
try testing.expectEqualStrings("b.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } })).kind.string);
}
test "scalar sequence" {
var ast = try parseAbstract(testing.allocator,
\\ports
\\> * 1
\\> * 2
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
}
test "dotted section header re-anchors baseline" {
var ast = try parseAbstract(testing.allocator,
\\services.web.frontend
\\> replicas = 3
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "services" }, .{ .key = "web" }, .{ .key = "frontend" }, .{ .key = "replicas" } })).kind.number.raw);
}
test "append header appends and re-anchors" {
var ast = try parseAbstract(testing.allocator,
\\jobs.test.steps[]
\\> uses = a
\\jobs.test.steps[]
\\> run = b
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("a", (try ast.getValByPath(&.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 0 }, .{ .key = "uses" } })).kind.string);
try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 1 }, .{ .key = "run" } })).kind.string);
}
test "index addressing edits existing elements" {
var ast = try parseAbstract(testing.allocator,
\\clusters[0].name = alpha
\\clusters[0].size = 3
\\clusters[1].name = beta
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("alpha", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 0 }, .{ .key = "name" } })).kind.string);
try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 0 }, .{ .key = "size" } })).kind.number.raw);
try testing.expectEqualStrings("beta", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 1 }, .{ .key = "name" } })).kind.string);
}
test "skipping a sequence index errors" {
try testing.expectError(error.FigIndexSkipped, parseAbstract(testing.allocator, "xs[1].a = 1\n", .Fig));
}
test "explicit typing" {
var ast = try parseAbstract(testing.allocator,
\\port: int = 8080
\\mode: enum = creative
\\huge: float = inf
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("8080", (try ast.getValByPath(&.{.{ .key = "port" }})).kind.number.raw);
const mode = try ast.getValByPath(&.{.{ .key = "mode" }});
try testing.expect(mode.kind.extended.kind == .enum_literal);
try testing.expectEqualStrings("creative", mode.kind.extended.text);
const huge = try ast.getValByPath(&.{.{ .key = "huge" }});
try testing.expect(huge.kind.extended.kind == .number_special);
}
test "explicit typing rejects a mismatch" {
try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "port: int = hello\n", .Fig));
}
test "char literal via explicit typing → char_literal codepoint" {
var ast = try parseAbstract(testing.allocator,
\\letter: char = 'A'
\\tab: char = '\t'
\\quote: char = '\''
\\emoji: char = '\u{1F600}'
\\hash: char = '#'
, .Fig);
defer ast.deinit();
// `: char =` reads a ZON-style char literal (escapes included) and stores the
// decimal Unicode codepoint — the cross-format `char_literal` invariant.
const cases = .{
.{ "letter", "65" }, // 'A'
.{ "tab", "9" }, // '\t'
.{ "quote", "39" }, // '\''
.{ "emoji", "128512" }, // '\u{1F600}'
.{ "hash", "35" }, // '#' — a `#` glued inside quotes is not a comment
};
inline for (cases) |c| {
const node = try ast.getValByPath(&.{.{ .key = c[0] }});
try testing.expect(node.kind.extended.kind == .char_literal);
try testing.expectEqualStrings(c[1], node.kind.extended.text);
}
}
test "char literal rejects a non-char lexeme" {
// A bare (unquoted) atom is not a char literal — quotes are the disambiguator.
try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: char = A\n", .Fig));
// Two codepoints in one literal is not a char.
try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: char = 'ab'\n", .Fig));
}
test "explicit typing coerces number lookalikes but keeps the verbatim lexeme + tag" {
var ast = try parseAbstract(testing.allocator,
\\a: float = 1.
\\b: int = 09
\\c: float = 09
\\d: int = -007
\\e: string = [ 1 + 2 ]
, .Fig);
defer ast.deinit();
// `: float`/`: int` accept a trailing-dot / leading-zero lexeme (bare-sniffing
// rejects them), but the VERBATIM bytes are kept as the number raw — the
// annotation is stored as a type tag, so both the lexeme and the `: type`
// surface round-trip through `fig fmt`.
const a = try ast.getValByPath(&.{.{ .key = "a" }});
try testing.expect(a.kind.number.kind == .float);
try testing.expectEqualStrings("1.", a.kind.number.raw);
try testing.expect(ast.node_tags[a.id].?.kind == .float);
const b = try ast.getValByPath(&.{.{ .key = "b" }});
try testing.expect(b.kind.number.kind == .integer);
try testing.expectEqualStrings("09", b.kind.number.raw);
try testing.expect(ast.node_tags[b.id].?.kind == .integer);
try testing.expectEqualStrings("09", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.number.raw);
try testing.expectEqualStrings("-007", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.number.raw);
// `: string` is a total sink: the bracketed RHS is verbatim text, not flow.
const e = try ast.getValByPath(&.{.{ .key = "e" }});
try testing.expectEqualStrings("[ 1 + 2 ]", e.kind.string);
try testing.expect(ast.node_tags[e.id].?.kind == .string);
}
test ": string takes the ENTIRE RHS as raw text — quotes, backslashes, triple-quotes are content" {
var ast = try parseAbstract(testing.allocator,
\\a: string = "hello"
\\b: string = 42
\\c: string = a\nb
\\d: string = '''
\\e: string = 'single'
\\f: string = """
, .Fig);
defer ast.deinit();
// Quote characters are literal content: `a` is the SEVEN-character string
// `"hello"` — no quote form exists under the annotation.
try testing.expectEqualStrings("\"hello\"", (try ast.getValByPath(&.{.{ .key = "a" }})).kind.string);
// Sniffing is off: a number lexeme is the string of its digits (unchanged).
try testing.expectEqualStrings("42", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
// Backslashes are literal — no escape processing (`a\nb` is four characters).
try testing.expectEqualStrings("a\\nb", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
// A triple-quote opener is content too: no multiline form under the
// annotation (drop the annotation and quote normally for that).
try testing.expectEqualStrings("'''", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.string);
try testing.expectEqualStrings("'single'", (try ast.getValByPath(&.{.{ .key = "e" }})).kind.string);
try testing.expectEqualStrings("\"\"\"", (try ast.getValByPath(&.{.{ .key = "f" }})).kind.string);
// Every one still records the `: string` tag.
inline for (.{ "a", "b", "c", "d", "e", "f" }) |k| {
const n = try ast.getValByPath(&.{.{ .key = k }});
try testing.expect(ast.node_tags[n.id].?.kind == .string);
}
}
test ": string quote-led RHS follows the BARE trailing-comment rule" {
// A quote-led raw RHS behaves identically to a bare one: ` # ` after
// whitespace opens a comment; a `#` glued to non-whitespace stays literal.
var ast = try parseAbstract(testing.allocator,
\\a: string = "hi" # note
\\b: string = "x"#y
, .Fig);
defer ast.deinit();
const a = try ast.getValByPath(&.{.{ .key = "a" }});
try testing.expectEqualStrings("\"hi\"", a.kind.string);
try testing.expectEqualStrings("note", ast.comments(a.id).trailing.?.text);
try testing.expectEqualStrings("\"x\"#y", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
}
test ": string = \"hello\" is equivalent to the escaped-quote untagged spelling" {
// The spec's §5.3 equivalence: `str: string = "hello"` ≡ `str = "\"hello\""`
// (same VALUE; the first additionally records the type tag).
var tagged = try parseAbstract(testing.allocator, "str: string = \"hello\"\n", .Fig);
defer tagged.deinit();
var quoted = try parseAbstract(testing.allocator, "str = \"\\\"hello\\\"\"\n", .Fig);
defer quoted.deinit();
const t = try tagged.getValByPath(&.{.{ .key = "str" }});
const q = try quoted.getValByPath(&.{.{ .key = "str" }});
try testing.expectEqualStrings("\"hello\"", t.kind.string);
try testing.expectEqualStrings(t.kind.string, q.kind.string);
try testing.expect(tagged.eql(quoted));
}
test "type-mismatch diagnostic anchors on the value span, not the trailing comment" {
// A scalar-annotation failure (via `applyKnownType`, which runs AFTER the
// line — incl. its comment — was scanned) must point at the VALUE, with a
// tight end before the `#`.
const cases = [_]struct { src: []const u8, value: []const u8 }{
.{ .src = "x: int = hello # note\n", .value = "hello" },
.{ .src = "y: bool = maybe # note\n", .value = "maybe" },
};
for (cases) |c| {
var report: Report = .{};
try testing.expectError(error.FigTypeMismatch, parseWithReport(testing.allocator, c.src, .Fig, &report));
defer testing.allocator.free(report.warnings);
const d = report.diag.?;
// Caret sits at the value start, and the end stops at the value end —
// never inside the ` # note` comment.
const vstart = std.mem.indexOf(u8, c.src, c.value).?;
try testing.expectEqual(vstart, d.offset);
try testing.expect(d.end != null);
try testing.expectEqual(vstart + c.value.len, d.end.?);
try testing.expect(d.end.? < std.mem.indexOfScalar(u8, c.src, '#').?);
}
}
test "parseCollecting recovers past each error and reports them all" {
// Five distinct failures on five lines, with a clean line between two of
// them — a language server wants every squiggle, not just the first. The
// scanner must resync to the next line after each and keep going.
const src =
\\good = 1
\\bad1: int = hello
\\key: value
\\fine = ok
\\dup = 1
\\dup = 2
\\> orphan = 3
;
var report: Report = .{};
try testing.expectError(error.FigTypeMismatch, parseCollecting(testing.allocator, src, .Fig, &report));
defer testing.allocator.free(report.errors);
defer testing.allocator.free(report.warnings);
// FigTypeMismatch (l2), FigForeignSyntaxColon (l3), FigDuplicateKey (l6),
// FigRootMarker (l7) — the clean lines 1/4/5 do not add entries.
try testing.expectEqual(@as(usize, 4), report.errors.len);
try testing.expectEqual(Error.FigTypeMismatch, report.errors[0].code);
try testing.expectEqual(Error.FigForeignSyntaxColon, report.errors[1].code);
try testing.expectEqual(Error.FigDuplicateKey, report.errors[2].code);
try testing.expectEqual(Error.FigRootMarker, report.errors[3].code);
// `diag` mirrors the first error for single-error consumers.
try testing.expectEqual(Error.FigTypeMismatch, report.errors[0].code);
try testing.expect(report.diag != null);
try testing.expectEqual(Error.FigTypeMismatch, report.diag.?.code);
// Source order: each diagnostic sits on a later line than the last.
var prev: usize = 0;
for (report.errors) |d| {
const loc = d.locate(src);
try testing.expect(loc.line > prev);
prev = loc.line;
}
}
test "parseCollecting on a clean file returns the tree with no errors" {
var report: Report = .{};
const doc = try parseCollecting(testing.allocator, "a = 1\nb = two\n", .Fig, &report);
defer doc.deinit(testing.allocator);
defer testing.allocator.free(report.errors);
defer testing.allocator.free(report.warnings);
try testing.expectEqual(@as(usize, 0), report.errors.len);
try testing.expect(report.diag == null);
try testing.expectEqualStrings("1", (try doc.ast.getValByPath(&.{.{ .key = "a" }})).kind.number.raw);
}
test "committed values error rather than falling back to string" {
try testing.expectError(error.FigUnclosedFlow, parseAbstract(testing.allocator, "ports = [80, 443\n", .Fig));
}
test "a single-line quote that closes early with trailing content is the quote-specific error" {
// The classic slip: a bare string wrapped in unescaped outer quotes. The
// quote committed, closed at its match, and left `Hey there!""` stray — a
// quote-specific `FigTrailingContent` that names the bare-string fix.
try testing.expectError(error.FigQuotedTrailingContent, parseAbstract(testing.allocator, "she = \"She said, \"Hey there!\"\"\n", .Fig));
try testing.expectError(error.FigQuotedTrailingContent, parseAbstract(testing.allocator, "x = 'a' b\n", .Fig));
// A clean quoted string with a genuine trailing comment is still fine.
var ok = try parseAbstract(testing.allocator, "x = \"hi\" # note\n", .Fig);
defer ok.deinit();
try testing.expectEqualStrings("hi", (try ok.getValByPath(&.{.{ .key = "x" }})).kind.string);
}
test "balanced-then-trailing bracket RHS is a bare string, not committed flow" {
var ast = try parseAbstract(testing.allocator,
\\link = [Blog](/Blog/Blog.md)
\\angle = [Archived Documents](</Archive/Archived documents.md>)
\\glob = [a-z]*.md
\\regex = [0-9]+ years
\\bbcode = [b]bold[/b]
\\frag = [x]#anchor
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("[Blog](/Blog/Blog.md)", (try ast.getValByPath(&.{.{ .key = "link" }})).kind.string);
try testing.expectEqualStrings("[Archived Documents](</Archive/Archived documents.md>)", (try ast.getValByPath(&.{.{ .key = "angle" }})).kind.string);
try testing.expectEqualStrings("[a-z]*.md", (try ast.getValByPath(&.{.{ .key = "glob" }})).kind.string);
try testing.expectEqualStrings("[0-9]+ years", (try ast.getValByPath(&.{.{ .key = "regex" }})).kind.string);
try testing.expectEqualStrings("[b]bold[/b]", (try ast.getValByPath(&.{.{ .key = "bbcode" }})).kind.string);
// `#` glued to the close is literal (not a comment) → part of the string.
try testing.expectEqualStrings("[x]#anchor", (try ast.getValByPath(&.{.{ .key = "frag" }})).kind.string);
}
test "terminal bracket still commits to flow (with a trailing comment)" {
var ast = try parseAbstract(testing.allocator,
\\ports = [80, 443] # real comment
\\nested = [[a], [b]]
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("443", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "nested" }, .{ .index = 1 }, .{ .index = 0 } })).kind.string);
}
test "markdown links are bare strings as sequence elements too" {
var ast = try parseAbstract(testing.allocator,
\\links
\\> * [Blog](/Blog/Blog.md)
\\> * [Resume](/Resume.md)
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("[Blog](/Blog/Blog.md)", (try ast.getValByPath(&.{ .{ .key = "links" }, .{ .index = 0 } })).kind.string);
try testing.expectEqualStrings("[Resume](/Resume.md)", (try ast.getValByPath(&.{ .{ .key = "links" }, .{ .index = 1 } })).kind.string);
}
test "a bracket that never closes on its line still errors (truncation)" {
try testing.expectError(error.FigUnclosedFlow, parseAbstract(testing.allocator, "x = [oops\n", .Fig));
}
// ── Span tests (Editor(Fig) foundation — see DESIGN.md's "no in-place editor
// yet" note; this is the prerequisite the editor's span-splice engine needs) ──
fn expectSpan(source: []const u8, path: []const AST.PathSegment, expected: []const u8) !void {
const doc = try parse(testing.allocator, source, .Fig);
defer doc.deinit(testing.allocator);
const node = try doc.ast.getValByPath(path);
const span = doc.span(node);
try testing.expectEqualStrings(expected, source[span.start..span.end]);
}
/// Same as `expectSpan`, but reads the *keyvalue* wrapper's span (the raw node
/// at `path`, unwrapped by neither `getKeyByPath` nor `getValByPath`) — what
/// `Editor(Fig)`'s `deleteKey`/`moveKey` splice.
fn expectEntrySpan(source: []const u8, path: []const AST.PathSegment, expected: []const u8) !void {
const doc = try parse(testing.allocator, source, .Fig);
defer doc.deinit(testing.allocator);
const node = try doc.ast.getNodeByPath(path);
const span = doc.span(node);
try testing.expectEqualStrings(expected, source[span.start..span.end]);
}
test "span: scalar assignment values are tight" {
try expectSpan("x = 1\n", &.{.{ .key = "x" }}, "1");
try expectSpan("title = My server\n", &.{.{ .key = "title" }}, "My server");
try expectSpan("s = \"hi\\n\"\n", &.{.{ .key = "s" }}, "\"hi\\n\"");
try expectSpan("s = 'raw'\n", &.{.{ .key = "s" }}, "'raw'");
}
test "span: keys are tight, quotes included" {
try expectSpan("x = 1\n", &.{.{ .key = "x" }}, "1");
const doc = try parse(testing.allocator, "\"my key\" = 1\n", .Fig);
defer doc.deinit(testing.allocator);
const key = try doc.ast.getKeyByPath(&.{.{ .key = "my key" }});
try testing.expectEqualStrings("\"my key\"", "\"my key\" = 1\n"[doc.span(key).start..doc.span(key).end]);
}
test "span: nested marker-block value" {
const src =
\\database
\\> host = localhost
\\> pool
\\> > size = 10
\\
;
try expectSpan(src, &.{ .{ .key = "database" }, .{ .key = "host" } }, "localhost");
try expectSpan(src, &.{ .{ .key = "database" }, .{ .key = "pool" }, .{ .key = "size" } }, "10");
}
test "span: dotted flattener key segment" {
const src =
\\cache
\\> redis.host = 127.0.0.1
\\
;
try expectSpan(src, &.{ .{ .key = "cache" }, .{ .key = "redis" }, .{ .key = "host" } }, "127.0.0.1");
}
test "span: flow container covers exactly its brackets" {
try expectSpan("ports = [80, 443]\n", &.{.{ .key = "ports" }}, "[80, 443]");
try expectSpan("p = { x = 1, y = 2 }\n", &.{.{ .key = "p" }}, "{ x = 1, y = 2 }");
try expectSpan("ports = [80, 443]\n", &.{ .{ .key = "ports" }, .{ .index = 1 } }, "443");
}
test "span: a nested block container's keyvalue covers header through last body line" {
const src =
\\database
\\> host = localhost
\\> pool
\\> > size = 10
\\other = 1
\\
;
try expectEntrySpan(src, &.{ .{ .key = "database" }, .{ .key = "pool" } }, "pool\n> > size = 10");
try expectEntrySpan(src, &.{.{ .key = "database" }}, "database\n> host = localhost\n> pool\n> > size = 10");
}
test "span: sequence element (map-shaped) covers its own body only" {
const src =
\\servers
\\> *
\\>> host = a.com
\\> *
\\>> host = b.com
\\
;
try expectSpan(src, &.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } }, "a.com");
try expectSpan(src, &.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } }, "b.com");
}
test "span: append header re-anchored elements" {
const src =
\\jobs.test.steps[]
\\> uses = a
\\jobs.test.steps[]
\\> run = b
\\
;
try expectSpan(src, &.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 0 }, .{ .key = "uses" } }, "a");
try expectSpan(src, &.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 1 }, .{ .key = "run" } }, "b");
}
test "quotes: raw vs escaped" {
var ast = try parseAbstract(testing.allocator,
\\raw = 'C:\Users\me'
\\esc = "line1\nline2"
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("C:\\Users\\me", (try ast.getValByPath(&.{.{ .key = "raw" }})).kind.string);
try testing.expectEqualStrings("line1\nline2", (try ast.getValByPath(&.{.{ .key = "esc" }})).kind.string);
}
test "comment marker only after whitespace" {
var ast = try parseAbstract(testing.allocator,
\\home = https://example.com
\\api = https://example.com/v1#stable
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("https://example.com", (try ast.getValByPath(&.{.{ .key = "home" }})).kind.string);
try testing.expectEqualStrings("https://example.com/v1#stable", (try ast.getValByPath(&.{.{ .key = "api" }})).kind.string);
}
test "flow mode: fig-inline and pasted JSON" {
var ast = try parseAbstract(testing.allocator,
\\tags = [a, b, c]
\\point = { x = 1, y = 2 }
\\pasted = { "x": 1, "y": [2, 3] }
\\empty_list = []
\\empty_map = {}
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "tags" }, .{ .index = 1 } })).kind.string);
try testing.expectEqualStrings("1", (try ast.getValByPath(&.{ .{ .key = "point" }, .{ .key = "x" } })).kind.number.raw);
try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "pasted" }, .{ .key = "y" }, .{ .index = 1 } })).kind.number.raw);
}
test "flow: bare values may contain spaces (up to the comma)" {
var ast = try parseAbstract(testing.allocator,
\\audience = [friends, Adam Harris, Makena Harris, public]
\\who = { name = Adam Harris }
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("Adam Harris", (try ast.getValByPath(&.{ .{ .key = "audience" }, .{ .index = 1 } })).kind.string);
try testing.expectEqualStrings("Makena Harris", (try ast.getValByPath(&.{ .{ .key = "audience" }, .{ .index = 2 } })).kind.string);
try testing.expectEqualStrings("Adam Harris", (try ast.getValByPath(&.{ .{ .key = "who" }, .{ .key = "name" } })).kind.string);
}
test "flow: JSON vs fig split — bare key + colon is the foreign-syntax error" {
try testing.expectError(error.FigFlowBareKeyColon, parseAbstract(testing.allocator, "p = {x: 1}\n", .Fig));
}
test "flow: an object may not mix = and : separators" {
try testing.expectError(error.FigMixedFlowSeparators, parseAbstract(testing.allocator, "p = {x = 1, \"y\": 2}\n", .Fig));
try testing.expectError(error.FigMixedFlowSeparators, parseAbstract(testing.allocator, "p = {\"x\": 1, y = 2}\n", .Fig));
}
test "flow: Infinity/NaN are plain strings (JSON5 dropped)" {
var ast = try parseAbstract(testing.allocator, "p = [Infinity, NaN, -Infinity]\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("Infinity", (try ast.getValByPath(&.{ .{ .key = "p" }, .{ .index = 0 } })).kind.string);
try testing.expectEqualStrings("NaN", (try ast.getValByPath(&.{ .{ .key = "p" }, .{ .index = 1 } })).kind.string);
}
test "flow: JSONC-style trailing commas and # comments" {
var ast = try parseAbstract(testing.allocator,
\\nums = [
\\ 1, # one
\\ 2, # two
\\ 3,
\\]
\\obj = { "a": 1, "b": 2, } # trailing comma in a JSON object
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "nums" }, .{ .index = 2 } })).kind.number.raw);
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "obj" }, .{ .key = "b" } })).kind.number.raw);
}
test "glued >* element markers parse (scalars and maps)" {
var ast = try parseAbstract(testing.allocator,
\\ports
\\>* 25565
\\>* 25566
\\servers
\\>*
\\>> host = a.com
\\>*
\\>> host = b.com
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("25566", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
try testing.expectEqualStrings("a.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } })).kind.string);
try testing.expectEqualStrings("b.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } })).kind.string);
}
test "spaced > * is accepted and equals glued >*" {
var glued = try parseAbstract(testing.allocator, "xs\n>* 1\n>* 2\n", .Fig);
defer glued.deinit();
var spaced = try parseAbstract(testing.allocator, "xs\n> * 1\n> * 2\n", .Fig);
defer spaced.deinit();
try testing.expect(glued.eql(spaced));
}
test "root sequence elements are bare stars" {
var ast = try parseAbstract(testing.allocator, "* first\n* second\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("second", (try ast.getValByPath(&.{.{ .index = 1 }})).kind.string);
}
test "dash element lines are a foreign-syntax error (YAML habit)" {
try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "- 25565\n", .Fig));
try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n>- 1\n", .Fig));
try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n> - 1\n", .Fig));
try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n> - host = a\n", .Fig));
}
test "a star glued to its value is a bad separator" {
try testing.expectError(error.FigBadMarkerSeparator, parseAbstract(testing.allocator, "xs\n>*1\n", .Fig));
try testing.expectError(error.FigBadMarkerSeparator, parseAbstract(testing.allocator, "*1\n", .Fig));
}
test "multiline strings: raw and dedented" {
var ast = try parseAbstract(testing.allocator,
\\license = '''
\\line one
\\line two
\\'''
\\banner = """
\\ hi
\\ there
\\ """
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("line one\nline two", (try ast.getValByPath(&.{.{ .key = "license" }})).kind.string);
try testing.expectEqualStrings("hi\nthere", (try ast.getValByPath(&.{.{ .key = "banner" }})).kind.string);
}
test "multiline closer is line-anchored: indented closer OK, mid-line triple is content" {
var ast = try parseAbstract(testing.allocator,
"a = '''\n x\n '''\nb = '''\nhas ''' inside\n'''\nc = \"\"\"\n hi\n low\n \"\"\"\n", .Fig);
defer ast.deinit();
// Raw: content lines keep their indentation; the CLOSER line's leading
// whitespace is not content (the closer need not sit flush-left).
try testing.expectEqualStrings(" x", (try ast.getValByPath(&.{.{ .key = "a" }})).kind.string);
// A `'''` that is not the first non-whitespace content of its line is
// ordinary content, never a closer — nothing is silently dropped.
try testing.expectEqualStrings("has ''' inside", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
// Smart-dedent: a line with LESS leading whitespace than the closer's run
// loses whatever it has (never an error, never negative).
try testing.expectEqualStrings("hi\nlow", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
}
test "content after a multiline opener is a hard error, an opener comment is not" {
// `x = '''abc'''` is NOT a one-line multiline string: content begins on the
// next line, so the opener-line text would be silently lost.
try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = '''abc'''\n", .Fig));
try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = \"\"\"abc\nhi\n\"\"\"\n", .Fig));
try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = ''''\nhi\n'''\n", .Fig));
var ast = try parseAbstract(testing.allocator, "x = ''' # note\nhi\n'''\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("hi", (try ast.getValByPath(&.{.{ .key = "x" }})).kind.string);
}
test "CRLF input parses identically to LF (a line-terminating \\r is trivia)" {
var ast = try parseAbstract(testing.allocator,
"database\r\n> host = localhost\r\n> quoted = \"v\" # c\r\n>>\r\nports\r\n> * 1\r\n> * 2\r\nblob = '''\r\na\r\nb\r\n'''\r\nxs[]\r\n> k = 1\r\n+\r\n> k = 2\r\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("localhost", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "host" } })).kind.string);
try testing.expectEqualStrings("v", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "quoted" } })).kind.string);
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
// Multiline content captures its line breaks as LF — CRLF and LF sources
// yield byte-identical values.
try testing.expectEqualStrings("a\nb", (try ast.getValByPath(&.{.{ .key = "blob" }})).kind.string);
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "k" } })).kind.number.raw);
// A final line terminated by a lone \r at EOF is closed the same way.
var ast2 = try parseAbstract(testing.allocator, "section\r\n> a = 1\r", .Fig);
defer ast2.deinit();
try testing.expectEqualStrings("1", (try ast2.getValByPath(&.{ .{ .key = "section" }, .{ .key = "a" } })).kind.number.raw);
}
test "element with inline field is a hard error" {
try testing.expectError(error.FigElementInlineField, parseAbstract(testing.allocator, "xs\n> * host = a.com\n", .Fig));
}
test "skipped level errors" {
try testing.expectError(error.FigSkippedLevel, parseAbstract(testing.allocator, "a\n>>> x = 1\n", .Fig));
}
test "root marker errors" {
try testing.expectError(error.FigRootMarker, parseAbstract(testing.allocator, "> x = 1\n", .Fig));
}
test "mixed container children errors" {
try testing.expectError(error.FigMixedContainerChildren, parseAbstract(testing.allocator,
\\a
\\> x = 1
\\> *
, .Fig));
}
test "duplicate key errors" {
try testing.expectError(error.FigDuplicateKey, parseAbstract(testing.allocator, "x = 1\nx = 2\n", .Fig));
}
test "empty childless container errors" {
try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "a\nb = 1\n", .Fig));
}
test "foreign syntax guardrail: key: value" {
try testing.expectError(error.FigForeignSyntaxColon, parseAbstract(testing.allocator, "key: value\n", .Fig));
}
test "comments: leading and trailing" {
var ast = try parseAbstract(testing.allocator,
\\logging
\\> # a comment
\\> level = info # inline comment
, .Fig);
defer ast.deinit();
const level_key = try ast.getKeyByPath(&.{ .{ .key = "logging" }, .{ .key = "level" } });
try testing.expectEqualStrings("a comment", ast.comments(level_key.id).leading[0].text);
const level_val = try ast.getValByPath(&.{ .{ .key = "logging" }, .{ .key = "level" } });
try testing.expectEqualStrings("inline comment", ast.comments(level_val.id).trailing.?.text);
}
test "+ continuation appends another element to the last [] header" {
var ast = try parseAbstract(testing.allocator,
\\replacements[]
\\> file = README.md
\\> exactly = 0
\\+
\\> file = CHANGELOG.md
\\> exactly = 1
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("README.md", (try ast.getValByPath(&.{ .{ .key = "replacements" }, .{ .index = 0 }, .{ .key = "file" } })).kind.string);
try testing.expectEqualStrings("CHANGELOG.md", (try ast.getValByPath(&.{ .{ .key = "replacements" }, .{ .index = 1 }, .{ .key = "file" } })).kind.string);
}
test "assignment-final [] is not assignable" {
// `[]` appends only as a header (or mid-path step); `xs[] = v` is the
// teaching error whether the sequence is empty…
try testing.expectError(error.FigAppendAssignment, parseAbstract(testing.allocator, "xs[] = 1\n", .Fig));
// …or already has elements (this used to silently REPLACE the last one).
try testing.expectError(error.FigAppendAssignment, parseAbstract(testing.allocator, "xs[0] = 1\nxs[] = 2\n", .Fig));
// The fixes the message names both work: the explicit next index appends…
var ast = try parseAbstract(testing.allocator, "xs[0] = 1\nxs[1] = 2\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 } })).kind.number.raw);
// …and a mid-path `[]` ("the last element") still navigates fine.
var mid = try parseAbstract(testing.allocator, "xs[]\n> a = 1\nxs[].b = 2\n", .Fig);
defer mid.deinit();
try testing.expectEqualStrings("2", (try mid.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 0 }, .{ .key = "b" } })).kind.number.raw);
}
test "a non-final [] on an empty sequence is still the empty-append error" {
// The other `FigEmptyAppendTarget` site (mid-path navigation) is unaffected
// by the assignment-final ruling.
try testing.expectError(error.FigEmptyAppendTarget, parseAbstract(testing.allocator, "xs[].a = 1\n", .Fig));
}
test "an []-appended element that closes empty is an empty container" {
// Append-created elements are pre-committed to map shape at birth, but an
// element with zero entries errors like every other empty block container —
// closed by a following sibling line, at EOF, and with only a comment body.
try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\nb = 1\n", .Fig));
try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n", .Fig));
try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n> # note\n", .Fig));
// A `+` re-run creates an element the same way — an empty one errors too.
try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n", .Fig));
// Every element getting fields keeps `[]` headers and `+` chains working.
var ast = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n> a = 2\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings("1", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 0 }, .{ .key = "a" } })).kind.number.raw);
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "a" } })).kind.number.raw);
}
test "content glued to a + is a key line, and + is not a bare-key char" {
// `isPlusLine` only claims a LONE `+`; anything glued falls through to the
// key path, where `+` cannot start (or appear in) a bare key.
try testing.expectError(error.FigBadKey, parseAbstract(testing.allocator, "xs[]\n> a = 1\n+glued = 2\n", .Fig));
try testing.expectError(error.FigBadKey, parseAbstract(testing.allocator, "+x\n", .Fig));
}
test "+ continuation equals a repeated [] header" {
var plussed = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n> a = 2\n", .Fig);
defer plussed.deinit();
var repeated = try parseAbstract(testing.allocator, "xs[]\n> a = 1\nxs[]\n> a = 2\n", .Fig);
defer repeated.deinit();
try testing.expect(plussed.eql(repeated));
}
test "+ re-runs a NESTED append header (last-element semantics)" {
var ast = try parseAbstract(testing.allocator,
\\spec.containers[]
\\> name = app
\\spec.containers[].ports[]
\\> port = 80
\\+
\\> port = 443
, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("443", (try ast.getValByPath(&.{ .{ .key = "spec" }, .{ .key = "containers" }, .{ .index = 0 }, .{ .key = "ports" }, .{ .index = 1 }, .{ .key = "port" } })).kind.number.raw);
}
test "+ chain survives blanks and comments (attachment matches the repeated-header form)" {
var plussed = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n\n# note\n+\n> a = 2\n", .Fig);
defer plussed.deinit();
var repeated = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n\n# note\nxs[]\n> a = 2\n", .Fig);
defer repeated.deinit();
try testing.expect(plussed.eql(repeated));
try testing.expectEqualStrings("2", (try plussed.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "a" } })).kind.number.raw);
}
test "dangling + errors" {
try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "+\n> a = 1\n", .Fig));
// A plain header (no `[]`) breaks the chain…
try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\nother\n> b = 1\n+\n> a = 2\n", .Fig));
// …as does any other zero-marker structural line.
try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\nb = 2\n+\n> a = 2\n", .Fig));
// A `+` carrying depth markers is never a continuation.
try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\n> +\n", .Fig));
}
test "flow values are closed: no later dotted/header/index extension" {
try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "a = { x = 1 }\na.y = 2\n", .Fig));
try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "a = { x = 1 }\na\n> y = 2\n", .Fig));
try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "xs = [1, 2]\nxs[2] = 3\n", .Fig));
}
test "full kitchen-sink file parses" {
const src = @embedFile("testdata/kitchen_sink.figl");
var ast = try parseAbstract(testing.allocator, src, .Fig);
defer ast.deinit();
try testing.expectEqualStrings("2", (try ast.getValByPath(&.{.{ .key = "version" }})).kind.number.raw);
}
test "diagnostic captures the failing position" {
// Line 3 (`level: info`) is the YAML-habit error; the caret pins to the
// `:` (failAt), not where the cursor stopped after the would-be type name.
const src = "logging\n> format = json\n> level: info\n";
var report: Report = .{};
defer testing.allocator.free(report.warnings);
const result = parseWithReport(testing.allocator, src, .Fig, &report);
try testing.expectError(error.FigForeignSyntaxColon, result);
const d = report.diag.?;
try testing.expectEqual(@as(Error!void, error.FigForeignSyntaxColon), @as(Error!void, d.code));
const loc = d.locate(src);
try testing.expectEqual(@as(usize, 3), loc.line);
try testing.expectEqualStrings("> level: info", loc.line_text);
}
test "diagnostic renders file:line:col, the source line, and a caret" {
const src = "key: value\n";
var report: Report = .{};
defer testing.allocator.free(report.warnings);
_ = parseWithReport(testing.allocator, src, .Fig, &report) catch {};
const rendered = try report.diag.?.renderAlloc(testing.allocator, src, "app.figl");
defer testing.allocator.free(rendered);
try testing.expect(std.mem.startsWith(u8, rendered, "app.figl:1:"));
try testing.expect(std.mem.indexOf(u8, rendered, "error: `:` introduces a type, not a value") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "\n key: value\n") != null);
try testing.expect(std.mem.indexOf(u8, rendered, "^\n") != null);
}
test "report is empty on a clean parse" {
var report: Report = .{};
defer testing.allocator.free(report.warnings);
var parsed = try parseWithReport(testing.allocator, "a = 1\n", .Fig, &report);
defer parsed.deinit(testing.allocator);
try testing.expect(report.diag == null);
try testing.expectEqual(@as(usize, 0), report.warnings.len);
}
test "UTF-8 BOM before document is ignored" {
// Same tree as "root map with a scalar assignment", just with a leading
// BOM — the BOM must not shift any offsets/keys/values.
try expectParse("\xEF\xBB\xBFx = 1\n", .{ .allocator = testing.allocator, .root = 3, .nodes = &.{
.{ .id = 0, .kind = .{ .string = "x" } },
.{ .id = 1, .kind = .{ .number = .{ .raw = "1", .kind = .integer } } },
.{ .id = 2, .kind = .{ .keyvalue = .{ .key = 0, .value = 1 } } },
.{ .id = 3, .kind = .{ .mapping = 2 } },
} });
}
test "invalid UTF-8 bytes are rejected" {
try testing.expectError(error.FigInvalidUtf8, parseAbstract(testing.allocator, "x = \xff\xfe\n", .Fig));
}
/// Parse `input`, expecting success, and return the warning codes in order
/// (testing-allocator-backed; caller frees).
fn collectWarnCodes(input: []const u8) ![]Warning.Code {
var report: Report = .{};
defer testing.allocator.free(report.warnings);
var parsed = try parseWithReport(testing.allocator, input, .Fig, &report);
defer parsed.deinit(testing.allocator);
const codes = try testing.allocator.alloc(Warning.Code, report.warnings.len);
for (report.warnings, 0..) |w, i| codes[i] = w.code;
return codes;
}
fn expectWarnCodes(input: []const u8, expected: []const Warning.Code) !void {
const codes = try collectWarnCodes(input);
defer testing.allocator.free(codes);
try testing.expectEqualSlices(Warning.Code, expected, codes);
}
test "coercion warns: literal lookalikes and leading zeros" {
try expectWarnCodes("norway = Yes\n", &.{.string_looks_like_literal});
try expectWarnCodes("switch = ON\n", &.{.string_looks_like_literal});
try expectWarnCodes("shout = TRUE\n", &.{.string_looks_like_literal});
try expectWarnCodes("nothing = Null\n", &.{.string_looks_like_literal});
try expectWarnCodes("zip = 007\n", &.{.string_leading_zero});
// Float/exponent shapes with a leading zero on the integer part fall to
// strings too, and warn the same way (`01.5` is not silently a string).
try expectWarnCodes("f = 01.5\n", &.{.string_leading_zero});
try expectWarnCodes("f = 00.5\n", &.{.string_leading_zero});
try expectWarnCodes("f = -01e2\n", &.{.string_leading_zero});
// Still not a number even without the zeros → stays silent.
try expectWarnCodes("v = 01.5.6\n", &.{});
// A trailing-dot number lookalike (`1.`) falls to a string and warns —
// narrow, so version strings and prose stay silent.
try expectWarnCodes("sig = 1.\n", &.{.string_looks_like_number});
try expectWarnCodes("neg = -3.\n", &.{.string_looks_like_number});
try expectWarnCodes("ver = 1.2.3\n", &.{});
// The real literals, prose, quotes, and annotations never warn.
try expectWarnCodes("ok = true\nn = null\nx = 0\nhex = 0xFF\n", &.{});
try expectWarnCodes("real = 1.0\n", &.{});
try expectWarnCodes("movie = 12 monkeys\ntitle = Yes Prime Minister\n", &.{});
try expectWarnCodes("flag = \"true\"\n", &.{});
try expectWarnCodes("norway: string = Yes\n", &.{});
// A `: float` coercion silences the trailing-dot warn (the author opted in).
try expectWarnCodes("sig: float = 1.\n", &.{});
}
test "leading-dot number lookalike falls to a string and warns" {
// `.5` is still the string it spells — the warn surfaces the surprise.
try expectWarnCodes("dot = .5\n", &.{.string_looks_like_number});
try expectWarnCodes("q = .25\n", &.{.string_looks_like_number});
var ast = try parseAbstract(testing.allocator, "dot = .5\n", .Fig);
defer ast.deinit();
try testing.expectEqualStrings(".5", (try ast.getValByPath(&.{.{ .key = "dot" }})).kind.string);
// As narrow as the trailing-dot detector: only a single `.` + clean digits.
try expectWarnCodes("v = .5.6\n", &.{});
try expectWarnCodes("e = .e5\n", &.{});
try expectWarnCodes("d = ..\n", &.{});
try expectWarnCodes("s = -.5\n", &.{});
// `: float` does NOT coerce a leading dot (no fig number spelling has one) —
// the warn's advice must therefore be "write the zero", not `: float =`.
try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: float = .5\n", .Fig));
}
test "coercion warns: ambiguous bare time only" {
// A bare date is the common, deliberate case (frontmatter, deadlines) —
// no warn.
try expectWarnCodes("day = 2026-07-01\n", &.{});
// A bare time collides with duration/ratio/score shapes — warns, with or
// without seconds (precision doesn't disambiguate it from those).
try expectWarnCodes("meet = 10:30\n", &.{.ambiguous_datetime});
try expectWarnCodes("split = 10:30:00\n", &.{.ambiguous_datetime});
// A `T`/zone-carrying timestamp is unambiguous; prose containing a time
// never sniffs (the whole token has to match, not a substring).
try expectWarnCodes("when = 2026-07-01T12:00:00Z\n", &.{});
try expectWarnCodes("local = 2026-07-01T12:00:00\n", &.{});
try expectWarnCodes("note = call mom at 3:30\n", &.{});
}
test "flow-like string warns; markdown links and globs stay silent" {
try expectWarnCodes("oops = [80, 443] x\n", &.{.flow_like_string});
try expectWarnCodes("link = [Blog](/Blog.md)\n", &.{});
try expectWarnCodes("glob = [a-z]*.md\n", &.{});
try expectWarnCodes("bb = [b]x[/b]\n", &.{});
// A malformed prefix never parsed as flow, so it doesn't warn either.
try expectWarnCodes("odd = [80,, 443] x\n", &.{});
// Flow-element position gets the same treatment.
try expectWarnCodes("xs = [[80, 443] x, y]\n", &.{.flow_like_string});
try expectWarnCodes("links = [[Blog](/Blog.md), [Resume](/Resume.md)]\n", &.{});
}
test "missing-comma flow object warns" {
try expectWarnCodes("p = { x = 1 y = 2 }\n", &.{.flow_missing_comma});
try expectWarnCodes("p = { x = 1, y = 2 }\n", &.{});
}
test "indent/count lint" {
// Correct 2×depth indentation and unindented spaced markers: clean.
try expectWarnCodes("a\n > b = 1\n", &.{});
try expectWarnCodes("a\n> b\n> > c = 1\n", &.{});
// Wrong width, indented root line, tab indent: each warns.
try expectWarnCodes("a\n > b = 1\n", &.{.indent_marker_mismatch});
try expectWarnCodes(" a = 1\n", &.{.indent_marker_mismatch});
try expectWarnCodes("a\n\t> b = 1\n", &.{.indent_marker_mismatch});
// Comment lines participate (their depth is load-bearing for attachment).
try expectWarnCodes("a\n # note\n> b = 1\n", &.{.indent_marker_mismatch});
try expectWarnCodes("a\n > # note\n > b = 1\n", &.{});
}