//! The authoring editor: a `Splicer` that knows how its format is SPELLED.
//!
//! ── The two layers ─────────────────────────────────────────────────────────
//! `Splicer` (`ast/splicer.zig`) is the engine: byte spans in, reparse,
//! rollback, undo. It is language-agnostic by construction and imports no
//! language module — hand it a `parse_fn` and it will edit djot, Markdown or
//! XML with the same code. What it cannot do is decide that bold is spelled
//! `**` here and `*` there.
//!
//! `Editor` is that decision, and nothing else: `Splicer` + a `*const Syntax`.
//! It hosts the gestures a caret editor actually performs — Cmd-B, H1, quote,
//! link — each of which is "consult the table, build the bytes, hand the
//! Splicer one span". The Splicer's invariant survives intact, because `Editor`
//! depends only on the `Syntax` INTERFACE and still names no format: it never
//! learns whether the table it was handed came from djot or Markdown.
//! `format.zig`'s registry is what binds the two.
//!
//! ── Why this exists ────────────────────────────────────────────────────────
//! All of this lived in `c_abi.zig`. Not by design — it accreted there because
//! the C ABI was the first (and only) caller with a caret to serve, and the
//! layer it needed didn't exist. The cost was steep: the knowledge that
//! `mailto:a@b.dev` is a `url` in Markdown but an `email` in djot could only be
//! reached through an `extern` function, could only be tested through a
//! `TwigEditor*` handle and a `TwigStatus` code, and could not be reached by
//! `twig edit` at all.
//!
//! So the C ABI's `TwigEditor` was never `Splicer` — it was always this type,
//! `{ editor, format }`, assembled by hand at the boundary. That is why this
//! module took the `Editor` name and the engine underneath was renamed to what
//! it always was: `TwigEditor` maps to `*Editor`, 1:1, and the ABI's job is
//! back to marshalling.
//!
//! ── Errors ─────────────────────────────────────────────────────────────────
//! Typed, so the ABI's mapping is mechanical and every other caller gets to
//! `switch` on something real. `error.UnsupportedFormat` is uniformly "the
//! `Syntax` table has a `null` where this gesture needed a spelling" — never a
//! hand-written per-format arm. See `syntax.zig`.
const std = @import("std");
const Allocator = std.mem.Allocator;
const AST = @import("ast.zig");
const Span = @import("../span.zig");
const locate = @import("locate.zig");
const syntax_mod = @import("../syntax.zig");
pub const Splicer = @import("splicer.zig").Splicer;
/// Used by the free functions below; the public vocabularies all hang off
/// `Editor` itself (`Editor.InlineKind`, `Editor.Error`, ...).
const Syntax = syntax_mod.Syntax;
const ContainerSpelling = syntax_mod.ContainerSpelling;
/// The `Node.Kind` tag an `InlineKind`/`ContainerKind` parses back as. The
/// vocabularies are named for their kinds, so this is a rename, not a mapping —
/// and it fails to compile rather than silently mis-mapping if one drifts.
fn kindTag(kind: anytype) Splicer.KindTag {
return switch (kind) {
inline else => |k| @field(Splicer.KindTag, @tagName(k)),
};
}
/// Room for the widest marker/indent a list can produce (`999. ` and friends).
const container_indent = " " ** 24;
pub const Editor = struct {
/// The kind vocabularies, re-exported so a caller needs only this type:
/// `twig.Editor.InlineKind`. (The `Syntax` type itself is `twig.Syntax`.)
pub const InlineKind = syntax_mod.InlineKind;
pub const BlockKind = syntax_mod.BlockKind;
pub const ContainerKind = syntax_mod.ContainerKind;
pub const Error = error{
/// `start > end`, or a range reaching past the source.
InvalidRange,
/// A heading level outside 1-6.
InvalidLevel,
/// A destination this format cannot hold (one containing a newline).
InvalidDestination,
/// The `Syntax` table has no spelling for this gesture in this format.
UnsupportedFormat,
/// No block covers the offset/range this gesture needs one for.
NoBlock,
/// The target node has no editable span/interior, or the gesture would
/// corrupt something it refuses to touch.
NotEditable,
/// The edit produced a document that no longer parses; it was rolled
/// back and nothing changed.
EditConflict,
} || Allocator.Error;
splicer: Splicer,
/// This format's spelling. Borrowed — `format.zig`'s registry entries are
/// static, so it outlives any editor.
syntax: *const Syntax,
/// `parse_ctx`/`parse_fn` are the Splicer's contract (see its doc comment);
/// `syntax` is the table every gesture below consults. Pair them from
/// `format.zig`'s registry rather than by hand — an entry's `parseToAst` and
/// `syntax` are two halves of one language, and crossing them would spell
/// djot into a Markdown document.
pub fn init(
allocator: Allocator,
source_bytes: []const u8,
parse_ctx: *const anyopaque,
parse_fn: Splicer.ParseFn,
syntax: *const Syntax,
) !Editor {
return .{
.splicer = try Splicer.init(allocator, source_bytes, parse_ctx, parse_fn),
.syntax = syntax,
};
}
pub fn deinit(self: *Editor) void {
self.splicer.deinit();
}
pub fn sourceBytes(self: *const Editor) []const u8 {
return self.splicer.sourceBytes();
}
pub fn astView(self: *const Editor) *const AST {
return self.splicer.astView();
}
pub fn lastChange(self: *const Editor) ?Splicer.Change {
return self.splicer.last_change;
}
/// Validate a caller-supplied byte range. `Splicer.replaceAtSpan` ASSERTS on
/// a bad range — fine for internal callers, but a range from a C caller or a
/// stale caret is untrusted input, so it is checked into an error here,
/// once, before any gesture can reach the assert.
fn checkRange(self: *const Editor, start: usize, end: usize) Error!void {
if (start > end or end > self.sourceBytes().len) return error.InvalidRange;
}
/// Splice rebuilt source in over `[start, end)`. Every gesture ends here.
fn commitSplice(self: *Editor, start: usize, end: usize, text: []const u8) Error!void {
self.splicer.replaceAtSpan(Span.init(start, end), text) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
// Anything else is the parser rejecting the edited document; the
// splicer has already rolled it back.
else => return error.EditConflict,
};
}
// ── Inline marks ───────────────────────────────────────────────────────
/// Wrap `[start, end)` in `kind`'s delimiters — the unconditional half of
/// the inline toolbar (always adds a mark).
pub fn wrapRange(self: *Editor, span: Span, kind: InlineKind) Error!void {
try self.checkRange(span.start, span.end);
const d = self.syntax.inline_delims.get(kind) orelse return error.UnsupportedFormat;
self.splicer.wrapRange(span, d.open, d.close) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.EditConflict,
};
}
/// Toggle `kind` over `[start, end)`: strip the mark if the range already
/// IS a node of `kind` (its whole span or its interior), else wrap it — a
/// rich editor's Cmd-B.
pub fn toggleInline(self: *Editor, span: Span, kind: InlineKind) Error!void {
try self.checkRange(span.start, span.end);
const d = self.syntax.inline_delims.get(kind) orelse return error.UnsupportedFormat;
self.splicer.toggleInline(span, kindTag(kind), d.open, d.close) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.NoNodeSpan, error.NoContentSpan => return error.NotEditable,
else => return error.EditConflict,
};
}
// ── Block kind ─────────────────────────────────────────────────────────
/// Convert the block at `offset` to `kind` (a `level`-N heading, or a
/// paragraph) by rewriting its leading marker while keeping its inline
/// content verbatim — the block half of the toolbar (H1 / Body).
pub fn setBlock(self: *Editor, offset: usize, kind: BlockKind, level: u32) Error!void {
const marker = self.syntax.heading_marker orelse return error.UnsupportedFormat;
if (kind == .heading and (level < 1 or level > 6)) return error.InvalidLevel;
const src = self.sourceBytes();
if (offset > src.len) return error.InvalidRange;
const ast = self.astView();
const block = locate.innermostBlock(ast, offset, src.len) orelse return error.NoBlock;
const node = ast.nodes[block];
const cs = node.content_span orelse return error.NotEditable;
const content = src[cs.start..cs.end];
// Rewrite [block start, end-of-text): the leading marker region (a
// heading) or nothing (a paragraph), plus the text — but NOT any
// trailing newline the block span includes (Djot blocks do), so we don't
// fuse with the next block. Rebuilding from `content_span` also
// collapses a setext heading's underline line away for free.
var end = node.span.end;
if (end > node.span.start and src[end - 1] == '\n') end -= 1;
if (end > node.span.start and src[end - 1] == '\r') end -= 1;
const allocator = self.splicer.allocator;
const prefix_len: usize = if (kind == .heading) level + 1 else 0; // marker*level + " "
const buf = try allocator.alloc(u8, prefix_len + content.len);
defer allocator.free(buf);
if (kind == .heading) {
@memset(buf[0..level], marker);
buf[level] = ' ';
}
@memcpy(buf[prefix_len..], content);
return self.commitSplice(node.span.start, end, buf);
}
// ── Block containers (quote / lists) ───────────────────────────────────
// `setBlock` rewrites the leading marker of ONE block at one offset. A block
// container is a different animal: it prefixes EVERY line of a possibly
// multi-block range, it nests, and a list numbers its items — so it gets its
// own gesture rather than another `BlockKind`. Everything below is line
// surgery over the covered blocks, spliced in one shot.
/// Toggle a block container over the blocks `[start, end)` covers.
///
/// The already-in-container test walks the AST ancestors of `start` for a
/// container of `kind`, and the toggle turns OFF only when the range covers
/// every block that container holds — otherwise it turns ON, which is what
/// makes a partial selection inside a quote nest (`> >`) instead of dragging
/// the container's uncovered siblings out with it. Toggling a list kind
/// while inside the other list kind converts in place rather than nesting.
pub fn toggleBlockContainer(self: *Editor, span: Span, kind: ContainerKind) Error!void {
try self.checkRange(span.start, span.end);
const sp = self.syntax.container_spelling.get(kind) orelse return error.UnsupportedFormat;
const allocator = self.splicer.allocator;
const src = self.sourceBytes();
const ast = self.astView();
const blocks = coveredBlocks(allocator, ast, src, span.start, span.end) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.NoBlock,
};
defer allocator.free(blocks.chain);
const region_start = locate.lineStartAt(src, ast.nodes[blocks.first].span.start);
const region_end = locate.lineEndAt(src, ast.nodes[blocks.last].span.end -| 1);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(allocator);
// The toggle-off / convert / nest decision, all from the ancestor chain.
if (locate.innermostOfKind(ast, blocks.chain, kindTag(kind))) |target| {
if (containerFullyCovered(ast, src, target, region_start, region_end)) {
const t = ast.nodes[target].span;
// The container's own lines, not the range's: its span can reach
// past the last covered block (a quote's trailing `>` line).
const splice_start = locate.lineStartAt(src, t.start);
const splice_end = locate.lineEndAt(src, t.end -| 1);
switch (kind) {
.block_quote => try buildQuoteStrip(
allocator,
src,
splice_start,
splice_end,
quoteDepthAbove(ast, blocks.chain, target),
&out,
),
.bullet_list, .ordered_list => try buildListRewrite(
allocator,
src,
ast,
target,
splice_start,
splice_end,
null,
&out,
),
}
return self.commitSplice(splice_start, splice_end, out.items);
}
}
if (kind == .bullet_list or kind == .ordered_list) {
const other: Splicer.KindTag = if (kind == .bullet_list) .ordered_list else .bullet_list;
if (locate.innermostOfKind(ast, blocks.chain, other)) |target| {
if (containerFullyCovered(ast, src, target, region_start, region_end)) {
const t = ast.nodes[target].span;
const splice_start = locate.lineStartAt(src, t.start);
const splice_end = locate.lineEndAt(src, t.end -| 1);
try buildListRewrite(allocator, src, ast, target, splice_start, splice_end, sp, &out);
return self.commitSplice(splice_start, splice_end, out.items);
}
}
}
try buildContainerAdd(allocator, src, ast, blocks, region_start, region_end, sp, &out);
return self.commitSplice(region_start, region_end, out.items);
}
// ── Links ──────────────────────────────────────────────────────────────
/// Link `[start, end)` to `destination`, or repoint the link already there.
///
/// Decisions:
/// * An EXISTING link covering the range has its destination REPLACED, its
/// text kept. Re-linking is the common gesture (fix a URL), and it keeps
/// the op idempotent instead of nesting `[[t](a)](b)`. Removing a link is
/// already `Splicer.unwrapNode`, which peels a node to its interior.
/// * A RANGE INSIDE an existing autolink re-points it the same way, but
/// there is no text to keep: an autolink's text IS its destination, so
/// the node is respelled whole for the new one (canonically — see below
/// — so a `<url>` re-pointed at a relative path becomes `[dest](dest)`,
/// not a broken `<>`). Without this the op reads the URL as ordinary text
/// and splices a link into the middle of it:
/// `<https<https://y.dev>://x.dev>`.
///
/// This covers a caret AND any selection the autolink contains —
/// including one covering it exactly. An autolink's URL is not editable
/// text: no part of it can host a `[`, so "link half this URL" has no
/// spelling, and the selection carries no text a splice could keep.
///
/// A selection that starts or ends strictly INSIDE an autolink but isn't
/// contained by it (`see <https://x` … `.dev> ok`) is refused with
/// `error.NotEditable`: half of it is real text, so there is nothing to
/// re-point, and any splice would rewrite the URL. Refusing beats
/// silently changing the caller's URL, for the same reason a newline
/// destination is `error.InvalidDestination`.
///
/// A range inside BOTH a link and an autolink (`[<https://x.dev>](d)`)
/// re-points the link, not the autolink: a link's text is separable from
/// its destination, so re-pointing it keeps text that re-pointing the
/// autolink would discard.
///
/// A range that CONTAINS an autolink whole plus text around it is
/// untouched by all of the above — it splices at the autolink's edges,
/// corrupting nothing, and the autolink stays as the new link's text.
/// * A link with NO TEXT gets the canonical spelling for the destination it
/// was given, never `[](dest)`: a childless link has nothing to render,
/// so consumers fall back to showing the destination and the caret has
/// nowhere correct to sit. Where the format can spell an autolink it gets
/// `<dest>`; where it can't it gets `[dest](dest)`, the destination
/// doubling as text so it stays visible and editable. Which destinations
/// autolink, and how each format spells one, is twig's knowledge — a
/// consumer guessing would turn `<foo>` into raw HTML (Markdown) or
/// literal text (both). See `Syntax.spellsAutolink`.
/// * A destination is escaped per format (see `writeLinkDestination`); a
/// newline in one is `error.InvalidDestination`, since neither format can
/// hold it (Djot strips it, Markdown's `<…>` form forbids it) and
/// silently changing the caller's URL is worse than refusing.
pub fn insertLink(self: *Editor, span: Span, dest: []const u8) Error!void {
try self.checkRange(span.start, span.end);
// A format with no link spelling refuses before anything else is read.
if (self.syntax.link_text_escapes == null) return error.UnsupportedFormat;
if (std.mem.indexOfAny(u8, dest, "\r\n") != null) return error.InvalidDestination;
const start = span.start;
const end = span.end;
const allocator = self.splicer.allocator;
const src = self.sourceBytes();
const ast = self.astView();
var chain: std.ArrayList(AST.Node.Id) = .empty;
defer chain.deinit(allocator);
try locate.ancestorChain(allocator, ast, start, src.len, &chain);
// The text to sit in the brackets, and the span the rebuilt link
// replaces. Re-pointing an existing link rebuilds the whole node: a
// destination is a string payload with no span of its own, so there is
// nothing smaller to splice (see `splicer.zig`'s module doc).
var text: []const u8 = src[start..end];
var target = Span.init(start, end);
var repoint = locate.innermostCovering(ast, chain.items, &.{.link}, start, end);
if (repoint == null) repoint = autolinkCovering(ast, chain.items, start, end);
// Not covered by an autolink, but still landing inside one: the range
// runs from ordinary text into the middle of a URL (either end can be the
// one inside). There is nothing to re-point — half the selection is real
// text — and no way to spell the result, so refuse rather than corrupt
// the URL.
if (repoint == null and start != end) {
const splits =
(try splitsAutolink(allocator, ast, src.len, start)) or
(try splitsAutolink(allocator, ast, src.len, end));
if (splits) return error.NotEditable;
}
if (repoint) |id| {
const node = ast.nodes[id];
if (node.span.start == 0 and node.span.end == 0) return error.NotEditable;
// An autolink has no `[text]` half: the text it shows is the OLD
// destination, so keeping it would spell the new link with the URL it
// was meant to replace. Empty text sends it through the canonical
// spelling below, exactly as a caret on bare text goes.
text = switch (std.meta.activeTag(node.kind)) {
.url, .email => "",
else => if (node.content_span) |cs| src[cs.start..cs.end] else "",
};
target = node.span;
}
var out: std.ArrayList(u8) = .empty;
defer out.deinit(allocator);
// Keyed on the TEXT being empty, not the range: re-pointing an existing
// `[](old)` is an empty range too, and it has the same childless link to
// avoid. A non-empty range always carries text, so it never lands here.
if (text.len == 0) {
if (self.syntax.spellsAutolink) |spells| {
try out.append(allocator, '<');
try out.appendSlice(allocator, dest);
try out.append(allocator, '>');
// Ask about the exact bytes we would emit, so the test and the
// output cannot disagree about what was spelled.
if (spells(out.items)) return self.commitSplice(target.start, target.end, out.items);
out.clearRetainingCapacity();
}
}
try out.append(allocator, '[');
if (text.len == 0) {
// `dest` is a raw string being repurposed as text, so it needs
// escaping for that position — unlike `text`, which is already source
// the author (or a prior parse) spelled and which must be copied
// through verbatim.
try writeLinkText(allocator, self.syntax, dest, &out);
} else {
try out.appendSlice(allocator, text);
}
try out.appendSlice(allocator, "](");
try writeLinkDestination(allocator, self.syntax, dest, &out);
try out.append(allocator, ')');
return self.commitSplice(target.start, target.end, out.items);
}
};
// ── Block-container internals ──────────────────────────────────────────────
/// The blocks `[start, end)` touches: sibling `first`…`last` under the nearest
/// ancestor whose children are blocks. You cannot quote half a paragraph, so a
/// container op always widens to whole blocks first.
const BlockRange = struct {
first: AST.Node.Id,
last: AST.Node.Id,
/// The ancestor chain down to `start`, reused for container detection.
chain: []const AST.Node.Id,
};
/// Resolve `[start, end)` to the sibling blocks it touches. `end` is pulled back
/// off a trailing newline first: a block's span stops at its text in Markdown,
/// so a selection ending on the line break would otherwise resolve above the
/// block and drag the whole document in.
fn coveredBlocks(
allocator: Allocator,
ast: *const AST,
src: []const u8,
start: usize,
end: usize,
) !BlockRange {
var last_off = if (end > start) end - 1 else start;
while (last_off > start and (src[last_off] == '\n' or src[last_off] == '\r')) last_off -= 1;
var chain_a: std.ArrayList(AST.Node.Id) = .empty;
errdefer chain_a.deinit(allocator);
try locate.ancestorChain(allocator, ast, start, src.len, &chain_a);
var chain_b: std.ArrayList(AST.Node.Id) = .empty;
defer chain_b.deinit(allocator);
try locate.ancestorChain(allocator, ast, last_off, src.len, &chain_b);
var i: usize = 0;
while (i + 1 < chain_a.items.len and i + 1 < chain_b.items.len and
chain_a.items[i + 1] == chain_b.items[i + 1]) : (i += 1)
{}
// Climb to the nearest ancestor that holds blocks: the deepest shared node
// may be an inline (a `str`), and a container wraps blocks, not words.
var p = i;
while (p > 0 and !locate.isBlockParent(std.meta.activeTag(ast.nodes[chain_a.items[p]].kind))) p -= 1;
if (p + 1 >= chain_a.items.len) return error.NoBlock;
const first = chain_a.items[p + 1];
const last = if (p + 1 < chain_b.items.len) chain_b.items[p + 1] else first;
return .{
.first = first,
.last = last,
.chain = try chain_a.toOwnedSlice(allocator),
};
}
/// True when the range's lines cover every block `target` holds — the condition
/// for toggling the container OFF rather than nesting inside it.
///
/// The test is "are all its blocks covered?", NOT "is its span inside the
/// region?": a container's span can run past its last block, because the blank
/// `>` line continuing a quote belongs to the quote and to no paragraph in it
/// (Djot spans `> > a\n>\n` as the inner quote, ending two bytes past its only
/// paragraph). Comparing spans there reads a fully-covered quote as partial and
/// nests forever.
fn containerFullyCovered(
ast: *const AST,
src: []const u8,
target: AST.Node.Id,
region_start: usize,
region_end: usize,
) bool {
const first = ast.nodes[target].first_child orelse return false;
var last = first;
var cur: ?AST.Node.Id = first;
while (cur) |c| {
last = c;
cur = ast.nodes[c].next_sibling;
}
const lo = locate.lineStartAt(src, ast.nodes[first].span.start);
const hi = locate.lineEndAt(src, ast.nodes[last].span.end -| 1);
return region_start <= lo and region_end >= hi;
}
/// How many quotes enclose `target` on the chain — the number of `>` markers to
/// step over before the one that belongs to `target`.
fn quoteDepthAbove(ast: *const AST, chain: []const AST.Node.Id, target: AST.Node.Id) usize {
var depth: usize = 0;
for (chain) |id| {
if (id == target) break;
if (std.meta.activeTag(ast.nodes[id].kind) == .block_quote) depth += 1;
}
return depth;
}
/// Advance past one `>` quote marker — its optional indent, the `>`, and the one
/// optional space after it — or `null` if `line[i..]` doesn't start one.
fn skipQuoteMarker(line: []const u8, i: usize) ?usize {
var j = i;
var indent: usize = 0;
while (j < line.len and line[j] == ' ' and indent < 3) : (indent += 1) j += 1;
if (j >= line.len or line[j] != '>') return null;
j += 1;
if (j < line.len and line[j] == ' ') j += 1;
return j;
}
/// The `[start, end)` of a list marker on `line` — `start` at the bullet/first
/// digit (so the indent before it stays put, keeping an enclosing container's
/// prefix intact) and `end` past the marker's trailing spaces. `null` if the
/// line doesn't open a list item.
fn listMarkerAt(line: []const u8) ?struct { start: usize, end: usize } {
var j: usize = 0;
while (j < line.len and (line[j] == ' ' or line[j] == '\t')) j += 1;
const start = j;
if (j >= line.len) return null;
if (line[j] == '-' or line[j] == '*' or line[j] == '+') {
j += 1;
} else {
if (line[j] == '(') j += 1;
var digits: usize = 0;
while (j < line.len and line[j] >= '0' and line[j] <= '9') : (digits += 1) j += 1;
if (digits == 0) return null;
if (j >= line.len or (line[j] != '.' and line[j] != ')')) return null;
j += 1;
}
// A marker must be followed by whitespace (or end the line): `-x` is a
// paragraph starting with a hyphen, not a bullet.
if (j < line.len and line[j] != ' ' and line[j] != '\n' and line[j] != '\r') return null;
while (j < line.len and line[j] == ' ') j += 1;
return .{ .start = start, .end = j };
}
/// True if one of the covered blocks begins on `[line_start, line_end)` — the
/// test for "this line opens a new list item". Djot starts a quoted block at its
/// text (after `> `), Markdown at the line start; either way it lands on the
/// block's first line, which is all this asks.
fn blockStartsOnLine(ast: *const AST, blocks: BlockRange, line_start: usize, line_end: usize) bool {
var cur: ?AST.Node.Id = blocks.first;
while (cur) |id| {
const s = ast.nodes[id].span.start;
if (s >= line_start and s < line_end) return true;
if (id == blocks.last) break;
cur = ast.nodes[id].next_sibling;
}
return false;
}
/// True if one of `list`'s items begins on `[line_start, line_end)`.
fn itemStartsOnLine(ast: *const AST, list: AST.Node.Id, line_start: usize, line_end: usize) bool {
var it = ast.children(list);
while (it.next()) |item| {
const s = item.span.start;
if (s >= line_start and s < line_end) return true;
}
return false;
}
/// Wrap every line of `[region_start, region_end)` in `kind`'s prefix, one item
/// per covered block. The lines already carry any enclosing container's prefix,
/// so prefixing at column 0 nests naturally (`> a` -> `> > a`).
fn buildContainerAdd(
allocator: Allocator,
src: []const u8,
ast: *const AST,
blocks: BlockRange,
region_start: usize,
region_end: usize,
sp: ContainerSpelling,
out: *std.ArrayList(u8),
) !void {
var ordinal: u32 = 1;
var cont: []const u8 = sp.cont;
var line_start = region_start;
while (line_start < region_end) {
const line_end = locate.lineEndAt(src, line_start);
const line = src[line_start..line_end];
const body = locate.lineBody(line);
if (locate.isBlankLine(body)) {
// A blank line inside the region: mark it for a quote (else the quote
// ends here), leave it bare for a list (it separates items).
if (sp.blank.len > 0) {
try out.appendSlice(allocator, sp.blank);
try out.appendSlice(allocator, line[body.len..]);
} else {
try out.appendSlice(allocator, line);
}
line_start = line_end;
continue;
}
if (blockStartsOnLine(ast, blocks, line_start, line_end)) {
var num_buf: [24]u8 = undefined;
const marker = if (sp.numbered)
std.fmt.bufPrint(&num_buf, "{d}. ", .{ordinal}) catch unreachable
else
sp.marker;
if (sp.numbered) cont = container_indent[0..@min(marker.len, container_indent.len)];
try out.appendSlice(allocator, marker);
try out.appendSlice(allocator, line);
ordinal += 1;
} else {
try out.appendSlice(allocator, cont);
try out.appendSlice(allocator, line);
}
line_start = line_end;
}
}
/// Strip the quote marker `target` contributes from each of its lines, leaving
/// any outer quote levels untouched: `depth` is how many quotes enclose it, so
/// the marker removed is the `depth`-th + 1 on every line. That's what makes
/// toggling off a nested quote peel exactly one level (`> > a` -> `> a`).
fn buildQuoteStrip(
allocator: Allocator,
src: []const u8,
region_start: usize,
region_end: usize,
depth: usize,
out: *std.ArrayList(u8),
) !void {
var line_start = region_start;
while (line_start < region_end) {
const line_end = locate.lineEndAt(src, line_start);
const line = src[line_start..line_end];
var keep: usize = 0;
var d: usize = 0;
while (d < depth) : (d += 1) keep = skipQuoteMarker(line, keep) orelse break;
if (d == depth) {
if (skipQuoteMarker(line, keep)) |after| {
try out.appendSlice(allocator, line[0..keep]);
try out.appendSlice(allocator, line[after..]);
line_start = line_end;
continue;
}
}
// A line with no marker at this level (a lazy continuation) is already
// outside the level being removed — pass it through untouched.
try out.appendSlice(allocator, line);
line_start = line_end;
}
}
/// Rewrite the list `target`'s item markers: `sp == null` removes the list
/// (toggle off), otherwise it converts one list kind to the other in place. The
/// text before a marker (an enclosing quote's `> `, a nesting indent) is kept
/// verbatim; a block's continuation lines are re-indented to the new marker's
/// width so they stay attached to their item.
///
/// Removing a list has to keep its items separate BLOCKS: a tight `- a\n- b\n`
/// would strip to `a\nb\n`, which is one two-line paragraph, not two. So a blank
/// line is injected between items that had none — the structure the items had is
/// what survives, not their tightness.
fn buildListRewrite(
allocator: Allocator,
src: []const u8,
ast: *const AST,
target: AST.Node.Id,
region_start: usize,
region_end: usize,
sp: ?ContainerSpelling,
out: *std.ArrayList(u8),
) !void {
var ordinal: u32 = 1;
var old_width: usize = 0;
var new_width: usize = 0;
var seen_item = false;
var last_blank = true;
var line_start = region_start;
while (line_start < region_end) {
const line_end = locate.lineEndAt(src, line_start);
const line = src[line_start..line_end];
const body = locate.lineBody(line);
if (locate.isBlankLine(body)) {
try out.appendSlice(allocator, line);
last_blank = true;
line_start = line_end;
continue;
}
if (itemStartsOnLine(ast, target, line_start, line_end)) {
// Only when the list is going away: a conversion keeps the items as
// items, so it must not loosen a tight list.
if (sp == null and seen_item and !last_blank) try out.append(allocator, '\n');
const m = listMarkerAt(line) orelse {
try out.appendSlice(allocator, line);
line_start = line_end;
continue;
};
var num_buf: [24]u8 = undefined;
const marker: []const u8 = if (sp) |s|
(if (s.numbered)
std.fmt.bufPrint(&num_buf, "{d}. ", .{ordinal}) catch unreachable
else
s.marker)
else
"";
try out.appendSlice(allocator, line[0..m.start]);
try out.appendSlice(allocator, marker);
try out.appendSlice(allocator, line[m.end..]);
old_width = m.end - m.start;
new_width = marker.len;
ordinal += 1;
seen_item = true;
last_blank = false;
} else {
// A continuation line: swap the old marker's indent for the new one's
// so the line stays inside its item.
var j: usize = 0;
while (j < line.len and j < old_width and line[j] == ' ') j += 1;
try out.appendSlice(allocator, container_indent[0..@min(new_width, container_indent.len)]);
try out.appendSlice(allocator, line[j..]);
last_blank = false;
}
line_start = line_end;
}
}
// ── Link internals ─────────────────────────────────────────────────────────
// `toggleInline` can't spell a link: its delimiters are a fixed `(open, close)`
// pair, and a link's closing half carries a payload (`](dest)`). Hence a
// dedicated gesture with a destination argument — and with the escaping that
// payload needs.
/// Write `dest` into `out` spelled so the format parses it back byte-for-byte.
///
/// This is the sharp edge of the whole gesture, and it is NOT one escape table:
///
/// * Markdown ends a destination at the first space — `[t](a b)` is not a link
/// at all, it is literal text — so a destination holding whitespace has to
/// move into the `<…>` form, where `<`/`>`/`\` are what need escaping.
/// * Djot takes spaces literally and gives `<…>` NO meaning: `[t](<a b>)`
/// links to the seven characters `<a b>`. Wrapping there would corrupt the
/// URL rather than protect it.
///
/// That difference is `DestEscapes.angle`: non-null means the format HAS an
/// angle form to escape into. The algorithm is the same either way, which is why
/// it lives here once and the alphabets live in `syntax.zig`.
///
/// Both formats honour a backslash escape inside the destination, which is what
/// keeps an unbalanced `)` from closing the link early.
fn writeLinkDestination(
allocator: Allocator,
syntax: *const Syntax,
dest: []const u8,
out: *std.ArrayList(u8),
) !void {
const de = syntax.link_dest_escapes orelse return error.UnsupportedFormat;
const angle: ?[]const u8 = if (de.angle) |a|
(if (std.mem.indexOfAny(u8, dest, " \t") != null) a.escapes else null)
else
null;
if (angle != null) try out.append(allocator, '<');
const escapes = angle orelse de.plain;
for (dest) |c| {
if (std.mem.indexOfScalar(u8, escapes, c) != null) try out.append(allocator, '\\');
try out.append(allocator, c);
}
if (angle != null) try out.append(allocator, '>');
}
fn writeLinkText(
allocator: Allocator,
syntax: *const Syntax,
text: []const u8,
out: *std.ArrayList(u8),
) !void {
const escapes = syntax.link_text_escapes orelse return error.UnsupportedFormat;
for (text) |c| {
if (std.mem.indexOfScalar(u8, escapes, c) != null) try out.append(allocator, '\\');
try out.append(allocator, c);
}
}
/// The innermost autolink — the `<https://x.dev>` / `<a@b.dev>` form — on the
/// chain that wholly contains `[start, end)`.
///
/// Both node kinds are matched in both formats because the split is not the one
/// the names suggest — it follows the FORMAT, not just the destination.
/// `<mailto:a@b.dev>` parses as a `url` in Markdown and an `email` in djot, so
/// picking one kind per format would miss half the autolinks it was meant to
/// catch.
fn autolinkCovering(ast: *const AST, chain: []const AST.Node.Id, start: usize, end: usize) ?AST.Node.Id {
return locate.innermostCovering(ast, chain, &.{ .url, .email }, start, end);
}
/// Whether writing at `pos` would land STRICTLY INSIDE an autolink's URL — an
/// autolink covers `pos`, and `pos` is neither of its edges. A splice at an edge
/// is safe (it lands beside the node); one strictly inside rewrites the URL
/// itself, which is never what any caller meant. See `Editor.insertLink`.
///
/// Builds its own chain because the caller's is rooted at `start`, and the offset
/// that lands inside can be `end` (a selection running from ordinary text into
/// the middle of a URL).
fn splitsAutolink(allocator: Allocator, ast: *const AST, source_len: usize, pos: usize) Allocator.Error!bool {
var chain: std.ArrayList(AST.Node.Id) = .empty;
defer chain.deinit(allocator);
try locate.ancestorChain(allocator, ast, pos, source_len, &chain);
const id = autolinkCovering(ast, chain.items, pos, pos) orelse return false;
const span = ast.nodes[id].span;
return span.start < pos and pos < span.end;
}
test {
_ = @import("editor_test.zig");
}