//! 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);

// Format-specific editing logic the generic engine delegates to from its
// `if (Language == Toml/Yaml/Fig)` branches: TOML's multi-region gather +
// whole-table ops, YAML's reference-layer / block-framing helpers, and fig's
// marker-prefix-copying block/flow insert + append/prepend. See each module's
// header for the split rationale. These modules also hold that language's editor
// tests, so editor-test code lives next to the concern it exercises.
const toml_edit = @import("languages/toml/editor_helper.zig");
const yaml_edit = @import("languages/yaml/editor_helper.zig");
const fig_edit = @import("languages/fig/editor_helper.zig");
const zon_edit = @import("languages/zon/editor_helper.zig");
// Language tags used by the comptime branches above.
const Toml = @import("languages/toml/toml.zig").Language;
const Yaml = @import("languages/yaml/yaml.zig").Language;
const Fig = @import("languages/fig/fig.zig").Language;
// ZON's editing needs are small parameter swaps (comment marker, key/value
// separator, key-quoting rule, container shape) rather than the structural
// logic TOML/YAML/Fig delegate out — so, like JSON, it has no dedicated
// per-op helpers here; only `zon_edit.appendZonFieldName` (the ZON dotted-key
// quoting rule, shared with the printer's) lives in its editor_helper module.
const Zon = @import("languages/zon/zon.zig").Language;

