//! Editor module, generic over Language.
const std = @import("std");
const build_options = @import("build_options");
const AST = @import("ast/ast.zig");
const Document = @import("document.zig");
const Span = @import("util/span.zig");
const json = @import("languages/json/json.zig");
const json_string = @import("util/json_string.zig");
const log = std.log.scoped(.editor);
// The declared half of the Language interface — `Syntax`, `Caps`,
// `CommentStyle`. Every surface-syntax parameter this engine used to select
// with an `if (Language == X)` branch now comes from `Language.syntax(t)`
// instead; see `languages/manifest.zig` and
// `docs/proposals/language-interface.md`. A leaf module, so importing it here
// (and from every `<lang>/<lang>.zig`) pulls in nothing else.
const lang = @import("languages/manifest.zig");
// The OPERATIONS half of the interface — the part `syntax` can't express.
//
// Where a format's editing need is a value (a separator, a marker, whether
// block sequences are editable), it is declared on `Language.syntax` and read
// above. Where it is LOGIC, the format declares a hook: a `pub` decl on its
// `Language` struct named for the operation it takes over. This engine
// dispatches on presence —
//
// if (@hasDecl(Language, "insertKey")) return Language.insertKey(...);
//
// — so it names no format at all. Each hook's signature is fixed by its one
// call site and documented on the method it overrides; the implementations
// live in `<lang>/editor_helper.zig` (which also holds that language's editor
// tests, so editor-test code sits next to the concern it exercises), and the
// DECLARATION of which operations a format overrides, with the reason, sits in
// the "Editing hooks" block of its `<lang>/<lang>.zig`. See
// `docs/proposals/language-interface.md`.
//
// What remains below is what hook dispatch does not reach:
//
// * `zon_edit.appendFieldName` is reached through `key_style`, as the
// rendering half of a syntax parameter rather than an operation override.
// * The bare language tags below are used by this file's OWN tests, nothing
// else. The EXCLUSIVE whole-container operations (see that block) used to
// need `Language != Toml`/`!= Fig` guards and direct imports of those two
// formats' helper modules; they are `@hasDecl`-dispatched now, like every
// hook, so this file names no format outside its tests. Zig still has no
// conditional container-level declarations (`usingnamespace` was removed in
// 0.15), so the methods exist for every format and refuse at comptime —
// but on a missing declaration rather than on a format's identity.
const zon_edit = @import("languages/zon/editor_helper.zig");
const Toml = @import("languages/toml/toml.zig").Language;
const Fig = @import("languages/fig/fig.zig").Language;
const Yaml = @import("languages/yaml/yaml.zig").Language;
const Zon = @import("languages/zon/zon.zig").Language;
const Dotenv = @import("languages/dotenv/dotenv.zig").Language;
const Properties = @import("languages/properties/properties.zig").Language;
const Ini = @import("languages/ini/ini.zig").Language;
const Plist = @import("languages/plist/plist.zig").Language;
const NestedText = @import("languages/nestedtext/nestedtext.zig").Language;
pub fn Editor(comptime Language: type) type {
@import("languages/language.zig").validate(Language);
return struct {
const Self = @This();
/// Everything this format declares about its own surface syntax, for
/// the dialect the document is currently being read as. Replaces the
/// per-language `if (Language == X)` parameter branches this file used
/// to carry — the values now live on `<lang>/<lang>.zig`, checked by
/// `language.validate`. See `languages/manifest.zig`.
///
/// Indexed by `self.format` rather than fixed at comptime because the
/// comment markers genuinely vary by dialect (strict JSON has no
/// comment syntax; JSONC and JSON5 do), and every splice is reparsed
/// under whichever dialect the editor is holding. The rest of the
/// struct is dialect-invariant today; a TOML 1.0/1.1 or YAML
/// 1.1/1.2.2 *editing* divergence would land here if one appeared.
///
/// Cost is one runtime switch and it stops there: every consumer is an
/// `appendSlice` or an argument to `commentBlockStart`/
/// `entryBlockStart`, none of which needs a comptime value.
fn syntax(self: *const Self) lang.Syntax {
return Language.syntax(self.format);
}
/// Whether `path`'s final segment names a key the document RESOLVES but
/// no physical entry declares — one supplied by the format's reference
/// layer, which path navigation does not follow and therefore reports
/// as `NotFound`.
///
/// **Hook** `keyIsInherited(parsed, path) !bool`. Declared by YAML
/// alone, where a `<<` merge supplies keys its own mapping never spells
/// out. With no hook the answer is false, which is correct for every
/// format that has no reference layer — so the two `NotFound` recovery
/// sites below need no language test of their own.
///
/// A predicate rather than a recovery hook, because only the QUESTION
/// is the language's: `replaceValAtPath` answers it by shadowing the
/// inherited key with a local entry (copy-on-write) and `deleteKey` by
/// refusing outright, and both of those policies are generic.
fn keyIsInherited(parsed: Document, path: []const AST.PathSegment) !bool {
if (!@hasDecl(Language, "keyIsInherited")) return false;
return Language.keyIsInherited(parsed, path);
}
allocator: std.mem.Allocator,
source: std.ArrayList(u8) = .empty,
document: ?Document = null,
format: Language.Type = Language.default_type,
/// Set by `replaceAtSpan` when the reparse it does after splicing
/// FAILED — i.e. the caller's replacement text, not the document it
/// went into, is what doesn't parse (the document parsed at `init`,
/// and every edit routes through that one splice gate). The error
/// itself can't say this: it is an ordinary parse error, identical to
/// what a malformed input file produces. Callers that hand a user's
/// raw text through — the CLI's `edit`/`set`/`insert` — read this to
/// blame the text instead of the file. Cleared at the top of each
/// splice, so it always describes the most recent one.
splice_rejected: bool = false,
pub fn getParsed(self: *const Self) !Document {
return self.document orelse {
log.err("Not initialized!", .{});
return error.NotInitialized;
};
}
pub fn init(self: *Self, input: []const u8) !void {
if (self.source.items.len != 0 or self.document != null) return error.MultipleInit;
try self.source.appendSlice(self.allocator, input);
self.document = try self.parseSource();
}
/// Replace a span with a new span. Atomic: on success `self.document` is
/// the reparse of the edited source; if the edit produces source that no
/// longer parses, the source is rolled back and the prior `self.document`
/// stays valid, so a failed edit leaves the editor exactly as it was.
pub fn replaceAtSpan(self: *Self, span: Span, replacement: []const u8) !void {
// Snapshot the whole source so a failed reparse can be undone. The
// edit already costs a full reparse, so an O(n) copy is negligible.
const backup = try self.allocator.dupe(u8, self.source.items);
defer self.allocator.free(backup);
self.splice_rejected = false;
try self.replaceSource(span, replacement);
self.reparse() catch |err| {
// Restore byte-for-byte. Capacity is retained from before the
// edit (>= backup.len), so the refill cannot fail.
self.source.clearRetainingCapacity();
self.source.appendSliceAssumeCapacity(backup);
// `backup` parsed at `init`, so the only new thing in the
// buffer was `replacement`: it is what `err` is about.
self.splice_rejected = true;
return err;
};
}
/// Replace the value at `path`. Reference-layer behavior is copy-on-write:
/// editing a value that is an alias (`b: *x`) replaces the `*x` text with
/// the new literal (severing only that alias — its anchor and any other
/// alias are untouched), which falls out of splicing the alias node's own
/// span. A key supplied only by a `<<` merge is materialized locally,
/// shadowing the merge. Use `replaceValAtPathFollowing` to edit through to
/// a shared anchor instead.
///
/// **Hook** `replaceValAtPath(self, parsed, path, node, span,
/// replacement) !void` — replaces the splice below wholesale, for a
/// format where `replacement` cannot go into the old value's slot
/// verbatim. Two distinct reasons, both declared:
///
/// * The slot is not a bare literal. plist wraps every value in a
/// typed element (`<integer>42</integer>`), and NestedText frames
/// one either rest-of-line or as a nested `>`-block — so the text
/// has to be RENDERED, not spliced.
/// * The slot's shape can change. YAML and fig re-emit the whole
/// `key<sep>value` so a block collection can descend onto the
/// following lines where the old value was inline (`k: []` → a
/// block list), which a span splice cannot express. Both take the
/// direct splice for a non-mapping target, inside the hook.
///
/// A hook receives the resolved `node` and its `span`, so it never
/// repeats the path walk; a format that declares none (JSON, TOML, ZON,
/// INI, dotenv, `.properties`) has a literal syntax that splices
/// verbatim in place.
pub fn replaceValAtPath(self: *Self, path: []const AST.PathSegment, replacement: []const u8) !void {
const parsed = try self.getParsed();
const node = parsed.ast.getValByPath(path) catch |err| {
// An inherited key surfaces as NotFound (path nav doesn't follow
// the reference layer); copy-on-write it by inserting a local
// `key: value` entry that shadows what it inherits from.
if (err == error.NotFound and try keyIsInherited(parsed, path)) {
try self.insertKey(path[0 .. path.len - 1], path[path.len - 1].key, replacement);
return;
}
return err;
};
const span = parsed.span(node);
if (@hasDecl(Language, "replaceValAtPath"))
return Language.replaceValAtPath(self, parsed, path, node, span, replacement);
try self.replaceAtSpan(span, replacement);
}
/// Upsert a mapping value: replace the value at `path`, or — when only
/// the trailing key is absent — insert it as a fresh `key: value` entry
/// in the parent mapping. This is the "set this key, creating it if
/// missing" primitive every config editor reaches for; it folds the
/// usual `replaceValAtPath` → (on `NotFound`) `insertKey` two-step into
/// one op.
///
/// The path's last segment MUST name a key — `set` only ever *creates* a
/// mapping entry, never a sequence item, so a path ending in an index is
/// rejected with `NotAMapping`. Missing *intermediate* containers are
/// auto-vivified (`mkdir -p` for config): if the parent mapping doesn't
/// exist yet, `set` seeds it — and any of ITS missing ancestors, deepest
/// first — as an empty map, then lands the leaf. Vivification fires when
/// an intermediate key is genuinely absent (`NotFound`), or when what
/// stands in its place is an EMPTY node — a null, i.e. a bare `key:` or an
/// empty document's root, which is a container waiting to exist. A
/// segment that resolves to a non-map SCALAR is a real type error
/// (`NotAMapping`) and is never clobbered. See `Syntax.empty_map_literal`
/// for what a seed is spelled as per format (YAML block, flow `{}`
/// elsewhere).
///
/// Vivify-then-land is atomic as a whole: if the leaf cannot be placed
/// after a seed has been spliced, the seed is rolled back, so a failed
/// `set` leaves the document byte-for-byte as it was. Where the seeds are
/// FLOW containers (every format but YAML) that has one consequence
/// callers see: a block-spelled value cannot land inside one, and is
/// refused with `BlockValueIntoFlow` rather than spliced into text that
/// no longer means what was written.
///
/// Delegates to `replaceValAtPath`, so the replace case inherits that
/// op's YAML value reframing (inline↔block) and merge-key COW.
///
/// Key duality: the replace branch matches the trailing segment
/// *logically* (against decoded key names), but the insert branch needs
/// the key as *syntax*. `set` bridges the two — when it inserts, it
/// renders the logical key into the format's key syntax (quoting/escaping
/// it for strict JSON, verbatim for YAML/TOML where a simple key already
/// is its own syntax) — so creating a not-yet-present key works for every
/// editable format, JSON included.
pub fn set(self: *Self, path: []const AST.PathSegment, value_text: []const u8) !void {
if (path.len == 0 or std.meta.activeTag(path[path.len - 1]) != .key)
return error.NotAMapping;
self.replaceValAtPath(path, value_text) catch |replace_err| {
// The value isn't there to replace — create it. The trailing key
// is logical (it just matched against decoded names), so render it
// into the format's key syntax before splicing. `insertKey`
// re-validates the parent (a mapping, or an empty/null root it
// promotes), so a non-mapping parent still errors; surface the
// original replace error when the insert can't proceed. Falling
// back on any replace error (not just `NotFound`) is what lets
// `set` seed a freshly-created, still-empty document — where
// navigating to the key fails with `NotAMapping`.
const key_text = try self.formatInsertKey(path[path.len - 1].key);
defer self.allocator.free(key_text);
self.insertKey(path[0 .. path.len - 1], key_text, value_text) catch |insert_err| {
// One insert failure is more informative than any replace
// error can be, so it is NOT swallowed by the fallback
// below: the parent was found and IS a mapping, just a flow
// one that cannot hold this value. Reporting the replace's
// `NotFound` there would send the caller looking for a
// missing key that isn't the problem.
if (insert_err == error.BlockValueIntoFlow) return insert_err;
// The parent mapping itself is missing (an intermediate key
// is absent — `NotFound`, NOT the `NotAMapping` of a scalar
// standing where a map should be, which must never be
// clobbered). Auto-vivify it as an empty map — recursing to
// seed any missing ancestor deepest-first — then retry the
// leaf insert into the now-existing parent.
//
// INI can't take part: `Syntax.empty_map_literal` is a value-literal
// sentinel (`{}`/`.{}`), and that spelling is only safe
// because it's ALSO genuinely valid syntax for "an empty
// mapping" in every other editable format (a real JSON
// object / YAML-TOML-fig flow map / ZON struct literal) —
// so vivifying through it can't produce anything wrong
// regardless of why it was reached. INI has no such
// literal (`{}` there is just a two-character STRING
// value; only a `[section]` header introduces real
// nesting), so blindly reusing the sentinel would splice
// a nonsense `section = {}` root key instead of a real
// section. Skip the vivify and surface the original
// `NotFound` — "no such section" — rather than corrupt.
// plist joins INI in opting out: `set`'s vivify seed is the
// flow `{}` literal, which plist has no reader for (an empty
// dict is `<dict/>`, not a value literal you can splice as
// `value_text`). Rather than teach the seed a plist spelling,
// surface the original `NotFound` — an intermediate `<dict>`
// must already exist to land a nested key.
// NestedText joins INI/plist here: the vivify seed is the
// flow `{}` literal, and while NestedText's reader DOES
// accept that as a genuinely empty (childless) mapping,
// this editor's own `ntInsertKey` declines to insert into
// one (see its module doc — expanding an inline `{}`
// into block form is deliberately out of scope) — so
// vivifying through it would only trade today's clear
// `NotFound` for a confusing `EmptyInlineContainer` one
// step later, after already splicing the `{}` in.
//
// `NotAMapping` joins `NotFound` as vivifiable in exactly
// one shape: when what stands in the way is an EMPTY node (a
// null), not a scalar. Navigating *through* a null fails the
// same way navigating through a scalar does, but they are
// opposite cases — a null is "nothing here yet" (an empty
// document's root, or a bare `key:`), which `insertKey`
// promotes to a real mapping, while a scalar is real data.
// Without this, a nested `set` on an empty document failed
// outright, though `set` is documented to seed one.
const vivifiable = insert_err == error.NotFound or
(insert_err == error.NotAMapping and
self.blockedByEmptyNode(path[0 .. path.len - 1]));
// A format with no `empty_map_literal` has no literal
// spelling for "an empty nested mapping" to seed WITH, so
// it cannot vivify at all — INI, plist and NestedText.
// The opt-out and the seed are one declaration because
// this is the literal's only consumer.
const seed = self.syntax().empty_map_literal;
if (seed != null and path.len >= 2 and vivifiable) {
// Vivify-then-insert is TWO splices, so it needs a
// snapshot of its own: each `replaceAtSpan` is
// individually atomic, but if the leaf insert fails
// after the ancestor seed landed, that seed is a
// half-finished edit no caller asked for — one that
// makes `Err` mean "nothing happened" a lie, and a
// retry an edit against an unexpected document.
// Restore, and report the failure as the whole `set`
// failing.
//
// Written as two `catch`es rather than one helper
// wrapping both splices: `set` recurses through the seed
// below, and routing that recursion through a helper
// makes the two functions' inferred error sets depend on
// each other (a comptime dependency loop).
const parent = path[0 .. path.len - 1];
const backup = try self.allocator.dupe(u8, self.source.items);
defer self.allocator.free(backup);
self.set(parent, seed.?) catch |seed_err| {
try self.restoreSource(backup);
return seed_err;
};
self.insertKey(parent, key_text, value_text) catch |leaf_err| {
try self.restoreSource(backup);
return leaf_err;
};
} else return replace_err;
};
};
}
/// Whether what blocks navigation to `path` is an EMPTY node — a null —
/// rather than real data. Walks back from `path` to the deepest prefix
/// that still resolves and reports whether that node is a null; an
/// unresolvable path with a null root (an empty document) counts too.
///
/// This is the distinction `set`'s vivify guard needs and `NotAMapping`
/// alone cannot make: a null is a container waiting to exist, which
/// `insertKey` promotes, while a scalar standing where a mapping should
/// be is data that must never be clobbered.
fn blockedByEmptyNode(self: *Self, path: []const AST.PathSegment) bool {
const parsed = self.getParsed() catch return false;
var len = path.len;
while (len > 0) : (len -= 1) {
const node = parsed.ast.getValByPath(path[0..len]) catch continue;
return node.kind == .null_;
}
return parsed.ast.nodes[parsed.ast.root].kind == .null_;
}
/// Restore the source to `backup` and reparse, undoing a compound
/// (multi-splice) op. `backup` is a snapshot of source that parsed, so
/// the reparse only fails on OOM. Capacity is retained from before the
/// edits (>= `backup.len`), so the refill cannot fail — the same
/// reasoning `replaceAtSpan`'s own rollback rests on.
fn restoreSource(self: *Self, backup: []const u8) !void {
self.source.clearRetainingCapacity();
self.source.appendSliceAssumeCapacity(backup);
try self.reparse();
}
/// Render a logical mapping key into this format's key syntax for the
/// `set` insert branch, as the format's declared `key_style` says —
/// see `Syntax.KeyStyle` for what each spelling is and why.
/// Always returns an owned slice (the caller frees it).
fn formatInsertKey(self: *Self, key: []const u8) ![]u8 {
switch (self.syntax().key_style) {
.json_quoted => {
var w = std.Io.Writer.Allocating.init(self.allocator);
defer w.deinit();
try json_string.writeQuoted(&w.writer, key);
return self.allocator.dupe(u8, w.written());
},
.zon_field => {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
try zon_edit.appendFieldName(&out, self.allocator, key);
return out.toOwnedSlice(self.allocator);
},
.verbatim => return self.allocator.dupe(u8, key),
}
}
/// Like `replaceValAtPath`, but follow into the reference layer: when the
/// target value is an alias, edit the *anchored node* (the shared source),
/// so every alias to that anchor reflects the change. The `&name` (and any
/// tag) prefix is preserved — only the anchored value's bytes are
/// replaced. A non-alias target behaves exactly like `replaceValAtPath`.
///
/// **Hook** `replaceValAtPathFollowing(self, parsed, path, node, span,
/// replacement) !void`, same signature as `replaceValAtPath`'s. Declared
/// by YAML alone, because YAML alone HAS a reference layer: with no
/// hook, "following" and not following are the same operation, which is
/// exactly what the fall-through below expresses.
pub fn replaceValAtPathFollowing(self: *Self, path: []const AST.PathSegment, replacement: []const u8) !void {
const parsed = try self.getParsed();
const node = parsed.ast.getValByPath(path) catch {
return self.replaceValAtPath(path, replacement);
};
if (@hasDecl(Language, "replaceValAtPathFollowing"))
return Language.replaceValAtPathFollowing(self, parsed, path, node, parsed.span(node), replacement);
try self.replaceValAtPath(path, replacement);
}
/// Rename the key at `path`, leaving its value untouched.
///
/// **Hook** `replaceKeyAtPath(self, parsed, path, replacement) !void` —
/// replaces this op wholesale, before the key node is resolved, for a
/// format where a key's span is not the whole of its syntax. Declared by
/// NestedText: a plain key's span excludes its trailing `:` (so a
/// plain-to-plain rename splices directly, as below), but a MULTILINE
/// key (`: key\n: continued`) has no separator colon in the source at
/// all, so converting between the two forms means adding or dropping
/// one.
pub fn replaceKeyAtPath(self: *Self, path: []const AST.PathSegment, replacement: []const u8) !void {
const parsed = try self.getParsed();
if (@hasDecl(Language, "replaceKeyAtPath"))
return Language.replaceKeyAtPath(self, parsed, path, replacement);
const node = try parsed.ast.getKeyByPath(path);
const span = parsed.span(node);
try self.replaceAtSpan(span, replacement);
}
// ========
// COMMENTS
// ========
//
// Comments are trivia — they live OUTSIDE every AST node span — so these
// ops reuse the same splice + reparse machinery as the structural edits:
// compute a byte position from a node's span, splice the comment text,
// reparse. The reparse is the safety net (`replaceAtSpan` rolls back if
// the result no longer parses).
//
// **Hooks.** Each of the six ops below dispatches to a `Language`
// declaration of the same name when one exists, passing exactly its own
// arguments (`self, path` — plus `text` for the two setters) and handing
// over the operation entirely; the marker lookup and the whole generic
// body are skipped. plist declares all six, because a plist comment is a
// `<!-- ... -->` PAIR rather than a line carrying a marker, so none of
// the marker-scanning below has anything to scan for.
/// The line-comment marker for the dialect this document is being read
/// as, or null when that dialect forbids comments (strict JSON) — in
/// which case the comment ops return `CommentsUnsupported`. Indexed by
/// `self.format` because the splice is reparsed under that same
/// dialect. See `Comments.line`.
fn lineCommentMarker(self: *const Self) ?[]const u8 {
return self.syntax().comments.line;
}
/// The marker for a same-line TRAILING comment specifically, or null
/// for a format that has no such syntax (INI, NestedText — where a
/// `;`/`#` after a value is literal value text). Distinct from
/// `lineCommentMarker`, which those two formats do have. See
/// `Comments.trailing` for the full reasoning.
fn trailingCommentMarker(self: *const Self) ?[]const u8 {
return self.syntax().comments.trailing;
}
/// The key/value separator the GENERIC entry-insert helpers splice.
///
/// Every caller sits under the generic `insertKey`/`promoteNullToMapping`
/// dispatch, so a format that declares `kv_sep = null` — fig, TOML,
/// plist, NestedText — cannot reach one: `language.validate` requires a
/// null to come with an `insertKey` hook, and the hook replaces this
/// whole path. The error is the same "unreachable by construction"
/// answer `setSequence` gives on its own impossible branch, rather than
/// a fabricated separator that would splice syntax the format's own
/// parser rejects — fig's `": "` was exactly that.
fn kvSep(self: *const Self) ![]const u8 {
return self.syntax().kv_sep orelse error.UnsupportedShape;
}
/// The line a leading-comment op anchors on: the node's own line start,
/// which for a mapping entry is its key's line.
///
/// **Hook** `seqItemLineStart(source, parsed, path) !usize` — consulted
/// only when `path`'s final segment is an `.index`. Declared by
/// NestedText alone: its sequence items carry no keyvalue-shaped
/// wrapper node, so a nested or empty item's value span can begin on a
/// LATER line than the item's own `-` dash, and a comment anchored on
/// `span.start`'s line would land inside the item rather than above it.
/// Every other block format keeps some token on the dash's own line, so
/// `lineStartBefore` is already the dash's line there.
fn leadingCommentLineStart(self: *const Self, parsed: Document, path: []const AST.PathSegment, span: Span) !usize {
const source = self.source.items;
if (@hasDecl(Language, "seqItemLineStart") and path.len > 0 and std.meta.activeTag(path[path.len - 1]) == .index)
return Language.seqItemLineStart(source, parsed, path);
return lineStartBefore(source, span.start);
}
/// Add an own-line comment ABOVE the node at `path` — the key's line for a
/// mapping entry, else the node's own line — matched to that line's
/// indentation. It lands at the BOTTOM of any existing leading comment
/// block (the comment line nearest the node). `text` may be multi-line;
/// each line becomes its own comment line. Returns `CommentsUnsupported`
/// for a dialect without comment syntax (strict JSON).
pub fn addLeadingComment(self: *Self, path: []const AST.PathSegment, text: []const u8) !void {
if (@hasDecl(Language, "addLeadingComment")) return Language.addLeadingComment(self, path, text);
const marker = self.lineCommentMarker() orelse return error.CommentsUnsupported;
const parsed = try self.getParsed();
const node = try parsed.ast.getNodeByPath(path);
const span = parsed.span(node);
const source = self.source.items;
const line_start = try self.leadingCommentLineStart(parsed, path, span);
// A format whose line prefix is STRUCTURAL (fig's `>` marker run)
// copies the raw prefix; everywhere else it is pure whitespace.
// See `Syntax.structural_indent`.
const indent = if (self.syntax().structural_indent)
source[line_start..span.start]
else
source[line_start..firstNonSpace(source, line_start)];
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(self.allocator);
try renderLineComments(self.allocator, &buf, indent, marker, text);
try self.replaceAtSpan(Span.init(line_start, line_start), buf.items);
}
/// The byte window `[start, line_end)` on the entry-at-`path`'s line where a
/// same-line trailing comment lives, shared by the set/delete/get trailing
/// ops. For a scalar or flow value the window runs from just past the value
/// to that line's newline. For a BLOCK-style mapping/sequence value — whose
/// node span begins at its first child on a later line — the trailing
/// comment instead rides the key's line (e.g. `contents: # note` above a
/// block sequence), so the window is the key line, starting just past the
/// key. `start` always sits before any comment marker and after the value
/// (scalar) or key (block), so a `#`/`//` inside the value can't false-match.
fn trailingCommentWindow(self: *Self, path: []const AST.PathSegment) !struct { start: usize, line_end: usize } {
const parsed = try self.getParsed();
const val = try parsed.ast.getValByPath(path);
const val_span = parsed.span(val);
const source = self.source.items;
const is_block_collection = switch (std.meta.activeTag(val.kind)) {
.mapping, .sequence => !isFlow(source, val_span),
else => false,
};
const start = if (is_block_collection)
parsed.span(try parsed.ast.getKeyByPath(path)).end
else
val_span.end;
const line_end = std.mem.indexOfScalarPos(u8, source, start, '\n') orelse source.len;
return .{ .start = start, .line_end = line_end };
}
/// Set the same-line trailing comment on the value at `path`: replace an
/// existing trailing comment on that line, or append one if there is none.
/// `text` must be a single line. Returns `CommentsUnsupported` for a
/// dialect without comment syntax (strict JSON), `MultilineComment` if
/// `text` contains a newline.
pub fn setTrailingComment(self: *Self, path: []const AST.PathSegment, text: []const u8) !void {
if (@hasDecl(Language, "setTrailingComment")) return Language.setTrailingComment(self, path, text);
const marker = self.trailingCommentMarker() orelse return error.CommentsUnsupported;
if (std.mem.indexOfScalar(u8, text, '\n') != null) return error.MultilineComment;
const win = try self.trailingCommentWindow(path);
const source = self.source.items;
// If a comment marker already follows on this line, splice from it
// (replace); otherwise splice from the line's end (append).
var cut = if (std.mem.indexOf(u8, source[win.start..win.line_end], marker)) |rel|
win.start + rel
else
win.line_end;
// Drop the run of spaces/tabs just before the splice so the rebuilt
// " <marker> text" controls its own single leading space.
while (cut > win.start and (source[cut - 1] == ' ' or source[cut - 1] == '\t')) cut -= 1;
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(self.allocator);
try buf.appendSlice(self.allocator, " ");
try buf.appendSlice(self.allocator, marker);
if (text.len > 0) {
try buf.append(self.allocator, ' ');
try buf.appendSlice(self.allocator, text);
}
try self.replaceAtSpan(Span.init(cut, win.line_end), buf.items);
}
/// Remove the run of own-line comments immediately ABOVE the node at
/// `path` — its owned leading block (contiguous comment lines with no
/// blank line between, the same block `deleteKey` carries). A no-op when
/// the node has none. Returns `CommentsUnsupported` for a dialect without
/// comment syntax (strict JSON).
pub fn deleteLeadingComments(self: *Self, path: []const AST.PathSegment) !void {
if (@hasDecl(Language, "deleteLeadingComments")) return Language.deleteLeadingComments(self, path);
_ = self.lineCommentMarker() orelse return error.CommentsUnsupported;
const parsed = try self.getParsed();
const node = try parsed.ast.getNodeByPath(path);
const span = parsed.span(node);
const source = self.source.items;
const line_start = try self.leadingCommentLineStart(parsed, path, span);
const block_start = commentBlockStart(source, line_start, self.syntax().comments.style);
if (block_start == line_start) return; // nothing above to remove
try self.replaceAtSpan(Span.init(block_start, line_start), "");
}
/// Remove the same-line trailing comment on the value at `path`, if any.
/// A no-op when there is none. Returns `CommentsUnsupported` for a dialect
/// without comment syntax (strict JSON).
pub fn deleteTrailingComment(self: *Self, path: []const AST.PathSegment) !void {
if (@hasDecl(Language, "deleteTrailingComment")) return Language.deleteTrailingComment(self, path);
const marker = self.trailingCommentMarker() orelse return error.CommentsUnsupported;
const win = try self.trailingCommentWindow(path);
const source = self.source.items;
const rel = std.mem.indexOf(u8, source[win.start..win.line_end], marker) orelse return; // none
var cut = win.start + rel;
// Take the whitespace separating the value from the comment with it.
while (cut > win.start and (source[cut - 1] == ' ' or source[cut - 1] == '\t')) cut -= 1;
try self.replaceAtSpan(Span.init(cut, win.line_end), "");
}
/// Read back the own-line comment block immediately ABOVE the node at
/// `path` — the same owned block `deleteLeadingComments` removes — with each
/// line's indentation and `marker` (and one following space) stripped, lines
/// rejoined by '\n'. Returns `null` when there is no block above the node
/// (distinct from a present-but-empty comment — a bare `#` — which yields
/// ""). The caller owns the returned bytes. Returns `CommentsUnsupported`
/// for a dialect without comment syntax (strict JSON).
pub fn getLeadingComment(self: *Self, path: []const AST.PathSegment) !?[]u8 {
if (@hasDecl(Language, "getLeadingComment")) return Language.getLeadingComment(self, path);
const marker = self.lineCommentMarker() orelse return error.CommentsUnsupported;
const parsed = try self.getParsed();
const node = try parsed.ast.getNodeByPath(path);
const span = parsed.span(node);
const source = self.source.items;
const line_start = try self.leadingCommentLineStart(parsed, path, span);
const block_start = commentBlockStart(source, line_start, self.syntax().comments.style);
if (block_start == line_start) return null; // no block above
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(self.allocator);
var it = std.mem.splitScalar(u8, source[block_start..line_start], '\n');
var first = true;
while (it.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, "\r");
const trimmed = std.mem.trimStart(u8, line, " \t");
if (trimmed.len == 0) continue; // skip a trailing empty split slice
if (!first) try out.append(self.allocator, '\n');
first = false;
try out.appendSlice(self.allocator, stripLineCommentMarker(trimmed, marker));
}
return try out.toOwnedSlice(self.allocator);
}
/// Read back the same-line trailing comment on the value at `path` — the
/// one `setTrailingComment` sets and `deleteTrailingComment` removes — with
/// its `marker` (and one following space) stripped. Returns `null` when
/// there is no trailing comment (distinct from a present-but-empty bare `#`,
/// which yields ""). The caller owns the returned bytes. Returns
/// `CommentsUnsupported` for a dialect without comment syntax (strict JSON).
pub fn getTrailingComment(self: *Self, path: []const AST.PathSegment) !?[]u8 {
if (@hasDecl(Language, "getTrailingComment")) return Language.getTrailingComment(self, path);
const marker = self.trailingCommentMarker() orelse return error.CommentsUnsupported;
const win = try self.trailingCommentWindow(path);
const source = self.source.items;
const rel = std.mem.indexOf(u8, source[win.start..win.line_end], marker) orelse
return null; // none
const after = std.mem.trimEnd(u8, source[win.start + rel .. win.line_end], " \t\r");
return try self.allocator.dupe(u8, stripLineCommentMarker(after, marker));
}
// ===============
// INSERT / DELETE
// ===============
//
// These ops never reserialize the document: each computes a byte span +
// replacement text and reuses `replaceAtSpan` (splice + reparse). Inserts
// splice at a zero-length span; deletes splice an empty replacement.
// `value_text`/`key_text` arrive already serialized (single-line scalars,
// or multi-line block text indented from column 0); the editor only
// re-frames indentation and newline/comma context for the splice site.
/// Insert `key_text: value_text` into the mapping at `path` (empty path =
/// root). Appends after the mapping's last entry for block mappings, or
/// inside the braces for flow `{}`. If `path` resolves to a `null` value
/// (a bare `key:`), promotes it to a one-entry nested mapping.
///
/// **Hook** `insertKey(self, parsed, path, node, span, key_text,
/// value_text) !void` — replaces this op wholesale: `node` is already
/// resolved from `path` and `span` is its extent, and the hook owns
/// everything below, including the flow/block decision. Declared by
/// TOML, fig, INI, plist and NestedText.
pub fn insertKey(self: *Self, path: []const AST.PathSegment, key_text: []const u8, value_text: []const u8) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
const span = parsed.span(node);
const source = self.source.items;
if (@hasDecl(Language, "insertKey"))
return Language.insertKey(self, parsed, path, node, span, key_text, value_text);
switch (node.kind) {
.mapping => |first| {
if (isFlow(source, span)) {
try self.insertFlowMapEntry(parsed, node, span, first != null, key_text, value_text);
} else {
try self.insertBlockKey(parsed, node, key_text, value_text);
}
},
.null_ => try self.promoteNullToMapping(span, node.id == parsed.ast.root, key_text, value_text),
else => return error.NotAMapping,
}
}
/// Delete the mapping entry at `path` (which must name a key). For a
/// *block* mapping, removes the entry's full line(s) plus any owned
/// leading comment block (a run of comment lines — `#` for YAML/TOML,
/// `//` or `/* */` for JSON5/JSONC — with no intervening blank line),
/// leaving no blank gap. For a *flow* (`{…}`) mapping — any JSON/JSON5/
/// JSONC object, or a YAML/TOML/fig/ZON inline mapping — routes through a
/// comma-aware entry splice instead (see the comment at that branch),
/// since a flow mapping's entries are comma-separated rather than
/// one-per-line and the block path's line delete cannot handle them
/// safely at any arity or position.
pub fn deleteKey(self: *Self, path: []const AST.PathSegment) !void {
const parsed = try self.getParsed();
const node = parsed.ast.getNodeByPath(path) catch |err| {
// A key with no physical entry of its own — inherited through
// the format's reference layer — has no line to delete, and
// there is no syntax to un-inherit it; deleting the source it
// comes from is a different operation. Refuse explicitly rather
// than report it missing.
if (err == error.NotFound and try keyIsInherited(parsed, path))
return error.MergeOnlyKey;
return err;
};
if (node.kind != .keyvalue) return error.NotAMapping;
const span = parsed.span(node);
const source = self.source.items;
// The language's veto, before anything is spliced. Every declared
// guard today refuses the same hazard: an entry whose value is a
// SCATTERED container, assembled from lines elsewhere in the file,
// so the line-based delete below would remove only the piece it can
// see and orphan or misparse the rest.
if (@hasDecl(Language, "deleteKeyGuard")) try Language.deleteKeyGuard(self, parsed, node, span);
// A flow (`{...}`) mapping stores its entries comma-separated, not
// one-per-line, so the line-based delete below (sized for a *block*
// mapping's one-entry-per-line shape) mishandles it in three ways:
// * packed `{ a: 1, b: 2 }` (the only shape JSON/JSON5/JSONC
// objects ever have, and one inline YAML/TOML/fig/ZON mappings
// also allow) — deleting the whole line swallows the sibling;
// * the *last* entry of a one-per-line flow mapping — the line
// delete strands the predecessor's separator comma before the
// closing `}`, invalid in strict JSON and in TOML inline tables;
// * a *single-entry* flow mapping — the line delete removes the
// whole `{ … }` down to nothing, which YAML/TOML/fig (whose
// grammar reads an empty document as an empty mapping) then
// silently "succeed" at reparsing, committing a wiped file to
// disk.
// So route every flow-mapping entry through `removeFlowItem` — the
// same comma-aware splice a flow *sequence* delete uses — which
// drops exactly one adjoining separator (the following comma for the
// first entry, the preceding comma otherwise) and always leaves the
// enclosing braces intact, correct for every arity and position.
const parent = try parsed.ast.getValByPath(path[0 .. path.len - 1]);
if (parent.kind == .mapping and isFlow(source, parsed.span(parent))) {
// Find the entry's immediate predecessor: `removeFlowItem` drops
// the *following* comma for the first entry and the *preceding*
// comma for any later one, so it needs to know which this is.
var item = (try parsed.ast.child(&parent)).?;
var prev: ?AST.Node = null;
while (item.id != node.id) {
prev = item;
item = parsed.ast.next(&item) orelse return error.NotFound;
}
// A key span that EXCLUDES the format's key sigil — ZON's
// leading `.`, whose span starts at the bare identifier (see
// `insertFlowMapEntry`'s doc on the same quirk) — is backed up
// over so the splice carries `.name` as a unit rather than
// stranding a bare `.` next to a survivor.
const entry_start = if (self.syntax().key_sigil) |sigil|
if (span.start > 0 and source[span.start - 1] == sigil) span.start - 1 else span.start
else
span.start;
// When the entry begins its own physical line, absorb any owned
// leading comment block above it — trivia `removeFlowItem` can't
// find on its own but the block path would have carried. A packed
// entry (a sibling or the opening brace shares its line) is not
// first-on-line, so this is skipped and `removeFlowItem`'s own
// whitespace scan handles the separator and indentation. The
// comment extension applies only when a block was actually found
// (`cbs` climbed above `line_start`); with none, splicing from
// the entry itself keeps the survivors' indentation intact.
const line_start = lineStartBefore(source, entry_start);
const on_own_line = firstNonSpace(source, line_start) == entry_start;
const cbs = if (on_own_line) commentBlockStart(source, line_start, self.syntax().comments.style) else line_start;
const del_start = if (cbs < line_start) cbs else entry_start;
return self.removeFlowItem(Span.init(del_start, span.end), prev == null);
}
const line_start = lineStartBefore(source, span.start);
const del_start = commentBlockStart(source, line_start, self.syntax().comments.style);
const del_end = lineEndAfter(source, span.end -| 1);
try self.replaceAtSpan(Span.init(del_start, del_end), "");
}
/// Append `value_text` as a new item to the sequence at `path`.
///
/// **Hook** `appendToSeq(self, parsed, node, value_text) !void` — takes
/// over the BLOCK arm only. A flow (`[…]`) sequence is comma-delimited
/// the same way in every format that has one, so it keeps the generic
/// path, and a format whose block sequences aren't editable at all
/// (`block_seq_editable`, i.e. TOML) has already refused above. Declared
/// by plist, fig and NestedText.
pub fn appendToSeq(self: *Self, path: []const AST.PathSegment, value_text: []const u8) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
const span = parsed.span(node);
const source = self.source.items;
if (isFlow(source, span)) {
const first = node.kind.sequence;
try self.insertFlowItem(parsed, node, span, first != null, value_text);
return;
}
// A non-flow TOML sequence is an array-of-tables; use
// `appendContainerToSeq` for those. (TOML has no block scalar array.)
if (!self.syntax().block_seq_editable) return error.NotAnInlineArray;
if (@hasDecl(Language, "appendToSeq")) return Language.appendToSeq(self, parsed, node, value_text);
const last = (try parsed.ast.lastChild(&node)) orelse return error.NotASequence;
const first_item = (try parsed.ast.child(&node)).?;
const dash_col = dashColumn(source, parsed.span(first_item).start);
const insert_at = lineEndAfter(source, parsed.span(last).end -| 1);
try self.insertSeqLine(insert_at, dash_col, value_text);
}
/// Insert `value_text` before the first item of the sequence at `path`.
///
/// **Hook** `prependToSeq(self, parsed, node, value_text) !void` — the
/// block-arm twin of `appendToSeq`'s, on the same terms.
pub fn prependToSeq(self: *Self, path: []const AST.PathSegment, value_text: []const u8) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
const span = parsed.span(node);
const source = self.source.items;
if (isFlow(source, span)) {
try self.prependFlowItem(parsed, node, span, node.kind.sequence != null, value_text);
return;
}
if (!self.syntax().block_seq_editable) return error.NotAnInlineArray;
if (@hasDecl(Language, "prependToSeq")) return Language.prependToSeq(self, parsed, node, value_text);
const first_item = (try parsed.ast.child(&node)) orelse return error.NotASequence;
const first_start = parsed.span(first_item).start;
const line_start = lineStartBefore(source, first_start);
const dash_col = dashColumn(source, first_start);
try self.insertSeqLine(line_start, dash_col, value_text);
}
/// Remove the item at `index` from the sequence at `path`. `index ==
/// std.math.maxInt(usize)` is the "end" sentinel — the same one
/// `parsePath` produces for the `[-]`/`[$]` append token — and means
/// "the last item" here, so `contents[-]` deletes symmetrically with
/// how it appends.
///
/// **Hook** `removeSeqItem(self, parsed, node, item, prev) !void` —
/// takes over the block arm, on the same terms as `appendToSeq`'s. It
/// is handed the resolved `item` and its predecessor rather than the
/// index, so the walk below is done once, here.
pub fn removeSeqItem(self: *Self, path: []const AST.PathSegment, index: usize) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
const span = parsed.span(node);
const source = self.source.items;
// Walk to the target item, keeping `prev` (its immediate
// preceding sibling, or null when it's first) alongside — both the
// `is_first` flag (for the flow path) and the block hook (whose
// callers may need to find the item's own line relative to its
// predecessor) need it, and computing it here keeps the walk to a
// single pass rather than repeating it inside the hook.
var item = (try parsed.ast.child(&node)) orelse return error.NotFound;
var prev: ?AST.Node = null;
if (index == std.math.maxInt(usize)) {
while (parsed.ast.next(&item)) |nxt| {
prev = item;
item = nxt;
}
} else {
for (0..index) |_| {
prev = item;
item = parsed.ast.next(&item) orelse return error.NotFound;
}
}
const is_first = prev == null;
const item_span = parsed.span(item);
if (isFlow(source, span)) {
try self.removeFlowItem(item_span, is_first);
return;
}
if (!self.syntax().block_seq_editable) return error.NotAnInlineArray;
if (@hasDecl(Language, "removeSeqItem")) return Language.removeSeqItem(self, parsed, node, item, prev);
const line_start = commentBlockStart(source, lineStartBefore(source, item_span.start), self.syntax().comments.style);
const del_end = lineEndAfter(source, item_span.end -| 1);
try self.replaceAtSpan(Span.init(line_start, del_end), "");
}
/// Reconcile the sequence at `path` so its items are exactly `items` —
/// each an already-serialized *scalar* value in this document's format —
/// while preserving the comments on items that survive the change.
///
/// Items are matched to the current items by abstract value (kind +
/// value, honoring multiplicity), so an item that is kept or merely
/// reordered keeps its leading and trailing comments; only a genuinely
/// new value is inserted and only a genuinely dropped value is deleted.
/// The final item order matches `items`. This is the comment-preserving
/// alternative to replacing the whole list value (which would blow every
/// item's comments away).
///
/// It is a thin orchestration over `appendToSeq` / `removeSeqItem` /
/// `reorderItems`: append the new values, delete the dropped ones, then
/// reorder to `items`. The compound edit is atomic — on any error the
/// document is restored byte-for-byte.
///
/// Declines (errors) rather than guessing when the shape isn't a flat
/// scalar list it can safely diff:
/// * a target that isn't a sequence value -> `NotASequence`;
/// * empty `items`, an empty current list, or any non-scalar item on
/// either side -> `UnsupportedShape` — the caller should fall back to
/// replacing the whole value (e.g. with `[]` for the empty case).
/// A format whose scalars cannot stand alone as a document (TOML) also
/// surfaces as `UnsupportedShape`; reconciling a TOML inline array buys
/// nothing anyway, as it carries no per-element comments.
pub fn setSequence(self: *Self, path: []const AST.PathSegment, items: []const []const u8) !void {
if (items.len == 0) return error.UnsupportedShape;
// ---- plan against the current parse (no mutation yet) ----
// Current item kinds. These borrow `self.document`, so the plan must
// be reduced to plain indices before the first edit reparses.
var cur: std.ArrayList(AST.Node.Kind) = .empty;
defer cur.deinit(self.allocator);
{
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
var maybe = try parsed.ast.child(&node);
while (maybe) |item| {
if (!isScalarKind(item.kind)) return error.UnsupportedShape;
try cur.append(self.allocator, item.kind);
maybe = parsed.ast.next(&item);
}
}
if (cur.items.len == 0) return error.UnsupportedShape;
// Target item kinds: parse each serialized value back to a scalar so
// matching is by abstract value, not formatting (`1` != `'1'`). A
// format whose scalar can't stand alone as a document (TOML) fails
// the parse and is declined here.
var tdocs: std.ArrayList(Document) = .empty;
defer {
for (tdocs.items) |d| d.deinit(self.allocator);
tdocs.deinit(self.allocator);
}
var tgt: std.ArrayList(AST.Node.Kind) = .empty;
defer tgt.deinit(self.allocator);
for (items) |text| {
var parser: Language.Parser = .{ .allocator = self.allocator };
const d = Language.parse(&parser, text, self.format) catch return error.UnsupportedShape;
const k = d.ast.nodes[d.ast.root].kind;
if (!isScalarKind(k)) {
d.deinit(self.allocator);
return error.UnsupportedShape;
}
try tdocs.append(self.allocator, d);
try tgt.append(self.allocator, k);
}
const m = cur.items.len;
const t = tgt.items.len;
// Occurrence index of element `i` = how many earlier elements share
// its value. (kind, occ) is the per-item identity used for matching,
// so duplicate values are paired up by their order of appearance.
const occ = struct {
fn at(kinds: []const AST.Node.Kind, i: usize) usize {
var c: usize = 0;
for (kinds[0..i]) |k| {
if (k.eql(kinds[i])) c += 1;
}
return c;
}
}.at;
// A current item survives iff some target item shares its identity.
const removed = try self.allocator.alloc(bool, m);
defer self.allocator.free(removed);
var removed_count: usize = 0;
for (0..m) |i| {
removed[i] = true;
for (0..t) |j| {
if (cur.items[i].eql(tgt.items[j]) and occ(cur.items, i) == occ(tgt.items, j)) {
removed[i] = false;
break;
}
}
if (removed[i]) removed_count += 1;
}
// A target item is an addition iff no current item shares its identity.
var additions: std.ArrayList(usize) = .empty;
defer additions.deinit(self.allocator);
for (0..t) |j| {
var present = false;
for (0..m) |i| {
if (cur.items[i].eql(tgt.items[j]) and occ(cur.items, i) == occ(tgt.items, j)) {
present = true;
break;
}
}
if (!present) try additions.append(self.allocator, j);
}
// The physical order after append+remove is survivors (old order)
// then additions (target order). `slots[s]` says what sits at index
// `s`: a kept current item or an appended target item.
const Slot = union(enum) { keep: usize, add: usize };
var slots: std.ArrayList(Slot) = .empty;
defer slots.deinit(self.allocator);
for (0..m) |i| {
if (!removed[i]) try slots.append(self.allocator, .{ .keep = i });
}
for (additions.items) |j| try slots.append(self.allocator, .{ .add = j });
// `order[k]` = the slot holding target item `k`, so a reorder by
// `order` (a full permutation) lands the sequence in target order.
const order = try self.allocator.alloc(usize, t);
defer self.allocator.free(order);
const used = try self.allocator.alloc(bool, slots.items.len);
defer self.allocator.free(used);
@memset(used, false);
for (0..t) |k| {
var found: ?usize = null;
for (slots.items, 0..) |slot, s| {
if (used[s]) continue;
const hit = switch (slot) {
.keep => |i| cur.items[i].eql(tgt.items[k]) and occ(cur.items, i) == occ(tgt.items, k),
.add => |j| j == k,
};
if (hit) {
found = s;
break;
}
}
const s = found orelse return error.UnsupportedShape; // unreachable by construction
order[k] = s;
used[s] = true;
}
// No-op: same items, same order — leave the bytes untouched so a
// redundant set never churns formatting.
var needs_reorder = false;
for (order, 0..) |o, k| {
if (o != k) {
needs_reorder = true;
break;
}
}
if (removed_count == 0 and additions.items.len == 0 and !needs_reorder) return;
// ---- apply: append, remove, reorder — atomic across all steps ----
const backup = try self.allocator.dupe(u8, self.source.items);
defer self.allocator.free(backup);
errdefer {
// Capacity only grew during the edits, so the refill cannot fail;
// `backup` parsed before, so the reparse cannot fail either.
self.source.clearRetainingCapacity();
self.source.appendSliceAssumeCapacity(backup);
self.reparse() catch {};
}
// Append first so a full replacement never empties the block mid-edit
// (an empty block sequence has no valid syntax). Appends land at the
// tail, leaving the original items' indices valid for removal.
for (additions.items) |j| try self.appendToSeq(path, items[j]);
// Remove dropped originals high-index-first so lower indices stay put.
var di: usize = m;
while (di > 0) {
di -= 1;
if (removed[di]) try self.removeSeqItem(path, di);
}
if (needs_reorder) try self.reorderItems(path, order);
}
// ============
// MOVE / REORDER
// ============
//
// Like insert/delete, these never reserialize: they relocate whole entry
// blocks (a mapping key's owned comment block + line(s), or a sequence
// item's) and reuse `replaceAtSpan` to splice + reparse. The moved bytes
// are the originals, so comments, quoting, and formatting ride along.
// Block containers tile into per-entry blocks (trailing trivia rides with
// the preceding entry); a flow sequence (`[a, b]`) reuses its original
// separators so only the items move.
/// Move the mapping entry named by `src_path` to sit immediately before
/// the entry named by `dest_path`. Both paths must name keys in the
/// *same* block mapping. The moved entry carries its owned leading
/// comment block and any trailing same-line comment; the bytes between
/// the two entries are preserved. Moving an entry to before itself (or
/// into its own comment block) is a no-op.
pub fn moveKey(self: *Self, src_path: []const AST.PathSegment, dest_path: []const AST.PathSegment) !void {
const parsed = try self.getParsed();
const src = try parsed.ast.getNodeByPath(src_path);
if (src.kind != .keyvalue) return error.NotAMapping;
const dest = try parsed.ast.getNodeByPath(dest_path);
if (dest.kind != .keyvalue) return error.NotAMapping;
const source = self.source.items;
try self.moveBlock(
entryBlockStart(source, parsed.span(src), self.syntax().comments.style),
entryBlockEnd(source, parsed.span(src)),
entryBlockStart(source, parsed.span(dest), self.syntax().comments.style),
);
}
/// Move the sequence item at index `from` to index `to` (both positions
/// in the current order; standard array-move semantics — the item is
/// removed and reinserted, shifting the others to fill). A block item
/// carries its owned leading comment block. No-op when `from == to`.
pub fn moveItem(self: *Self, path: []const AST.PathSegment, from: usize, to: usize) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
const n = try seqLen(parsed, node);
if (from >= n or to >= n) return error.NotFound;
if (from == to) return;
// Build the post-move index order, then reorder by it.
const order = try self.allocator.alloc(usize, n);
defer self.allocator.free(order);
for (order, 0..) |*o, i| o.* = i;
const val = order[from];
if (from < to) {
var i = from;
while (i < to) : (i += 1) order[i] = order[i + 1];
} else {
var i = from;
while (i > to) : (i -= 1) order[i] = order[i - 1];
}
order[to] = val;
try self.reorderSeqNode(parsed, node, order);
}
/// Reorder the entries of the block mapping at `path` (empty path =
/// root) so the keys listed in `keys` come first, in that order; entries
/// whose key is not listed keep their original relative order and follow.
/// Keys in `keys` that the mapping does not contain are ignored. Each
/// entry's owned comments — and any interleaved blank lines / orphan
/// comments, which ride with the entry that precedes them — are
/// preserved, so no bytes are dropped. Errors on a flow mapping (`{…}`).
pub fn reorderKeys(self: *Self, path: []const AST.PathSegment, keys: []const []const u8) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .mapping) return error.NotAMapping;
const first_id = node.kind.mapping orelse return; // empty mapping
const source = self.source.items;
if (isFlow(source, parsed.span(node))) return error.NotAMapping;
// Gather each entry's key (for matching) and block, in document order.
var entry_keys: std.ArrayList([]const u8) = .empty;
defer entry_keys.deinit(self.allocator);
var blocks: std.ArrayList(Block) = .empty;
defer blocks.deinit(self.allocator);
var cur = parsed.ast.nodes[first_id];
var last_end: usize = 0;
while (true) {
if (cur.kind != .keyvalue) return error.InvalidDocument;
const key_node = parsed.ast.nodes[cur.kind.keyvalue.key];
const key = switch (key_node.kind) {
.string => |s| s,
else => return error.InvalidDocument,
};
try entry_keys.append(self.allocator, key);
try blocks.append(self.allocator, .{ .start = entryBlockStart(source, parsed.span(cur), self.syntax().comments.style), .end = 0 });
last_end = entryBlockEnd(source, parsed.span(cur));
cur = parsed.ast.next(&cur) orelse break;
}
tileBlocks(blocks.items, last_end);
// Translate the requested keys into entry indices (first unused match
// wins), then reorder the blocks by that index list.
var order: std.ArrayList(usize) = .empty;
defer order.deinit(self.allocator);
const chosen = try self.allocator.alloc(bool, blocks.items.len);
defer self.allocator.free(chosen);
@memset(chosen, false);
for (keys) |k| {
for (entry_keys.items, 0..) |seen, i| {
if (!chosen[i] and std.mem.eql(u8, seen, k)) {
try order.append(self.allocator, i);
chosen[i] = true;
break;
}
}
}
try self.reorderBlocks(blocks.items[0].start, last_end, blocks.items, order.items);
}
/// Reorder the items of the sequence at `path` (block or flow) so the
/// items at the indices listed in `indices` (positions in the current
/// order) come first, in that order; items not listed keep their
/// original relative order and follow. Out-of-range indices are ignored.
/// Block items carry their owned comments; a flow sequence keeps its
/// original separators so only the items move.
pub fn reorderItems(self: *Self, path: []const AST.PathSegment, indices: []const usize) !void {
const parsed = try self.getParsed();
const node = try parsed.ast.getValByPath(path);
if (node.kind != .sequence) return error.NotASequence;
try self.reorderSeqNode(parsed, node, indices);
}
// --- move / reorder internals ---
/// Reorder a sequence node's items by `order` (bring-to-front indices),
/// dispatching on flow vs block style.
fn reorderSeqNode(self: *Self, parsed: Document, node: AST.Node, order: []const usize) !void {
const source = self.source.items;
var spans: std.ArrayList(Span) = .empty;
defer spans.deinit(self.allocator);
var maybe = try parsed.ast.child(&node);
while (maybe) |item| {
try spans.append(self.allocator, parsed.span(item));
maybe = parsed.ast.next(&item);
}
if (spans.items.len == 0) return;
if (isFlow(source, parsed.span(node))) {
try self.reorderFlowItems(spans.items, order);
return;
}
if (!self.syntax().block_seq_editable) return error.NotAnInlineArray;
// Hook `reorderSeqItems(self, parsed, node, order) !void`, taking
// over the block arm — for a format whose item block boundaries
// can't be recovered from `spans.items[i].start` alone. The tiling/
// permutation/splice underneath is format-independent, so a hook is
// expected to reuse this file's `Block`/`fullOrder`/`appendBlockSep`
// rather than reimplement them.
if (@hasDecl(Language, "reorderSeqItems")) return Language.reorderSeqItems(self, parsed, node, order);
var blocks: std.ArrayList(Block) = .empty;
defer blocks.deinit(self.allocator);
for (spans.items) |s| {
try blocks.append(self.allocator, .{ .start = entryBlockStart(source, s, self.syntax().comments.style), .end = 0 });
}
const last_end = entryBlockEnd(source, spans.items[spans.items.len - 1]);
tileBlocks(blocks.items, last_end);
try self.reorderBlocks(blocks.items[0].start, last_end, blocks.items, order);
}
/// Splice a block container's region so the entries indexed by `order`
/// (in document order) come first, then the rest in original order.
/// `blocks` must be in document order and tile `[region_start, region_end)`.
fn reorderBlocks(self: *Self, region_start: usize, region_end: usize, blocks: []const Block, order: []const usize) !void {
const perm = try fullOrder(self.allocator, order, blocks.len);
defer self.allocator.free(perm);
const source = self.source.items;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
for (perm) |i| try appendBlockSep(&out, self.allocator, source[blocks[i].start..blocks[i].end]);
try self.replaceAtSpan(Span.init(region_start, region_end), out.items);
}
/// Splice a flow sequence (`[a, b, …]`) so its items follow `order`,
/// reusing each slot's original separator bytes so the comma/space
/// framing is preserved while only the item contents move.
fn reorderFlowItems(self: *Self, items: []const Span, order: []const usize) !void {
const perm = try fullOrder(self.allocator, order, items.len);
defer self.allocator.free(perm);
const source = self.source.items;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
for (perm, 0..) |src_idx, slot| {
try out.appendSlice(self.allocator, source[items[src_idx].start..items[src_idx].end]);
// Reuse the separator that originally sat after position `slot`.
if (slot + 1 < perm.len) {
try out.appendSlice(self.allocator, source[items[slot].end..items[slot + 1].start]);
}
}
try self.replaceAtSpan(Span.init(items[0].start, items[items.len - 1].end), out.items);
}
/// Move the block `[src_start, src_end)` so it begins at `dest_start`,
/// preserving the bytes between source and destination. No-op when the
/// destination falls within the source block.
fn moveBlock(self: *Self, src_start: usize, src_end: usize, dest_start: usize) !void {
if (dest_start >= src_start and dest_start <= src_end) return;
const source = self.source.items;
const moved = source[src_start..src_end];
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
if (src_end <= dest_start) {
// src precedes dest: [src][between][dest..] -> [between][src][dest..]
try appendBlockSep(&out, self.allocator, source[src_end..dest_start]);
try appendBlockSep(&out, self.allocator, moved);
try self.replaceAtSpan(Span.init(src_start, dest_start), out.items);
} else {
// dest precedes src: [dest..][between][src] -> [src][dest..][between]
try appendBlockSep(&out, self.allocator, moved);
try appendBlockSep(&out, self.allocator, source[dest_start..src_start]);
try self.replaceAtSpan(Span.init(dest_start, src_end), out.items);
}
}
// --- insert helpers (build text, then splice) ---
/// Insert `key_text<kv_sep>value_text` as a new entry in the block
/// (non-flow) mapping `mapping`, after its last existing entry (or,
/// if it has none, at a language-appropriate fallback point — see
/// below). `pub` so `ini/editor_helper.zig`'s `iniInsertKey` (INI's
/// `isFlow`-bypassing `insertKey`) can reuse it directly rather than
/// duplicate it.
pub fn insertBlockKey(self: *Self, parsed: Document, mapping: AST.Node, key_text: []const u8, value_text: []const u8) !void {
const source = self.source.items;
// Every currently-supported block-mapping language represents an
// empty mapping as `.null_` (promoted via `promoteNullToMapping`),
// never as a childless `.mapping` — so `lastChild`/`firstChildKey`
// have always found a real entry to anchor on. dotenv/.properties
// break that assumption: their root is unconditionally `.mapping`
// even for a totally empty (or comment-only) file, so the very
// first `insertKey` into a fresh file lands here with zero
// children. Fall back to column 0 and the mapping's own span end
// (its whole-file span for the flat formats' root) rather than
// unwrapping a null.
const maybe_last = try parsed.ast.lastChild(&mapping);
const col: usize = if (try parsed.ast.firstChildKey(&mapping)) |key_node|
columnOf(source, parsed.span(key_node).start)
else
0;
const insert_at = if (maybe_last) |last|
lineEndAfter(source, parsed.span(last).end -| 1)
else if (mapping.id == parsed.ast.root)
// The root's span is the whole remaining document (dotenv/
// .properties/INI's root is always `Span.init(0, input.len)`
// — see each parser's `parseOnce`), so its `.end` is already
// the right splice point even with zero existing keys.
parsed.span(mapping).end
else
// A childless NON-ROOT mapping is only reachable for INI (an
// empty `[section]` with nothing under it yet): unlike the
// root, its span is anchored at just the header's name token
// (see `ini/parser.zig`'s `parseSectionHeader`), not the
// section's body extent — splicing at `.end` would land
// inside the `[section]` line itself. Anchor on the header
// LINE's end instead; `span.start` is guaranteed to fall
// somewhere on that line, so `lineEndAfter` finds it
// regardless of where exactly within the line it points.
lineEndAfter(source, parsed.span(mapping).start);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
if (insert_at > 0 and source[insert_at - 1] != '\n') try out.append(self.allocator, '\n');
try out.appendNTimes(self.allocator, ' ', col);
try out.appendSlice(self.allocator, key_text);
try self.writeMapValue(&out, col, value_text);
try out.append(self.allocator, '\n');
try self.replaceAtSpan(Span.init(insert_at, insert_at), out.items);
}
// --- Whole-container structural editing (section formats) ---
//
// The EXCLUSIVE operations: a format whose logical containers are
// scattered through the source (TOML's `[table]` headers, fig's `>`
// marker runs and re-entered headers, INI's reopened `[section]`) needs
// ops the generic line-splice engine has no counterpart for, because
// there is no single `[min,max)` range to splice. Each implementation
// lives in that format's `editor_helper.zig`, next to the region gather
// it builds on, over the shared machinery in
// `languages/shared/sections.zig`.
//
// These are DECLARED, not identity-checked: the wrapper dispatches on
// `@hasDecl(Language, …)` exactly as every hook above does, so a format
// opts in by declaring the op in its "Editing hooks" block and this file
// names no format. What the wrappers cannot do is stop existing for the
// formats that declare nothing — Zig has no conditional container-level
// declarations since `usingnamespace` went away in 0.15 (see the
// proposal's §8.1) — so a `@compileError` remains the answer there. The
// difference is what it is keyed on: a missing declaration rather than a
// hardcoded `Language != Toml`.
//
// The vocabulary is the operation's, not any one format's: TOML's
// `[table]`, fig's block container and INI's `[section]` are the same
// thing here, and the format's own words survive in its errors
// (`NotATable` vs `NotAContainer`) and its helper's documentation.
/// Delete the whole container named by `path` — every scattered region
/// of its subtree, leaving interleaved foreign content untouched. For
/// TOML that is a table, array-of-tables, or single AoT element.
pub fn deleteContainer(self: *Self, path: []const AST.PathSegment) !void {
comptime requireSectionOp("deleteContainer");
return Language.deleteContainer(self, path);
}
/// Create a new container at `path` whose body is `body_text` (verbatim
/// entry lines, possibly empty), spliced where no existing key is
/// reparented.
pub fn insertContainer(self: *Self, path: []const AST.PathSegment, body_text: []const u8) !void {
comptime requireSectionOp("insertContainer");
return Language.insertContainer(self, path, body_text);
}
/// Rename the leaf segment of the container at `path` to `new_leaf`,
/// rewriting every header that shares the prefix.
///
/// Only TOML needs this: a fig or INI header carries its key in one
/// tight span the generic `replaceKeyAtPath` already rewrites, while a
/// TOML rename must also reach `[a.b]`, `[a.b.c]`, `[[a.b]]`.
pub fn renameContainer(self: *Self, path: []const AST.PathSegment, new_leaf: []const u8) !void {
comptime requireSectionOp("renameContainer");
return Language.renameContainer(self, path, new_leaf);
}
/// Move the container at `src_path` before `dest_path` (or to EOF if
/// null), re-emitting its scattered fragments contiguously.
pub fn moveContainer(self: *Self, src_path: []const AST.PathSegment, dest_path: ?[]const AST.PathSegment) !void {
comptime requireSectionOp("moveContainer");
return Language.moveContainer(self, src_path, dest_path);
}
/// Reorder top-level containers to the order given by `order` (their
/// keys). Containers not named are untouched.
pub fn reorderContainers(self: *Self, order: []const []const u8) !void {
comptime requireSectionOp("reorderContainers");
return Language.reorderContainers(self, order);
}
/// Append a new element (body `body_text`) to the container sequence at
/// `path` — TOML's `[[header]]` array-of-tables append.
pub fn appendContainerToSeq(self: *Self, path: []const AST.PathSegment, body_text: []const u8) !void {
comptime requireSectionOp("appendContainerToSeq");
return Language.appendContainerToSeq(self, path, body_text);
}
/// The comptime refusal shared by the six ops above. Names the format
/// and the declaration it is missing, since "this format has no such
/// operation" is the whole content of the error.
fn requireSectionOp(comptime op: []const u8) void {
if (!@hasDecl(Language, op))
@compileError("'" ++ op ++ "' is a whole-container op that '" ++ Language.name ++
"' does not declare — see the \"Editing hooks\" block of its <lang>.zig");
}
/// How a splice's `value_text` is *spelled* — whether it stands as an
/// inline value right after `key<sep>`, or is a block construct that has
/// to descend onto the following lines.
///
/// A splice carries only text, so this is the classification the framing
/// decisions hang off. The container cases are settled by PARSING the
/// text rather than sniffing its shape: a one-entry block mapping
/// (`k: v`) is a single line with no dash and no line break, so shape
/// alone cannot tell it from a scalar — and splicing it inline yields
/// `key: k: v`, which is not YAML at all. That indistinguishability is
/// the whole reason this enum exists.
const ValueShape = enum {
/// A scalar, a flow container (`[a, b]` / `{k: v}`), or a quoted
/// string: splices directly after the separator.
inline_,
/// A block-scalar header (`|`/`>`): also splices after the separator
/// (its body is already indented), but has no flow spelling.
block_scalar,
/// A block sequence (`- a`), which descends at the KEY's own column.
block_seq,
/// A block mapping (`k: v`), which descends indented under the key.
block_map,
};
/// Classify `value_text` for the framing decisions (see `ValueShape`).
fn valueShape(self: *Self, value_text: []const u8) ValueShape {
const v = stripTrailingNewline(value_text);
const nl = std.mem.indexOfScalar(u8, v, '\n');
const first_line = std.mem.trimStart(u8, if (nl) |i| v[0..i] else v, " ");
if (first_line.len == 0) return .inline_;
if (first_line[0] == '|' or first_line[0] == '>') return .block_scalar;
// A block sequence is recognizable even on a single line (`- a`); it
// must still descend, since `key: - a` is invalid. (A serialized
// scalar that would read as a dash is quoted, so this is safe.)
if (std.mem.startsWith(u8, first_line, "- ") or std.mem.eql(u8, first_line, "-")) return .block_seq;
if (nl != null) return .block_map;
return if (self.singleLineIsBlockMapping(first_line)) .block_map else .inline_;
}
/// Whether single-line `text` is a block MAPPING entry (`k: v`) rather
/// than a scalar — the one shape that cannot be told apart by sniffing,
/// so it is read by the language's own parser.
///
/// Only asked of a format that declares `single_line_block_mapping`
/// (YAML alone today); everywhere else a `k: v` value is genuinely
/// just scalar text and must keep splicing inline. See that field.
fn singleLineIsBlockMapping(self: *Self, text: []const u8) bool {
if (!self.syntax().single_line_block_mapping) return false;
// A flow container also parses as a mapping/sequence but must stay
// inline; a quoted scalar parses as a string. Both are settled by
// the opening byte, cheaper than a parse.
switch (text[0]) {
'{', '[', '"', '\'' => return false,
else => {},
}
var parser: Language.Parser = .{ .allocator = self.allocator };
var doc = Language.parse(&parser, text, self.format) catch return false;
defer doc.deinit(self.allocator);
return doc.ast.nodes[doc.ast.root].kind == .mapping;
}
/// Append `: value` for a mapping entry whose key is already written at
/// column `col`. Scalars and block scalars stay inline (`key: value`);
/// a block collection goes on the following lines, indented (a nested
/// mapping at `col + 2`, an indentless sequence at `col`).
pub fn writeMapValue(self: *Self, out: *std.ArrayList(u8), col: usize, value_text: []const u8) !void {
const v = stripTrailingNewline(value_text);
const shape = self.valueShape(v);
const is_seq = shape == .block_seq;
// No value at all — a bare `key:` (YAML's null, and the seed `set`
// vivifies missing ancestors with). The separator's padding is
// trimmed so the line doesn't end in whitespace; formats whose
// separator carries no padding (`KEY=`) are unaffected.
if (v.len == 0) {
try out.appendSlice(self.allocator, std.mem.trimEnd(u8, try self.kvSep(), " "));
return;
}
if (shape == .inline_ or shape == .block_scalar) {
try out.appendSlice(self.allocator, try self.kvSep());
try reindentInto(out, self.allocator, v, col);
return;
}
// Block collection value: descend onto the next lines. Only
// reachable for languages with real nested block containers
// (YAML/JSON5/fig) — dotenv/.properties values are always a
// single line, so they never take this branch; the literal `:`
// below is that block-mapping syntax, not `kv_sep`.
const child_col = if (is_seq) col else col + 2;
try out.append(self.allocator, ':');
var it = std.mem.splitScalar(u8, v, '\n');
while (it.next()) |line| {
try out.append(self.allocator, '\n');
if (line.len > 0) try out.appendNTimes(self.allocator, ' ', child_col);
try out.appendSlice(self.allocator, line);
}
}
fn insertSeqLine(self: *Self, insert_at: usize, dash_col: usize, value_text: []const u8) !void {
const source = self.source.items;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
if (insert_at > 0 and source[insert_at - 1] != '\n') try out.append(self.allocator, '\n');
try out.appendNTimes(self.allocator, ' ', dash_col);
try out.appendSlice(self.allocator, "- ");
try reindentInto(&out, self.allocator, value_text, dash_col + 2);
try out.append(self.allocator, '\n');
try self.replaceAtSpan(Span.init(insert_at, insert_at), out.items);
}
fn promoteNullToMapping(self: *Self, null_span: Span, is_root: bool, key_text: []const u8, value_text: []const u8) !void {
const source = self.source.items;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
// A format with no bare `key: value` document form (ZON — unlike
// YAML/JSON5, whose root can be a keyless top-level mapping)
// promotes a `null` value in place to a flow container, root or
// nested alike, so this needs no `is_root`/descend distinction.
const syn = self.syntax();
if (!syn.bare_document_mapping) {
try out.appendSlice(self.allocator, syn.flow_map_open);
try out.append(self.allocator, ' ');
try out.appendSlice(self.allocator, key_text);
try out.appendSlice(self.allocator, try self.kvSep());
try out.appendSlice(self.allocator, value_text);
try out.append(self.allocator, ' ');
try out.appendSlice(self.allocator, syn.flow_map_close);
try self.replaceAtSpan(null_span, out.items);
return;
}
// `writeMapValue` emits the separator itself, so a block value
// (`- a`, `k: v`) descends onto the following lines instead of being
// jammed inline after the `:` — where it would not parse.
if (is_root) {
// Empty document: the whole source becomes a single entry.
try out.appendSlice(self.allocator, key_text);
try self.writeMapValue(&out, 0, value_text);
try out.append(self.allocator, '\n');
try self.replaceAtSpan(Span.init(0, source.len), out.items);
return;
}
const line_start = lineStartBefore(source, null_span.start);
const key_col = firstNonSpace(source, line_start) - line_start;
const child_col = key_col + 2;
try out.append(self.allocator, '\n');
try out.appendNTimes(self.allocator, ' ', child_col);
try out.appendSlice(self.allocator, key_text);
try self.writeMapValue(&out, child_col, value_text);
try self.replaceAtSpan(null_span, out.items);
}
/// Reject `value_text` that has no flow spelling before it is spliced
/// into a `{…}`/`[…]` container. A flow container holds one line of
/// comma-separated members: a block sequence (`- a`), a block mapping
/// (`k: v`), a block scalar (`|`), or anything multi-line means something
/// else entirely — or nothing at all — once wrapped in braces.
///
/// The reparse in `replaceAtSpan` is the general safety net for a bad
/// splice, but it cannot be the one that catches this: `{b: - a}` is
/// text a lenient reader may well accept as the STRING `"- a"`, so the
/// document still parses and the sequence is silently gone. Refuse up
/// front instead, and refuse with a name that says why, rather than
/// leaving the caller a generic parse failure to interpret.
///
/// A caller that needs a block value under a flow container has to
/// render the value in flow (fig's binding: `flow = 1`) or expand the
/// container to block form first.
fn requireFlowValue(self: *Self, value_text: []const u8) !void {
if (self.valueShape(value_text) != .inline_) return error.BlockValueIntoFlow;
}
/// Insert a `key: value` entry into a brace-delimited (flow) mapping,
/// matching its layout. A pretty-printed mapping — one whose closing `}`
/// sits on its own line below the members — gets the new entry on its own
/// line, indented to match the existing members (a trailing comma after
/// the last member's value, newline, member indent, `key: value`). A
/// compact single-line mapping keeps the inline `", key: value"` style.
fn insertFlowMapEntry(self: *Self, parsed: Document, node: AST.Node, span: Span, non_empty: bool, key_text: []const u8, value_text: []const u8) !void {
try self.requireFlowValue(value_text);
const source = self.source.items;
if (non_empty) {
const last = (try parsed.ast.lastChild(&node)).?;
const last_end = parsed.span(last).end;
const close = span.end - 1; // the '}'
// Multi-line layout: the closing brace is separated from the last
// member by a newline. Splice after the last member's value so the
// new entry lands on its own line, not jammed before the brace.
if (std.mem.indexOfScalar(u8, source[last_end..close], '\n') != null) {
// Column of the key's line, not the key node's own span start:
// for ZON the key span covers only the bare identifier after
// its leading `.` (the dot is a separate token), so anchoring
// on the span would misindent by one column. Every other
// format's key span already starts at that line's first
// content byte, so this is equivalent for them.
const key_node = (try parsed.ast.firstChildKey(&node)).?;
const col = columnOf(source, firstNonSpace(source, lineStartBefore(source, parsed.span(key_node).start)));
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
try out.appendSlice(self.allocator, ",\n");
try out.appendNTimes(self.allocator, ' ', col);
try out.appendSlice(self.allocator, key_text);
try out.appendSlice(self.allocator, try self.kvSep());
try out.appendSlice(self.allocator, value_text);
try self.replaceAtSpan(Span.init(last_end, last_end), out.items);
return;
}
// Single-line layout: splice right after the last member's own
// value — NOT right before the closing brace (`span.end - 1`),
// which would land inside any padding space before `}` (`{ a: 1
// }` -> `{ a: 1 , b: 2}`, swallowing the closing pad and leaving
// none before the new entry). Landing at `last_end` keeps any such
// padding after the new entry instead.
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
try out.appendSlice(self.allocator, ", ");
try out.appendSlice(self.allocator, key_text);
try out.appendSlice(self.allocator, try self.kvSep());
try out.appendSlice(self.allocator, value_text);
try self.replaceAtSpan(Span.init(last_end, last_end), out.items);
return;
}
return self.insertFlowEntry(span, key_text, value_text);
}
/// Insert the first entry into an EMPTY flow mapping (`{}` / ZON's
/// `.{}`): a tight `{key: value}` splice, unpadded — matching how
/// `insertFlowItem`'s empty-array case and the pre-existing JSON/YAML
/// empty-flow-map tests already splice (no space added around a
/// freshly-created single member).
fn insertFlowEntry(self: *Self, span: Span, key_text: []const u8, value_text: []const u8) !void {
try self.requireFlowValue(value_text);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
try out.appendSlice(self.allocator, key_text);
try out.appendSlice(self.allocator, try self.kvSep());
try out.appendSlice(self.allocator, value_text);
const at = flowOpenEnd(self.source.items, span); // just after '{' (or ZON's '.{')
try self.replaceAtSpan(Span.init(at, at), out.items);
}
/// Insert `value_text` as the new last item of the flow sequence
/// `node`/`span`. Splices immediately after the current last
/// element rather than before the closing `]`, so a pre-existing
/// trailing comma (legal in fig/JSON5 flow arrays) isn't doubled
/// into an empty element that fails to reparse. When the array is
/// laid out one item per line, the new item follows that same
/// one-per-line style, indented to match the first item — mirroring
/// `insertFlowMapEntry`'s multi-line handling.
fn insertFlowItem(self: *Self, parsed: Document, node: AST.Node, span: Span, non_empty: bool, value_text: []const u8) !void {
try self.requireFlowValue(value_text);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
const source = self.source.items;
if (non_empty) {
const last = (try parsed.ast.lastChild(&node)).?;
const last_end = parsed.span(last).end;
const close = span.end - 1; // the ']'
if (std.mem.indexOfScalar(u8, source[last_end..close], '\n') != null) {
const first_item = (try parsed.ast.child(&node)).?;
const col = columnOf(source, parsed.span(first_item).start);
try out.appendSlice(self.allocator, ",\n");
try out.appendNTimes(self.allocator, ' ', col);
} else {
try out.appendSlice(self.allocator, ", ");
}
try out.appendSlice(self.allocator, value_text);
try self.replaceAtSpan(Span.init(last_end, last_end), out.items);
return;
}
try out.appendSlice(self.allocator, value_text);
const at = flowOpenEnd(self.source.items, span); // just after '[' (or ZON's '.{')
try self.replaceAtSpan(Span.init(at, at), out.items);
}
fn prependFlowItem(self: *Self, parsed: Document, node: AST.Node, span: Span, non_empty: bool, value_text: []const u8) !void {
try self.requireFlowValue(value_text);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(self.allocator);
try out.appendSlice(self.allocator, value_text);
// Splice right before the CURRENT first item's own span — not
// `flowOpenEnd` — so any padding space between the delimiter and that
// item (`[ a, b ]`) stays between the delimiter and the newly
// prepended item rather than being swallowed against it (`[0, a,
// b]`, an extra space where the old padding and the new separator
// collided).
const at = if (non_empty) blk: {
try out.appendSlice(self.allocator, ", ");
const first = (try parsed.ast.child(&node)).?;
break :blk parsed.span(first).start;
} else flowOpenEnd(self.source.items, span); // just after '[' (or ZON's '.{')
try self.replaceAtSpan(Span.init(at, at), out.items);
}
/// Whitespace the flow-item scan may cross while hunting for the
/// adjoining comma: spaces/tabs plus newlines, so a one-item-per-line
/// layout (item on its own indented line) is treated the same as a
/// packed single-line layout.
fn isFlowItemWs(c: u8) bool {
return c == ' ' or c == '\t' or c == '\n';
}
fn removeFlowItem(self: *Self, item_span: Span, is_first: bool) !void {
const source = self.source.items;
if (is_first) {
// Drop the item and a following ", " if present. Consuming
// forward through trailing whitespace *including newlines*
// means a standalone-line item's own indentation-and-newline
// goes with it, leaving the next item on the line the
// removed item's leading indent occupied — not a stray
// blank line.
var e = item_span.end;
while (e < source.len and isFlowItemWs(source[e])) e += 1;
if (e < source.len and source[e] == ',') {
e += 1;
while (e < source.len and isFlowItemWs(source[e])) e += 1;
}
try self.replaceAtSpan(Span.init(item_span.start, e), "");
} else {
// Drop a preceding ", " and the item. Scanning backward
// across newlines (not just spaces/tabs) is what lets this
// find the *previous* item's separator comma when the
// removed item sits alone on its own line — otherwise the
// scan stops at the newline and leaves the removed item's
// own trailing comma (if the array uses a trailing-comma
// style) dangling with nothing before it, which fails to
// reparse as an empty element.
var s = item_span.start;
while (s > 0 and isFlowItemWs(source[s - 1])) s -= 1;
if (s > 0 and source[s - 1] == ',') {
s -= 1;
while (s > 0 and isFlowItemWs(source[s - 1])) s -= 1;
}
try self.replaceAtSpan(Span.init(s, item_span.end), "");
}
}
/// Replace a span of bytes with a new span of bytes.
/// Not aware of self.format. Invalidates self.parsed until reparsed.
fn replaceSource(self: *Self, old_span: Span, text: []const u8) !void {
if (old_span.end < old_span.start or old_span.end > self.source.items.len) {
return error.InvalidSpan;
}
try self.source.replaceRange(self.allocator, old_span.start, old_span.len(), text);
}
/// After an edit, restores self.parsed so node spans are valid again.
fn reparse(self: *Self) !void {
const parsed = try self.parseSource();
self.freeDocument();
self.document = parsed;
}
fn parseSource(self: *Self) !Document {
var parser: Language.Parser = .{ .allocator = self.allocator };
return Language.parse(&parser, self.source.items, self.format);
}
fn freeDocument(self: *Self) void {
if (self.document) |parsed| {
parsed.deinit(self.allocator);
self.document = null;
}
}
pub fn deinit(self: *Self) void {
self.freeDocument();
self.source.deinit(self.allocator);
}
};
}
// ======================
// SOURCE-COORDINATE UTILS
// ======================
//
// Editing reframes splice text against the raw source, because indentation,
// trailing newlines, and comments live *outside* any AST node span (node spans
// are tight: they exclude leading indent and, except for block scalars, the
// trailing newline; comments are not represented in the AST at all).
/// Byte index of the start of the line containing `at` (just past the previous
/// '\n', or 0).
pub fn lineStartBefore(source: []const u8, at: usize) usize {
var i = at;
while (i > 0) : (i -= 1) {
if (source[i - 1] == '\n') return i;
}
return 0;
}
/// Byte index just past the next '\n' at or after `at`, or `source.len`.
pub fn lineEndAfter(source: []const u8, at: usize) usize {
if (std.mem.indexOfScalarPos(u8, source, at, '\n')) |nl| return nl + 1;
return source.len;
}
/// Index of the first non-space/non-tab byte at or after `from`.
pub fn firstNonSpace(source: []const u8, from: usize) usize {
var i = from;
while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1;
return i;
}
/// Column (0-based) of the byte at `at` within its line.
pub fn columnOf(source: []const u8, at: usize) usize {
return at - lineStartBefore(source, at);
}
/// Column of the `-` introducing the sequence item whose content begins at
/// `item_content_start`. The item's node span starts *after* the dash, so we
/// recover the dash from the first non-space byte on the item's line.
fn dashColumn(source: []const u8, item_content_start: usize) usize {
const line_start = lineStartBefore(source, item_content_start);
return firstNonSpace(source, line_start) - line_start;
}
/// Whether the container at `span` is written in flow style (`{...}`/`[...]`).
/// The AST records no flow/block flag, so we sniff the first content byte. ZON
/// has no other container shape — every struct/array literal opens `.{` (its
/// node span starts at the `.`, not the brace) — so it is unconditionally flow.
pub fn isFlow(source: []const u8, span: Span) bool {
const i = firstNonSpace(source, span.start);
if (i >= source.len) return false;
if (source[i] == '{' or source[i] == '[') return true;
return source[i] == '.' and i + 1 < source.len and source[i + 1] == '{';
}
/// Byte index just past a flow container's opening delimiter (`{`, `[`, or
/// ZON's two-byte `.{`). Used to splice the first entry/item into an empty
/// container, where `span.start` alone isn't past the delimiter for ZON.
pub fn flowOpenEnd(source: []const u8, span: Span) usize {
const i = firstNonSpace(source, span.start);
if (i < source.len and source[i] == '.') return i + 2; // '.' + '{'
return i + 1;
}
/// Comment syntax for the owned-comment scan: `#` line comments (YAML/TOML) vs
/// `//` line comments and `/* */` blocks (JSON5/JSONC).
///
/// Re-exported from the language manifest, where it moved so a
/// `<lang>/<lang>.zig` can name it in its own `syntax` without importing the
/// editor. Kept here because `toml/editor_helper.zig` and its siblings reach
/// it as `editor.CommentStyle`.
pub const CommentStyle = lang.CommentStyle;
/// Grow `line_start` upward to absorb an entry's owned comment block: the
/// contiguous run of comment lines immediately above, with no intervening blank
/// line (trivia policy "comment-above-belongs-to-key"). A blank line or any
/// non-comment content stops the scan. With `.slashes`, multi-line `/* ... */`
/// blocks are walked as a unit so a delete/move carries the whole block, not
/// just its closing line.
pub fn commentBlockStart(source: []const u8, line_start: usize, style: CommentStyle) usize {
var ls = line_start;
// `.slashes` only: set while scanning upward through the interior of a
// `/* ... */` block whose opener `/*` has not been reached yet.
var in_block = false;
while (ls > 0) {
const prev_start = lineStartBefore(source, ls - 1);
const line = source[prev_start..ls];
const trimmed = std.mem.trimStart(u8, std.mem.trimEnd(u8, line, "\r\n"), " \t");
const is_comment = switch (style) {
.hash => trimmed.len > 0 and trimmed[0] == '#',
.semicolon => trimmed.len > 0 and trimmed[0] == ';',
// plist/XML `<!-- ... -->`. Only the common own-line, single-line
// comment is recognized for the owned-block scan (a multi-line
// `<!--\n...\n-->` block is not walked as a unit — a rare shape a
// hand-editor is unlikely to place directly above a key). A line
// that merely opens a block (`<!--` with no closing `-->`) is not
// treated as an owned comment, so a delete never half-swallows one.
.xml_comment => std.mem.startsWith(u8, trimmed, "<!--") and std.mem.endsWith(u8, trimmed, "-->"),
.slashes => blk: {
if (in_block) {
// Inside a block comment, moving up: every line belongs to it
// until we reach the line bearing the `/*` opener.
if (std.mem.indexOf(u8, trimmed, "/*") != null) in_block = false;
break :blk true;
}
if (std.mem.startsWith(u8, trimmed, "//")) break :blk true;
// A line ending a `/* */` block: enter block-scan mode unless it is
// a self-contained single-line `/* ... */`.
if (std.mem.endsWith(u8, trimmed, "*/")) {
if (!std.mem.startsWith(u8, trimmed, "/*")) in_block = true;
break :blk true;
}
break :blk false;
},
};
if (is_comment) {
ls = prev_start;
} else break;
}
return ls;
}
/// Start of a mapping entry's full block: its owned leading comment block
/// (`commentBlockStart`) at the start of the key's line. Mirrors the span math
/// `deleteKey` uses, factored out for move/reorder.
fn entryBlockStart(source: []const u8, kv_span: Span, style: CommentStyle) usize {
return commentBlockStart(source, lineStartBefore(source, kv_span.start), style);
}
/// End of a mapping entry's full block: just past the newline ending its last
/// line (or `source.len` when the final line is unterminated).
fn entryBlockEnd(source: []const u8, kv_span: Span) usize {
return lineEndAfter(source, kv_span.end -| 1);
}
/// Append a relocated entry `block` to `out`, guaranteeing a single '\n'
/// separator from whatever precedes it. The block's own bytes are appended
/// verbatim (its trailing newline, if any, is preserved), so concatenating
/// blocks in a new order never welds two entries onto one line.
pub fn appendBlockSep(out: *std.ArrayList(u8), allocator: std.mem.Allocator, block: []const u8) !void {
if (out.items.len > 0 and out.items[out.items.len - 1] != '\n') {
try out.append(allocator, '\n');
}
try out.appendSlice(allocator, block);
}
/// Strip a leading line-comment `marker` (and one following space) from `line`,
/// the inverse of how `renderLineComments`/`setTrailingComment` emit a comment.
/// `line` must already have its leading whitespace trimmed. A line that doesn't
/// start with `marker` is returned unchanged.
fn stripLineCommentMarker(line: []const u8, marker: []const u8) []const u8 {
if (!std.mem.startsWith(u8, line, marker)) return line;
var rest = line[marker.len..];
if (rest.len > 0 and rest[0] == ' ') rest = rest[1..];
return rest;
}
/// Render `text` as one or more own-line comments into `out`, each line being
/// `indent` + `marker` (+ a space and the line's text, unless the line is empty)
/// + '\n'. A single trailing newline in `text` is ignored so it never yields a
/// stray empty comment line.
fn renderLineComments(
allocator: std.mem.Allocator,
out: *std.ArrayList(u8),
indent: []const u8,
marker: []const u8,
text: []const u8,
) !void {
const body = if (std.mem.endsWith(u8, text, "\n")) text[0 .. text.len - 1] else text;
var it = std.mem.splitScalar(u8, body, '\n');
while (it.next()) |line| {
try out.appendSlice(allocator, indent);
try out.appendSlice(allocator, marker);
if (line.len > 0) {
try out.append(allocator, ' ');
try out.appendSlice(allocator, line);
}
try out.append(allocator, '\n');
}
}
/// A relocatable entry block: a byte range `[start, end)` covering one mapping
/// entry or sequence item (its owned comment block through its last line).
pub const Block = struct { start: usize, end: usize };
/// Fill each block's `end` from the next block's `start` so the blocks tile a
/// contiguous region; the final block runs to `last_end`. Trailing trivia (a
/// blank line, an orphan comment) thus rides with the preceding entry.
pub fn tileBlocks(blocks: []Block, last_end: usize) void {
for (blocks, 0..) |*b, i| {
b.end = if (i + 1 < blocks.len) blocks[i + 1].start else last_end;
}
}
/// Build a full permutation of `0..n`: the valid, de-duplicated indices in
/// `order` first (in the given order), then every remaining index in ascending
/// (original) order. Caller owns the returned slice. An empty `order` yields
/// the identity, so a reorder with nothing to bring forward is a no-op.
pub fn fullOrder(allocator: std.mem.Allocator, order: []const usize, n: usize) ![]usize {
const result = try allocator.alloc(usize, n);
errdefer allocator.free(result);
const used = try allocator.alloc(bool, n);
defer allocator.free(used);
@memset(used, false);
var k: usize = 0;
for (order) |idx| {
if (idx < n and !used[idx]) {
result[k] = idx;
used[idx] = true;
k += 1;
}
}
for (0..n) |i| {
if (!used[i]) {
result[k] = i;
k += 1;
}
}
return result;
}
/// Whether a node is a leaf scalar — the kinds `setSequence` can match by value.
fn isScalarKind(kind: AST.Node.Kind) bool {
return switch (kind) {
.null_, .boolean, .string, .number, .extended => true,
.sequence, .mapping, .keyvalue, .alias => false,
};
}
/// Count the children of a container node.
fn seqLen(parsed: Document, node: AST.Node) !usize {
var n: usize = 0;
var maybe = try parsed.ast.child(&node);
while (maybe) |c| {
n += 1;
maybe = parsed.ast.next(&c);
}
return n;
}
/// Drop a single trailing '\n' (the serializer ends every value with one).
fn stripTrailingNewline(text: []const u8) []const u8 {
if (text.len > 0 and text[text.len - 1] == '\n') return text[0 .. text.len - 1];
return text;
}
/// Append `value_text` to `out`, re-indented so it sits at column `indent`.
/// The first line is emitted verbatim (it follows `key: ` or `- `); every
/// subsequent non-blank line is prefixed with `indent` spaces, preserving the
/// serializer's own relative indentation. One trailing '\n' is stripped.
fn reindentInto(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value_text: []const u8, indent: usize) !void {
const text = stripTrailingNewline(value_text);
var it = std.mem.splitScalar(u8, text, '\n');
var first = true;
while (it.next()) |line| {
if (!first) {
try out.append(allocator, '\n');
if (line.len > 0) try out.appendNTimes(allocator, ' ', indent);
}
try out.appendSlice(allocator, line);
first = false;
}
}
// ── Comment-editing tests ──────────────────────────────────────────────────
const testing = std.testing;
fn expectCommentEdit(
comptime Lang: type,
format: Lang.Type,
input: []const u8,
expected: []const u8,
op: enum { leading, trailing },
path: []const AST.PathSegment,
text: []const u8,
) !void {
var ed: Editor(Lang) = .{ .allocator = testing.allocator, .format = format };
try ed.init(input);
defer ed.deinit();
switch (op) {
.leading => try ed.addLeadingComment(path, text),
.trailing => try ed.setTrailingComment(path, text),
}
try testing.expectEqualStrings(expected, ed.source.items);
}
test "addLeadingComment inserts an own-line comment above a YAML key" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectCommentEdit(Yaml, .v1_2_2, "a: 1\nb: 2\n", "a: 1\n# note\nb: 2\n", .leading, &.{.{ .key = "b" }}, "note");
}
test "addLeadingComment matches indentation and lands nearest the key" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// Nested key: comment takes the key's 2-space indent and sits just above it,
// below the pre-existing comment.
try expectCommentEdit(
Yaml,
.v1_2_2,
"outer:\n # kept\n inner: 1\n",
"outer:\n # kept\n # new\n inner: 1\n",
.leading,
&.{ .{ .key = "outer" }, .{ .key = "inner" } },
"new",
);
}
test "setTrailingComment appends and then replaces a YAML same-line comment" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectCommentEdit(Yaml, .v1_2_2, "a: 1\n", "a: 1 # done\n", .trailing, &.{.{ .key = "a" }}, "done");
// Re-setting replaces the existing trailing comment rather than nesting it.
try expectCommentEdit(Yaml, .v1_2_2, "a: 1 # old\n", "a: 1 # new\n", .trailing, &.{.{ .key = "a" }}, "new");
}
test "addLeadingComment on TOML uses #" {
if (comptime !build_options.lang_toml) return error.SkipZigTest;
try expectCommentEdit(Toml, .TOML_1_1, "a = 1\nb = 2\n", "a = 1\n# note\nb = 2\n", .leading, &.{.{ .key = "b" }}, "note");
}
// This instantiation (plus every `Editor(Fig)` call below) is what pulls
// `Editor(Fig)`'s own methods into the test build's reachability graph — `zig
// test` analyzes a generic struct's methods only once something calls them, so
// an `Editor(Fig)` that nothing instantiates is an `Editor(Fig)` nothing
// type-checks. (`fig/editor_helper.zig`'s `test` blocks are discovered through
// `fig.zig`'s own `test {}`, and carry the rest of `Editor(Fig)`'s coverage
// rather than duplicating it here; the same split holds for TOML above.)
test "addLeadingComment on fig uses # at the target's own marker depth" {
if (comptime !build_options.lang_fig) return error.SkipZigTest;
try expectCommentEdit(Fig, .Fig, "a = 1\nb = 2\n", "a = 1\n# note\nb = 2\n", .leading, &.{.{ .key = "b" }}, "note");
try expectCommentEdit(
Fig,
.Fig,
"database\n> pool\n> > size = 10\n",
"database\n> pool\n> > # note\n> > size = 10\n",
.leading,
&.{ .{ .key = "database" }, .{ .key = "pool" }, .{ .key = "size" } },
"note",
);
}
test "comment ops on JSONC use // and respect indentation" {
try expectCommentEdit(
json.Language,
.JSONC,
"{\n \"a\": 1\n}",
"{\n // note\n \"a\": 1\n}",
.leading,
&.{.{ .key = "a" }},
"note",
);
}
test "set inserts into a pretty-printed JSON object on its own line, indented" {
var ed: Editor(json.Language) = .{ .allocator = testing.allocator, .format = .JSON };
try ed.init("{\n \"a\": 1,\n \"b\": 2\n}");
defer ed.deinit();
try ed.set(&.{.{ .key = "c" }}, "3");
try testing.expectEqualStrings("{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}", ed.source.items);
}
test "set keeps compact single-line JSON objects inline" {
var ed: Editor(json.Language) = .{ .allocator = testing.allocator, .format = .JSON };
try ed.init("{\"a\": 1, \"b\": 2}");
defer ed.deinit();
try ed.set(&.{.{ .key = "c" }}, "3");
try testing.expectEqualStrings("{\"a\": 1, \"b\": 2, \"c\": 3}", ed.source.items);
}
test "comment ops are rejected for strict JSON" {
var ed: Editor(json.Language) = .{ .allocator = testing.allocator, .format = .JSON };
try ed.init("{\"a\":1}");
defer ed.deinit();
try testing.expectError(error.CommentsUnsupported, ed.addLeadingComment(&.{.{ .key = "a" }}, "x"));
try testing.expectError(error.CommentsUnsupported, ed.setTrailingComment(&.{.{ .key = "a" }}, "x"));
}
test "multi-line leading comment becomes one line per row" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectCommentEdit(Yaml, .v1_2_2, "a: 1\n", "# one\n# two\na: 1\n", .leading, &.{.{ .key = "a" }}, "one\ntwo");
}
test "setTrailingComment rejects a multi-line comment" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("a: 1\n");
defer ed.deinit();
try testing.expectError(error.MultilineComment, ed.setTrailingComment(&.{.{ .key = "a" }}, "x\ny"));
}
fn expectCommentDelete(
comptime Lang: type,
format: Lang.Type,
input: []const u8,
expected: []const u8,
op: enum { leading, trailing },
path: []const AST.PathSegment,
) !void {
var ed: Editor(Lang) = .{ .allocator = testing.allocator, .format = format };
try ed.init(input);
defer ed.deinit();
switch (op) {
.leading => try ed.deleteLeadingComments(path),
.trailing => try ed.deleteTrailingComment(path),
}
try testing.expectEqualStrings(expected, ed.source.items);
}
test "deleteLeadingComments removes the owned block above a YAML key" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// Only the block touching the key goes; a blank line breaks ownership, so the
// earlier comment (above the blank) stays.
try expectCommentDelete(
Yaml,
.v1_2_2,
"# top\n\n# a\n# b\nkey: 1\n",
"# top\n\nkey: 1\n",
.leading,
&.{.{ .key = "key" }},
);
// No leading comment → no-op.
try expectCommentDelete(Yaml, .v1_2_2, "key: 1\n", "key: 1\n", .leading, &.{.{ .key = "key" }});
}
test "deleteTrailingComment removes a same-line comment (YAML/JSONC), else no-op" {
if (comptime build_options.lang_yaml)
try expectCommentDelete(Yaml, .v1_2_2, "a: 1 # gone\nb: 2\n", "a: 1\nb: 2\n", .trailing, &.{.{ .key = "a" }});
// No trailing comment → no-op.
if (comptime build_options.lang_yaml)
try expectCommentDelete(Yaml, .v1_2_2, "a: 1\n", "a: 1\n", .trailing, &.{.{ .key = "a" }});
// JSONC `//` trailing.
try expectCommentDelete(json.Language, .JSONC, "{\n \"a\": 1 // x\n}", "{\n \"a\": 1\n}", .trailing, &.{.{ .key = "a" }});
}
test "ZON owned-comment scan uses // (comments.style fix)" {
if (comptime !build_options.lang_zon) return error.SkipZigTest;
try expectCommentDelete(
Zon,
.ZON,
".{\n // note\n .n = 3,\n}\n",
".{\n .n = 3,\n}\n",
.leading,
&.{.{ .key = "n" }},
);
}
test "comment delete ops are rejected for strict JSON" {
var ed: Editor(json.Language) = .{ .allocator = testing.allocator, .format = .JSON };
try ed.init("{\"a\":1}");
defer ed.deinit();
try testing.expectError(error.CommentsUnsupported, ed.deleteLeadingComments(&.{.{ .key = "a" }}));
try testing.expectError(error.CommentsUnsupported, ed.deleteTrailingComment(&.{.{ .key = "a" }}));
}
fn expectCommentGet(
comptime Lang: type,
format: Lang.Type,
input: []const u8,
/// `null` asserts the comment is ABSENT; a string asserts it is present with
/// exactly those bytes (`""` = a present-but-empty bare marker).
expected: ?[]const u8,
op: enum { leading, trailing },
path: []const AST.PathSegment,
) !void {
var ed: Editor(Lang) = .{ .allocator = testing.allocator, .format = format };
try ed.init(input);
defer ed.deinit();
const got = switch (op) {
.leading => try ed.getLeadingComment(path),
.trailing => try ed.getTrailingComment(path),
};
defer if (got) |g| testing.allocator.free(g);
if (expected) |want| {
try testing.expect(got != null);
try testing.expectEqualStrings(want, got.?);
} else {
try testing.expect(got == null);
}
}
test "getLeadingComment returns the owned block above a key, markers stripped" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectCommentGet(Yaml, .v1_2_2, "# one\n# two\na: 1\n", "one\ntwo", .leading, &.{.{ .key = "a" }});
// No block above → absent (null).
try expectCommentGet(Yaml, .v1_2_2, "a: 1\nb: 2\n", null, .leading, &.{.{ .key = "b" }});
}
test "getTrailingComment returns the same-line comment, marker stripped" {
if (comptime build_options.lang_yaml) {
try expectCommentGet(Yaml, .v1_2_2, "a: 1 # done\n", "done", .trailing, &.{.{ .key = "a" }});
// No trailing comment → absent (null).
try expectCommentGet(Yaml, .v1_2_2, "a: 1\n", null, .trailing, &.{.{ .key = "a" }});
}
// JSONC `//` trailing.
try expectCommentGet(json.Language, .JSONC, "{\n \"a\": 1 // x\n}", "x", .trailing, &.{.{ .key = "a" }});
}
test "trailing comment on a block-collection key rides the key line" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
const seq = "contents: # note\n- one\n- two\n";
// get: the comment after the colon on the key's line, not after the last item.
try expectCommentGet(Yaml, .v1_2_2, seq, "note", .trailing, &.{.{ .key = "contents" }});
// set: replaces the key-line comment in place (does not append after `two`).
try expectCommentEdit(Yaml, .v1_2_2, seq, "contents: # new\n- one\n- two\n", .trailing, &.{.{ .key = "contents" }}, "new");
// set on a block key with no existing comment lands on the key line.
try expectCommentEdit(Yaml, .v1_2_2, "k:\n- a\n- b\n", "k: # added\n- a\n- b\n", .trailing, &.{.{ .key = "k" }}, "added");
// delete: removes the key-line comment.
try expectCommentDelete(Yaml, .v1_2_2, seq, "contents:\n- one\n- two\n", .trailing, &.{.{ .key = "contents" }});
}
test "trailing comment on a parent key ignores a child's same-line comment" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// The `# bc` belongs to child `b`; the parent `a` has no trailing comment.
try expectCommentGet(Yaml, .v1_2_2, "a:\n b: 1 # bc\n", null, .trailing, &.{.{ .key = "a" }});
try expectCommentGet(Yaml, .v1_2_2, "a:\n b: 1 # bc\n", "bc", .trailing, &.{ .{ .key = "a" }, .{ .key = "b" } });
}
test "getLeadingComment round-trips an empty comment line" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// A bare `#` (no text) decodes to an empty line within the block.
try expectCommentGet(Yaml, .v1_2_2, "# one\n#\n# three\na: 1\n", "one\n\nthree", .leading, &.{.{ .key = "a" }});
}
test "get distinguishes a present-but-empty comment from an absent one" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// A bare `#` is PRESENT with empty text → "" (not null).
try expectCommentGet(Yaml, .v1_2_2, "a: 1 #\n", "", .trailing, &.{.{ .key = "a" }});
try expectCommentGet(Yaml, .v1_2_2, "#\na: 1\n", "", .leading, &.{.{ .key = "a" }});
// No marker at all → absent (null).
try expectCommentGet(Yaml, .v1_2_2, "a: 1\n", null, .trailing, &.{.{ .key = "a" }});
}
test "get comment ops are rejected for strict JSON" {
var ed: Editor(json.Language) = .{ .allocator = testing.allocator, .format = .JSON };
try ed.init("{\"a\":1}");
defer ed.deinit();
try testing.expectError(error.CommentsUnsupported, ed.getLeadingComment(&.{.{ .key = "a" }}));
try testing.expectError(error.CommentsUnsupported, ed.getTrailingComment(&.{.{ .key = "a" }}));
}
// ── set (upsert) tests ──────────────────────────────────────────────────────
fn expectSet(
comptime Lang: type,
format: Lang.Type,
input: []const u8,
path: []const AST.PathSegment,
value: []const u8,
expected: []const u8,
) !void {
var ed: Editor(Lang) = .{ .allocator = testing.allocator, .format = format };
try ed.init(input);
defer ed.deinit();
try ed.set(path, value);
try testing.expectEqualStrings(expected, ed.source.items);
}
test "set replaces an existing YAML value" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectSet(Yaml, .v1_2_2, "a: 1\nb: 2\n", &.{.{ .key = "a" }}, "9", "a: 9\nb: 2\n");
}
test "set inserts a missing top-level YAML key" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectSet(Yaml, .v1_2_2, "a: 1\n", &.{.{ .key = "b" }}, "2", "a: 1\nb: 2\n");
}
test "set inserts a missing nested key under an existing mapping" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
try expectSet(
Yaml,
.v1_2_2,
"outer:\n inner: 1\n",
&.{ .{ .key = "outer" }, .{ .key = "added" } },
"2",
"outer:\n inner: 1\n added: 2\n",
);
}
test "set reframes a YAML value inline->block on replace" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// Inherits replaceValAtPath's reframing: a scalar becomes a block list
// (fig writes indentless block sequences under a key).
try expectSet(Yaml, .v1_2_2, "a: 1\n", &.{.{ .key = "a" }}, "- x\n- y", "a:\n- x\n- y\n");
}
test "set replaces an existing JSON value (replace branch is format-agnostic)" {
// The replace branch matches keys logically, so it works for strict JSON.
try expectSet(json.Language, .JSON, "{\"a\": 1}", &.{.{ .key = "a" }}, "\"x\"", "{\"a\": \"x\"}");
}
test "set creates a new JSON key, quoting it for the format" {
// The insert branch renders the logical key into JSON syntax (`b` -> `"b"`),
// so creating a not-yet-present key produces valid JSON.
try expectSet(json.Language, .JSON, "{\"a\": 1}", &.{.{ .key = "b" }}, "2", "{\"a\": 1, \"b\": 2}");
// A key needing escaping is escaped, not spliced raw.
try expectSet(json.Language, .JSON, "{}", &.{.{ .key = "a\"b" }}, "1", "{\"a\\\"b\": 1}");
}
test "set rejects a path that does not end in a key" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("a:\n - 1\n");
defer ed.deinit();
try testing.expectError(error.NotAMapping, ed.set(&.{ .{ .key = "a" }, .{ .index = 0 } }, "9"));
try testing.expectError(error.NotAMapping, ed.set(&.{}, "9"));
}
test "set auto-vivifies missing intermediate containers" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("a: 1\n");
defer ed.deinit();
// Parent `missing` does not exist: `set` seeds it as an empty map, then
// lands the leaf. The existing `a: 1` is untouched.
try ed.set(&.{ .{ .key = "missing" }, .{ .key = "leaf" } }, "2");
try testing.expect(std.mem.indexOf(u8, ed.source.items, "a: 1") != null);
const leaf = try ed.getParsed();
const v = try leaf.ast.getValByPath(&.{ .{ .key = "missing" }, .{ .key = "leaf" } });
try testing.expectEqualStrings("2", v.kind.number.raw);
}
test "set vivifies a nested path through an empty node" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// A null is a container waiting to exist, not data — so navigation failing
// through one is vivifiable, unlike failing through a scalar (next test).
// An empty document's root:
try expectSet(Yaml, .v1_2_2, "", &.{ .{ .key = "a" }, .{ .key = "b" } }, "1", "a:\n b: 1\n");
try expectSet(
Yaml,
.v1_2_2,
"",
&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } },
"1",
"a:\n b:\n c: 1\n",
);
// And a bare `key:` standing where an intermediate mapping should be.
try expectSet(
Yaml,
.v1_2_2,
"title: t\na:\n",
&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } },
"1",
"title: t\na:\n b:\n c: 1\n",
);
}
test "set does not clobber a scalar standing where a parent map should be" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("a: 1\n");
defer ed.deinit();
// `a` is a scalar, not a map: descending into it for `b` is a real type
// error (`NotAMapping`), not a missing key to vivify — `a: 1` stays intact.
try testing.expectError(error.NotAMapping, ed.set(&.{ .{ .key = "a" }, .{ .key = "b" } }, "2"));
try testing.expectEqualStrings("a: 1\n", ed.source.items);
}
test "set lands a single-entry mapping value like any other mapping value" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// A one-entry map renders `k: v` — the shape a scalar has — so every splice
// path used to read it as one and fail. All three branches of `set` (insert,
// nested insert, replace) must treat it as the block mapping it is, exactly
// as they already treated a two-entry map.
try expectSet(Yaml, .v1_2_2, "a: 1\n", &.{.{ .key = "fresh" }}, "k: v\n", "a: 1\nfresh:\n k: v\n");
try expectSet(
Yaml,
.v1_2_2,
"outer:\n inner: 1\n",
&.{ .{ .key = "outer" }, .{ .key = "added" } },
"k: v\n",
"outer:\n inner: 1\n added:\n k: v\n",
);
try expectSet(Yaml, .v1_2_2, "a: 1\n", &.{.{ .key = "a" }}, "k: v\n", "a:\n k: v\n");
}
test "set rolls back a vivified ancestor when the leaf insert fails" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("title: t\n");
defer ed.deinit();
// Malformed value text: `a` is seeded, then the leaf insert's reparse
// rejects it. Two splices, one outcome — `Err` has to mean the document is
// untouched, or a caller that retries is editing a document it never asked
// for. Without the rollback the seeded `a:` would survive.
try testing.expectError(
error.UnexpectedToken,
ed.set(&.{ .{ .key = "a" }, .{ .key = "b" } }, "[unclosed"),
);
try testing.expectEqualStrings("title: t\n", ed.source.items);
}
test "set reports the flow container, not a missing key, when a block value can't land" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
var ed: Editor(Yaml) = .{ .allocator = testing.allocator, .format = .v1_2_2 };
try ed.init("a: {b: 1}\n");
defer ed.deinit();
// The parent exists and IS a mapping — just a flow one. Falling back to the
// replace branch's `NotFound` would send the caller after a key that isn't
// the problem.
try testing.expectError(
error.BlockValueIntoFlow,
ed.set(&.{ .{ .key = "a" }, .{ .key = "c" } }, "- q\n"),
);
try testing.expectEqualStrings("a: {b: 1}\n", ed.source.items);
}
test "yaml set vivifies a fresh nested path as BLOCK containers" {
if (comptime !build_options.lang_yaml) return error.SkipZigTest;
// YAML seeds missing ancestors as bare `key:` (null), which `insertKey`
// promotes to a real block mapping — so a fresh nested path reads like
// hand-written YAML instead of a flow chain (`a: {b: {c: 1}}`).
try expectSet(
Yaml,
.v1_2_2,
"title: t\n",
&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } },
"1",
"title: t\na:\n b:\n c: 1\n",
);
// And the payoff: a BLOCK value now lands at a fresh nested path, which no
// flow seed could ever hold.
try expectSet(
Yaml,
.v1_2_2,
"title: t\n",
&.{ .{ .key = "a" }, .{ .key = "b" } },
"- q\n",
"title: t\na:\n b:\n - q\n",
);
try expectSet(
Yaml,
.v1_2_2,
"title: t\n",
&.{ .{ .key = "a" }, .{ .key = "b" } },
"k: v\n",
"title: t\na:\n b:\n k: v\n",
);
// The seeded line carries no trailing whitespace.
try testing.expect(std.mem.indexOf(u8, "title: t\na:\n b:\n c: 1\n", " \n") == null);
}
test "non-YAML formats keep vivifying through flow seeds" {
// The empty seed is YAML-specific: `a =` is not a TOML value, and the flow
// chain is the idiomatic intermediate form for the dotted-key formats.
if (comptime build_options.lang_toml) {
try expectSet(
Toml,
.TOML_1_0,
"title = 't'\n",
&.{ .{ .key = "a" }, .{ .key = "b" } },
"1",
"title = 't'\na = { b = 1 }\n",
);
}
try expectSet(
json.Language,
.JSON,
"{\"t\": 1}",
&.{ .{ .key = "a" }, .{ .key = "b" } },
"2",
"{\"t\": 1, \"a\": {\"b\": 2}}",
);
}
test "fig set vivifies a nested path from an empty document" {
if (comptime !build_options.lang_fig) return error.SkipZigTest;
var ed: Editor(Fig) = .{ .allocator = testing.allocator, .format = .Fig };
try ed.init(""); // empty fig doc = empty root map (seedable from scratch)
defer ed.deinit();
try ed.set(&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } }, "hi");
// The seeded parents nest as flow maps; the leaf reads back through the path.
const parsed = try ed.getParsed();
const v = try parsed.ast.getValByPath(&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } });
try testing.expectEqualStrings("hi", v.kind.string);
}
// ── dotenv / .properties (flat `KEY=value`) ─────────────────────────────────
//
// Both are flat-only (root mapping, no nesting/sequences), so unlike
// YAML/TOML/fig their root is never `.null_` — even a totally empty file
// parses as an empty (childless) `.mapping`. That's exactly the case
// `insertBlockKey` used to `.?`-unwrap into a panic on (see its comment);
// these tests exercise that path directly via `set`'s from-empty seed, which
// is also how the CLI's `set` on a freshly created file behaves.
test "dotenv set seeds the first key into an empty document" {
if (comptime !build_options.lang_dotenv) return error.SkipZigTest;
try expectSet(Dotenv, .DOTENV, "", &.{.{ .key = "FOO" }}, "bar", "FOO=bar\n");
}
test "dotenv set inserts a second key using '=', no spaces" {
if (comptime !build_options.lang_dotenv) return error.SkipZigTest;
try expectSet(Dotenv, .DOTENV, "FOO=bar\n", &.{.{ .key = "BAZ" }}, "qux", "FOO=bar\nBAZ=qux\n");
}
test "dotenv set replaces an existing value" {
if (comptime !build_options.lang_dotenv) return error.SkipZigTest;
try expectSet(Dotenv, .DOTENV, "FOO=bar\n", &.{.{ .key = "FOO" }}, "baz", "FOO=baz\n");
}
test "dotenv deleteKey removes the only entry, leaving an empty file" {
if (comptime !build_options.lang_dotenv) return error.SkipZigTest;
var ed: Editor(Dotenv) = .{ .allocator = testing.allocator, .format = .DOTENV };
try ed.init("FOO=bar\n");
defer ed.deinit();
try ed.deleteKey(&.{.{ .key = "FOO" }});
try testing.expectEqualStrings("", ed.source.items);
// Deleting down to empty round-trips back through the same from-empty
// insert path a from-scratch `set` would use.
try ed.set(&.{.{ .key = "AGAIN" }}, "v2");
try testing.expectEqualStrings("AGAIN=v2\n", ed.source.items);
}
test "dotenv comment ops use # and round-trip" {
if (comptime !build_options.lang_dotenv) return error.SkipZigTest;
var ed: Editor(Dotenv) = .{ .allocator = testing.allocator, .format = .DOTENV };
try ed.init("FOO=bar\n");
defer ed.deinit();
try ed.addLeadingComment(&.{.{ .key = "FOO" }}, "explaining foo");
try ed.setTrailingComment(&.{.{ .key = "FOO" }}, "inline note");
try testing.expectEqualStrings("# explaining foo\nFOO=bar # inline note\n", ed.source.items);
const leading = (try ed.getLeadingComment(&.{.{ .key = "FOO" }})).?;
defer testing.allocator.free(leading);
try testing.expectEqualStrings("explaining foo", leading);
const trailing = (try ed.getTrailingComment(&.{.{ .key = "FOO" }})).?;
defer testing.allocator.free(trailing);
try testing.expectEqualStrings("inline note", trailing);
}
test "properties set seeds the first key into an empty document" {
if (comptime !build_options.lang_properties) return error.SkipZigTest;
try expectSet(Properties, .PROPERTIES, "", &.{.{ .key = "foo" }}, "bar", "foo=bar\n");
}
test "properties set inserts a second key using '=', no spaces" {
if (comptime !build_options.lang_properties) return error.SkipZigTest;
try expectSet(Properties, .PROPERTIES, "foo=bar\n", &.{.{ .key = "baz" }}, "qux", "foo=bar\nbaz=qux\n");
}
test "properties deleteKey removes the only entry, leaving an empty file" {
if (comptime !build_options.lang_properties) return error.SkipZigTest;
var ed: Editor(Properties) = .{ .allocator = testing.allocator, .format = .PROPERTIES };
try ed.init("foo=bar\n");
defer ed.deinit();
try ed.deleteKey(&.{.{ .key = "foo" }});
try testing.expectEqualStrings("", ed.source.items);
try ed.set(&.{.{ .key = "again" }}, "v2");
try testing.expectEqualStrings("again=v2\n", ed.source.items);
}
// ── INI (`[section]` + flat `key = value`) ──────────────────────────────────
//
// Basic root-level/parameter sanity checks only — the same level of coverage
// TOML/fig/ZON leave here (one "uses the right marker" test apiece). The
// section-nesting behavior (`iniInsertKey`/`isSectionHeaderLine`,
// the `set` auto-vivify exclusion) is exercised in `ini/editor_helper.zig`,
// next to that logic.
test "ini set seeds the first root key into an empty document" {
if (comptime !build_options.lang_ini) return error.SkipZigTest;
try expectSet(Ini, .INI, "", &.{.{ .key = "name" }}, "fig", "name = fig\n");
}
test "ini set inserts a second root key using ' = '" {
if (comptime !build_options.lang_ini) return error.SkipZigTest;
try expectSet(Ini, .INI, "name = fig\n", &.{.{ .key = "lang" }}, "zig", "name = fig\nlang = zig\n");
}
test "ini comment ops: leading works with ';', trailing is unsupported" {
if (comptime !build_options.lang_ini) return error.SkipZigTest;
var ed: Editor(Ini) = .{ .allocator = testing.allocator, .format = .INI };
try ed.init("name = fig\n");
defer ed.deinit();
try ed.addLeadingComment(&.{.{ .key = "name" }}, "a language");
try testing.expectEqualStrings("; a language\nname = fig\n", ed.source.items);
const leading = (try ed.getLeadingComment(&.{.{ .key = "name" }})).?;
defer testing.allocator.free(leading);
try testing.expectEqualStrings("a language", leading);
try testing.expectError(error.CommentsUnsupported, ed.setTrailingComment(&.{.{ .key = "name" }}, "note"));
try testing.expectError(error.CommentsUnsupported, ed.getTrailingComment(&.{.{ .key = "name" }}));
try testing.expectError(error.CommentsUnsupported, ed.deleteTrailingComment(&.{.{ .key = "name" }}));
}