pub fn Editor(comptime Language: type) type {
    @import("languages/language.zig").validate(Language);
    return struct {
        const Self = @This();

        // Which leading-comment syntax this language uses, so the owned-comment
        // scan in delete/move (`commentBlockStart`) recognizes the right marker.
        // JSON/JSONC/JSON5 and ZON use `//` (ZON, like Zig); YAML and TOML use
        // `#`. Plain JSON has no comments, but `.slashes` is harmless there since
        // no `//` line can exist.
        const comment_style: CommentStyle = if (Language == json.Language or Language == Zon) .slashes else .hash;

        // The mapping key/value separator spliced by the generic flow-entry
        // insert helpers (`insertFlowMapEntry`/`insertFlowEntry`). Every editable
        // format but ZON writes `key: value`; ZON's struct-field syntax is
        // `.key = value`.
        const kv_sep: []const u8 = if (Language == Zon) " = " else ": ";

        allocator: std.mem.Allocator,
        source: std.ArrayList(u8) = .empty,
        document: ?Document = null,
        format: Language.Type = Language.default_type,

        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);

            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);
                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.
        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| {
                // A merge-only key surfaces as NotFound (default nav doesn't follow
                // `<<`); COW it by inserting a local `key: value` that shadows the
                // merge.
                if (Language == Yaml and err == error.NotFound and try yaml_edit.mergeSuppliesKey(parsed, path)) {
                    try self.insertKey(path[0 .. path.len - 1], path[path.len - 1].key, replacement);
                    return;
                }
                return err;
            };
            const span = parsed.span(node);
            // For a YAML mapping value, reframe the whole `: value` so the new
            // value is correctly shaped whatever its form — a scalar stays
            // inline, a block collection descends onto the following lines —
            // rather than splicing into the old value's slot, which can't
            // change inline<->block (e.g. `k: []` -> a block list). JSON has no
            // block style, so it keeps the direct splice.
            if (Language == Yaml and path.len > 0 and std.meta.activeTag(path[path.len - 1]) == .key) {
                try yaml_edit.reframeMappingValue(self, parsed, path, span, replacement);
                return;
            }
            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 only
        /// when an intermediate KEY is genuinely absent (`NotFound`); a segment
        /// that resolves to a non-map scalar is a real type error (`NotAMapping`)
        /// and is never clobbered. Seeds are flow `{}`; for the dotted-key formats
        /// (fig/TOML) `fig fmt` canonicalizes the resulting `a = { b = { c = v }}`
        /// chain to `a.b.c = v`.
        ///
        /// 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| {
                    // 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.
                    if (path.len >= 2 and insert_err == error.NotFound) {
                        try self.set(path[0 .. path.len - 1], emptyMapLiteral());
                        try self.insertKey(path[0 .. path.len - 1], key_text, value_text);
                    } else return replace_err;
                };
            };
        }

        /// The empty-map value literal `set` splices to auto-vivify a missing
        /// parent. Every editable format accepts a flow `{}` as an empty mapping
        /// value (fig/YAML flow map, JSON object, TOML inline table); ZON spells
        /// it `.{}`. Reparse-validated at the splice site like any other value.
        fn emptyMapLiteral() []const u8 {
            if (comptime Language == Zon) return ".{}";
            return "{}";
        }

        /// Render a logical mapping key into this format's key syntax for the
        /// `set` insert branch. Strict-JSON-family keys must be quoted and escaped
        /// (`b` → `"b"`); YAML/TOML splice the key verbatim, as `insertKey`'s other
        /// callers do. ZON's struct-field syntax always carries a leading `.`
        /// (`b` → `.b`, quoted as `.@"has space"` when not a bare identifier).
        /// Always returns an owned slice (the caller frees it).
        fn formatInsertKey(self: *Self, key: []const u8) ![]u8 {
            if (comptime Language == json.Language) {
                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());
            }
            if (comptime Language == Zon) {
                var out: std.ArrayList(u8) = .empty;
                defer out.deinit(self.allocator);
                try zon_edit.appendFieldName(&out, self.allocator, key);
                return out.toOwnedSlice(self.allocator);
            }
            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`.
        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 (Language == Yaml and node.kind == .alias) {
                const target = parsed.ast.nodes[try parsed.ast.resolveAlias(node)];
                try self.replaceAtSpan(yaml_edit.valueSpanWithoutProps(self, parsed, target), replacement);
                return;
            }
            try self.replaceValAtPath(path, replacement);
        }

        pub fn replaceKeyAtPath(self: *Self, path: []const AST.PathSegment, replacement: []const u8) !void {
            const parsed = try self.getParsed();
            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).

        /// The line-comment marker for this language/dialect, or null when the
        /// dialect forbids comments (strict JSON). `self.format` distinguishes
        /// strict JSON from JSONC/JSON5, which the marker choice must honor since
        /// the splice is reparsed under that same dialect.
        fn lineCommentMarker(self: *const Self) ?[]const u8 {
            if (comptime Language == json.Language) {
                // `//` is valid in JSONC/JSON5 but not strict JSON.
                return if (self.format == .JSON) null else "//";
            }
            // ZON uses Zig's `//`; YAML and TOML use `#`.
            if (comptime Language == Zon) return "//";
            return "#";
        }

        /// 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 {
            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 = lineStartBefore(source, span.start);
            // fig's `#`-only comment line needs the same `>` marker-run prefix
            // as the line it anchors above (comment depth is load-bearing for
            // attachment — DESIGN.md "Comments") — `firstNonSpace` would stop
            // at the `>` and yield bare whitespace, dropping the markers
            // entirely. `span.start` already sits right after that prefix for
            // every fig node (see `TNode.span`'s doc comment in
            // `fig/parser.zig`), so slicing back to the line start recovers it
            // exactly. Every other language's prefix is pure whitespace, where
            // `firstNonSpace` and `span.start` agree anyway.
            const indent = if (Language == Fig) 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 {
            const marker = self.lineCommentMarker() 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 {
            _ = 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 = lineStartBefore(source, span.start);
            const block_start = commentBlockStart(source, line_start, comment_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 {
            const marker = self.lineCommentMarker() 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 {
            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 = lineStartBefore(source, span.start);
            const block_start = commentBlockStart(source, line_start, comment_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 {
            const marker = self.lineCommentMarker() 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.
        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 (Language == Toml)
                return toml_edit.tomlInsertKey(self, parsed, node, span, path.len == 0, key_text, value_text);
            if (Language == Fig)
                return fig_edit.figInsertKey(self, parsed, node, span, path.len == 0, 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). 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.
        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 that exists only via a `<<` merge has no physical line to
                // delete, and there is no YAML syntax to un-inherit it — deleting
                // the merge source is a different operation. Refuse explicitly.
                if (Language == Yaml and err == error.NotFound and try yaml_edit.mergeSuppliesKey(parsed, path))
                    return error.MergeOnlyKey;
                return err;
            };
            if (node.kind != .keyvalue) return error.NotAMapping;
            const span = parsed.span(node);
            const source = self.source.items;
            // A TOML `[header]` table or `[[array]]` element has no contiguous
            // line span (its body is assembled from scattered headers), so a
            // line-based delete would only remove the header key. Refuse it; only
            // scalar/array/inline-table/dotted entries delete cleanly. (Detected
            // by the entry's line starting with `[`, which a normal `key = value`
            // never does.)
            if (Language == Toml) {
                const fns = firstNonSpace(source, lineStartBefore(source, span.start));
                if (fns < source.len and source[fns] == '[') return error.CannotDeleteTable;
            }
            // A fig block (non-flow) mapping/sequence value may be a
            // re-entered/scattered container (DESIGN.md "Re-entering a path
            // to add new keys is fine") — TOML's `CannotDeleteTable` twin,
            // refusing rather than risk swallowing an interleaved foreign
            // sibling on a scattered container's line-based delete. A flow
            // (`{…}`/`[…]`) value is always tightly contiguous, so it deletes
            // normally below.
            if (Language == Fig) {
                const val = parsed.ast.nodes[node.kind.keyvalue.value];
                if ((val.kind == .mapping or val.kind == .sequence) and !isFlow(source, parsed.span(val)))
                    return error.CannotDeleteContainer;
            }
            const line_start = lineStartBefore(source, span.start);
            const del_start = commentBlockStart(source, line_start, comment_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`.
        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
            // `appendTableToArray` for those. (TOML has no block scalar array.)
            if (Language == Toml) return error.NotAnInlineArray;
            if (Language == Fig) return fig_edit.figAppendSeqLine(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`.
        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 (Language == Toml) return error.NotAnInlineArray;
            if (Language == Fig) return fig_edit.figPrependSeqLine(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.
        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;
            const first = (try parsed.ast.child(&node)) orelse return error.NotFound;
            var item = first;
            var is_first = true;
            if (index == std.math.maxInt(usize)) {
                item = (try parsed.ast.lastChild(&node)) orelse return error.NotFound;
                is_first = item.id == first.id;
            } else {
                for (0..index) |_| item = parsed.ast.next(&item) orelse return error.NotFound;
                is_first = index == 0;
            }
            const item_span = parsed.span(item);
            if (isFlow(source, span)) {
                try self.removeFlowItem(item_span, is_first);
                return;
            }
            if (Language == Toml) return error.NotAnInlineArray;
            const line_start = commentBlockStart(source, lineStartBefore(source, item_span.start), comment_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), comment_style),
                entryBlockEnd(source, parsed.span(src)),
                entryBlockStart(source, parsed.span(dest), comment_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), comment_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 (Language == Toml) return error.NotAnInlineArray;
            var blocks: std.ArrayList(Block) = .empty;
            defer blocks.deinit(self.allocator);
            for (spans.items) |s| {
                try blocks.append(self.allocator, .{ .start = entryBlockStart(source, s, comment_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) ---

        fn insertBlockKey(self: *Self, parsed: Document, mapping: AST.Node, key_text: []const u8, value_text: []const u8) !void {
            const source = self.source.items;
            const last = (try parsed.ast.lastChild(&mapping)).?;
            const key_node = (try parsed.ast.firstChildKey(&mapping)).?;
            const col = columnOf(source, parsed.span(key_node).start);
            const insert_at = lineEndAfter(source, parsed.span(last).end -| 1);

            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);
        }

        // --- TOML whole-table structural editing (TOML-only) ---
        //
        // The implementations live in `toml/editor_helper.zig`, next to the
        // region helpers they build on, so this generic engine stays
        // format-agnostic. These wrappers are the public `Editor(Toml)` surface;
        // each guards with a comptime error so the method does not exist for
        // other formats. See the helper module for the per-op contract.

        /// Append a `[[header]]` element (body `body_text`) to the AoT at `path`.
        pub fn appendTableToArray(self: *Self, path: []const AST.PathSegment, body_text: []const u8) !void {
            if (Language != Toml) @compileError("appendTableToArray is TOML-only");
            return toml_edit.appendTableToArray(self, path, body_text);
        }

        /// Delete the table / array-of-tables / AoT element named by `path`.
        pub fn deleteTable(self: *Self, path: []const AST.PathSegment) !void {
            if (Language != Toml) @compileError("deleteTable is TOML-only");
            return toml_edit.deleteTable(self, path);
        }

        /// Create a new `[path]` table with body `body_text`.
        pub fn insertTable(self: *Self, path: []const AST.PathSegment, body_text: []const u8) !void {
            if (Language != Toml) @compileError("insertTable is TOML-only");
            return toml_edit.insertTable(self, path, body_text);
        }

        /// Rename the leaf segment of the table at `path` to `new_leaf`.
        pub fn renameTable(self: *Self, path: []const AST.PathSegment, new_leaf: []const u8) !void {
            if (Language != Toml) @compileError("renameTable is TOML-only");
            return toml_edit.renameTable(self, path, new_leaf);
        }

        /// Move the table at `src_path` before `dest_path` (or to EOF if null).
        pub fn moveTable(self: *Self, src_path: []const AST.PathSegment, dest_path: ?[]const AST.PathSegment) !void {
            if (Language != Toml) @compileError("moveTable is TOML-only");
            return toml_edit.moveTable(self, src_path, dest_path);
        }

        /// Reorder top-level tables to the order given by `order` (their keys).
        pub fn reorderTables(self: *Self, order: []const []const u8) !void {
            if (Language != Toml) @compileError("reorderTables is TOML-only");
            return toml_edit.reorderTables(self, order);
        }

        // --- fig whole-container structural editing (fig-only) ---
        //
        // The implementations live in `fig/editor_helper.zig`, next to the
        // region-gather helpers they build on — the fig generalization of the
        // TOML wrappers just above. `renameTable`'s fig twin doesn't exist: the
        // generic `replaceKeyAtPath` already splices a header's key in place.
        // See the helper module for the per-op contract and its scope.

        /// Delete the whole block mapping/sequence named by `path`.
        pub fn deleteContainer(self: *Self, path: []const AST.PathSegment) !void {
            if (Language != Fig) @compileError("deleteContainer is fig-only");
            return fig_edit.deleteContainer(self, path);
        }

        /// Move the block container at `src_path` before `dest_path` (or to EOF
        /// if null).
        pub fn moveContainer(self: *Self, src_path: []const AST.PathSegment, dest_path: ?[]const AST.PathSegment) !void {
            if (Language != Fig) @compileError("moveContainer is fig-only");
            return fig_edit.moveContainer(self, src_path, dest_path);
        }

        /// Reorder top-level block containers to the order given by `order`
        /// (their keys).
        pub fn reorderContainers(self: *Self, order: []const []const u8) !void {
            if (Language != Fig) @compileError("reorderContainers is fig-only");
            return fig_edit.reorderContainers(self, order);
        }

        /// Append `: value` for a mapping entry whose key is already written at
        /// column `col`. Scalars and block scalars stay inline (`key: value`);
        /// a multi-line 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 nl = std.mem.indexOfScalar(u8, v, '\n');
            const first_line = std.mem.trimStart(u8, if (nl) |i| v[0..i] else v, " ");
            const is_block_scalar = first_line.len > 0 and (first_line[0] == '|' or first_line[0] == '>');
            // A block sequence is recognizable even on a single line (`- a`); it
            // must still descend, since `key: - a` is invalid. A scalar value
            // (no line break, not a sequence dash) stays inline. (A serialized
            // scalar that would read as a dash is quoted, so this is safe.)
            const is_seq = std.mem.startsWith(u8, first_line, "- ") or std.mem.eql(u8, first_line, "-");
            if (is_block_scalar or (nl == null and !is_seq)) {
                try out.appendSlice(self.allocator, ": ");
                try reindentInto(out, self.allocator, v, col);
                return;
            }
            // Block collection value: descend onto the next lines.
            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);
            // ZON has no bare `key: value` document form (unlike YAML/JSON5,
            // whose root can be a keyless top-level mapping): a `null` value —
            // root or nested — promotes in place to a flow `.{ key = value }`
            // container, so this needs no `is_root`/descend distinction.
            if (comptime Language == Zon) {
                try out.appendSlice(self.allocator, ".{ ");
                try out.appendSlice(self.allocator, key_text);
                try out.appendSlice(self.allocator, kv_sep);
                try out.appendSlice(self.allocator, value_text);
                try out.appendSlice(self.allocator, " }");
                try self.replaceAtSpan(null_span, out.items);
                return;
            }
            if (is_root) {
                // Empty document: the whole source becomes a single entry.
                try out.appendSlice(self.allocator, key_text);
                try out.appendSlice(self.allocator, ": ");
                try reindentInto(&out, self.allocator, value_text, 0);
                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 out.appendSlice(self.allocator, ": ");
            try reindentInto(&out, self.allocator, value_text, child_col);
            try self.replaceAtSpan(null_span, out.items);
        }

        /// 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 {
            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, kv_sep);
                    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, kv_sep);
                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 {
            var out: std.ArrayList(u8) = .empty;
            defer out.deinit(self.allocator);
            try out.appendSlice(self.allocator, key_text);
            try out.appendSlice(self.allocator, kv_sep);
            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 {
            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 {
            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).
pub const CommentStyle = enum { hash, slashes };

/// 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] == '#',
            .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).
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.
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.
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 also what pulls
// `fig/editor_helper.zig` into the test build's reachability graph — `zig
// test` discovers a file's `test` blocks only once something forces it to be
// analyzed, and a bare top-level `const fig_edit = @import(...)` is not
// enough on its own (mirrors why the TOML test above matters for
// `toml/editor_helper.zig`, and why `fig/editor_helper.zig`'s OWN tests carry
// the rest of `Editor(Fig)`'s coverage rather than duplicating it here).
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 // (comment_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 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 "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);
}
