twig-sys 3.2.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
//! Hit-testing: byte offset -> node, plus the line scanning the block gestures
//! are built on.
//!
//! The AST addresses nodes three ways — an index path, a `Node.Id`, and a
//! `Select` match — and a caret speaks none of them. It speaks a byte offset.
//! This is the missing fourth: `deepestContaining(ast, offset)` and the
//! `ancestorChain` down to it.
//!
//! All of it is pure `AST` traversal and pure byte scanning: no format, no
//! allocation beyond the caller's chain buffer, nothing to do with any ABI. It
//! lived in `c_abi.zig` only because `twig_editor_node_at` was the first caller
//! that needed it.
//!
//! `Node` carries no parent link (the arena is index-based and children are a
//! sibling chain — see `ast.zig`), so an ancestor chain can't be walked upward;
//! it is rebuilt by descending from the root. Every walk here is therefore
//! top-down.

const std = @import("std");
const Allocator = std.mem.Allocator;

const AST = @import("ast.zig");
const Document = @import("../document.zig");
const Span = @import("../span.zig");

/// A `Node.Kind` tag without its payload — the language-agnostic way to name a
/// kind. Re-exported from the splicer so callers of this module needn't import
/// both.
pub const KindTag = @import("splicer.zig").Splicer.KindTag;

/// True if `offset` falls in node span `s` (half-open), treating a whole-source
/// end position as inside, and an unset `(0,0)` span as containing nothing.
///
/// The `(0,0)` guard matters: some parsers leave a node's span unset (notably
/// Markdown's inline nodes, and the `doc` root), and without it every such node
/// would claim to contain offset 0.
pub fn spanContains(s: Span, offset: usize, source_len: usize) bool {
    if (s.start == 0 and s.end == 0) return false;
    if (offset == source_len) return offset >= s.start and s.end >= source_len;
    return offset >= s.start and offset < s.end;
}

/// The child of `id` whose span contains `offset` — the LAST such child, so an
/// offset on a boundary resolves into the later sibling. `null` if none.
pub fn childContaining(doc: *const Document, id: AST.Node.Id, offset: usize) ?AST.Node.Id {
    var found: ?AST.Node.Id = null;
    var it = doc.children(id);
    while (it.next()) |child| {
        if (spanContains(doc.span(child.id), offset, doc.source.len)) found = child.id;
    }
    return found;
}

/// The deepest node containing `offset`, descending from the root. The root's
/// own span may be unset `(0,0)` (some parsers don't span the `doc` node); when
/// so, entry is the root's child that owns the offset, and descent continues
/// fully from there. `null` if no node covers the offset at all.
pub fn deepestContaining(doc: *const Document, offset: usize) ?AST.Node.Id {
    var cur = doc.ast.root;
    if (!spanContains(doc.span(cur), offset, doc.source.len)) {
        cur = childContaining(doc, cur, offset) orelse return null;
    }
    while (childContaining(doc, cur, offset)) |child| cur = child;
    return cur;
}

/// The chain of node ids from the root down to the deepest node containing
/// `offset` — the ancestor walk the container gestures detect enclosing
/// containers with. Appends to `out`; the caller owns it.
pub fn ancestorChain(
    allocator: Allocator,
    doc: *const Document,
    offset: usize,
    out: *std.ArrayList(AST.Node.Id),
) Allocator.Error!void {
    var cur = doc.ast.root;
    try out.append(allocator, cur);
    while (childContaining(doc, cur, offset)) |child| {
        cur = child;
        try out.append(allocator, cur);
    }
}

// ── Caret hit-testing ──────────────────────────────────────────────────────
// The same descent, under the containment rule an EDITING CARET needs rather
// than the one a byte range needs. Kept as its own pair of entry points rather
// than a flag on the ones above, because the two rules disagree and both are
// right for their own caller.

/// True if a CARET at `offset` sits in node span `s`, whose source is `src`.
///
/// Two differences from `spanContains`, and both exist because a caret is a
/// position BETWEEN bytes while a span is a range OF bytes:
///
///  1. **End-inclusive.** A caret at a block's end is in that block — it is
///     where you stand to type the rest of the paragraph. Half-open containment
///     puts it outside, which is why an editor probing `ancestors_at` had to
///     guess at contrived offsets (`content_start`, `caret - 1`, the marker's
///     own byte) to find the block it was plainly inside of.
///
///  2. **A trailing line terminator is not part of the block.** This is what
///     makes the two authorable formats agree. Djot ends a paragraph's span
///     AFTER its newline and Markdown BEFORE it, so on `"a\n\nb\n"` the caret
///     at offset 1 read as `para` in djot and as `doc` in Markdown, and the
///     caret at end-of-source read as `para` in djot and `doc` in Markdown —
///     the same caret, two answers, decided by which parser happened to
///     produce the tree. Trimming the terminator normalises both ends: the
///     caret after `a` is in `a`'s paragraph in both, and the caret on the
///     blank line after it is in neither.
///
/// Note that end-inclusive does NOT make an inline mark sticky, which was the
/// reason not to simply relax `spanContains` itself: `caretChildContaining`
/// keeps the LAST matching child, so a caret on the boundary between an `emph`
/// and the text after it resolves into the text, exactly as before.
pub fn caretContains(src: []const u8, s: Span, offset: usize) bool {
    if (s.start == 0 and s.end == 0) return false;
    if (offset < s.start) return false;
    var end = @min(s.end, src.len);
    if (end > s.start and src[end - 1] == '\n') end -= 1;
    if (end > s.start and src[end - 1] == '\r') end -= 1;
    return offset <= end;
}

/// `childContaining` under `caretContains` — the LAST matching child again, so
/// a caret on a boundary resolves into the later sibling.
pub fn caretChildContaining(doc: *const Document, id: AST.Node.Id, offset: usize) ?AST.Node.Id {
    var found: ?AST.Node.Id = null;
    var it = doc.children(id);
    while (it.next()) |child| {
        if (caretContains(doc.source, doc.span(child.id), offset)) found = child.id;
    }
    return found;
}

/// The deepest node a CARET at `offset` is in — `deepestContaining`'s answer to
/// the question an editor is actually asking. See `caretContains`.
///
/// Unlike `deepestContaining` this never returns `null` for a non-empty tree:
/// the descent starts AT the root and stops where no child matches, so a caret
/// in the gap between two blocks (or past the last one) reports the container
/// that holds the gap rather than nothing at all. The root's own span is never
/// tested, which is what lets this work for the parsers that leave it unset.
pub fn deepestContainingForCaret(doc: *const Document, offset: usize) ?AST.Node.Id {
    if (doc.ast.nodes.len == 0) return null;
    var cur = doc.ast.root;
    while (caretChildContaining(doc, cur, offset)) |child| cur = child;
    return cur;
}

/// `ancestorChain` under caret containment — root first, deepest last. See
/// `caretContains`.
pub fn caretChain(
    allocator: Allocator,
    doc: *const Document,
    offset: usize,
    out: *std.ArrayList(AST.Node.Id),
) Allocator.Error!void {
    if (doc.ast.nodes.len == 0) return;
    var cur = doc.ast.root;
    try out.append(allocator, cur);
    while (caretChildContaining(doc, cur, offset)) |child| {
        cur = child;
        try out.append(allocator, cur);
    }
}

// ── Line prefixes ──────────────────────────────────────────────────────────

/// Everything HIDDEN before the content on the line `offset` sits on: every
/// marker a node OPENS that line with, and the indentation between them, as one
/// span from the line start.
///
/// This is the assembled form of `Document.node_marker_spans`, which records
/// each node's own marker alone. A nested construct's prefix is the union of
/// its ancestors' — `>   1. [ ] ` is four nodes' markers plus the spaces
/// between them — and the union is contiguous from the line start, so this
/// walks the descent to `offset` and takes the LAST marker opening on that
/// line. Reaching back to the line start rather than concatenating the spans is
/// what picks up a nested item's INDENT, which is real hidden width that no
/// node claims as its own marker.
///
/// `null` when nothing opens on this line — a CONTINUATION line, the second
/// line of a wrapped paragraph or of a quote. That is deliberate and not an
/// oversight: what a continuation line is prefixed with is a different question
/// with a different answer (a quote repeats `> `, a list item repeats spaces),
/// and answering it from marker spans alone would be a guess. A caller that
/// wants it should ask for a continuation prefix, not read this and hope.
pub fn linePrefixSpan(doc: *const Document, offset: usize) ?Span {
    if (doc.ast.nodes.len == 0) return null;
    const line_start = lineStartAt(doc.source, offset);
    const line_end = lineEndAt(doc.source, offset);

    var end: ?usize = null;
    var cur = doc.ast.root;
    while (true) {
        if (doc.markerSpan(cur)) |m| {
            // On THIS line: an ancestor whose marker opened an earlier line
            // contributes its indent (via the reach back to `line_start`), not
            // its marker.
            if (m.start >= line_start and m.start < line_end) {
                if (end == null or m.end > end.?) end = m.end;
            }
        }
        cur = caretChildContaining(doc, cur, offset) orelse break;
    }
    const e = end orelse return null;
    return Span.init(line_start, e);
}

/// The raw source bytes `linePrefixSpan` covers, or `null` when it has none —
/// what a rich view hides at the head of this line.
pub fn linePrefixText(doc: *const Document, offset: usize) ?[]const u8 {
    const s = linePrefixSpan(doc, offset) orelse return null;
    return Span.of(u8, s, doc.source);
}

/// How wide a tab is. CommonMark's column model, which djot shares: a tab
/// advances to the next multiple of this, it does not contribute a fixed width.
const tab_stop = 4;

/// The COLUMN of `offset` on its own line — not `offset - line_start`, because a
/// tab in a marker or an indent advances to a tab stop rather than by one.
fn columnOf(src: []const u8, offset: usize) usize {
    const at = @min(offset, src.len);
    var i = lineStartAt(src, at);
    var col: usize = 0;
    while (i < at) : (i += 1) {
        col = if (src[i] == '\t') col + (tab_stop - col % tab_stop) else col + 1;
    }
    return col;
}

/// True for a container that holds its children behind a PER-LINE PREFIX — the
/// kinds a continuation line has to re-open, and exactly the kinds that report
/// `content_span == span` for the same underlying reason (see
/// `Document.node_content_spans`).
///
/// Hand-kept rather than derived from `contentModel`, for the reason
/// `isBlockParent` is: "may hold blocks" does not imply "prefixes every line of
/// them". A `cell` holds blocks and prefixes nothing.
fn isMarkerPrefixed(kind: AST.Node.Kind) bool {
    return switch (kind) {
        .block_quote,
        .list_item,
        .task_list_item,
        .definition_list_item,
        .definition,
        .footnote,
        => true,
        else => false,
    };
}

/// Whether a container's prefix is REPRODUCED on a continuation line or merely
/// INDENTED past. A quote's `> ` must reappear on every line it holds; a list
/// item's marker must NOT — repeating `- ` would open a second item, so what the
/// continuation carries is the marker's WIDTH IN SPACES instead.
fn repeatsVerbatim(kind: AST.Node.Kind) bool {
    return kind == .block_quote;
}

/// What a CONTINUATION LINE at `offset` must open with to stay inside every
/// container holding it — appended to `out`. Returns the prefix's width in
/// COLUMNS, which is the other half of the answer (Tab's step, a caret's
/// horizontal home, an outdent's width) and is not `out.items.len` once a tab is
/// involved.
///
/// ── Why this is not `linePrefixSpan` ───────────────────────────────────────
/// They answer opposite questions and neither can be derived from the other.
/// `linePrefixSpan` reports the bytes ALREADY THERE on a line something opens —
/// contiguous source, so it hands back a span. This one reports the bytes that
/// WOULD HAVE TO BE WRITTEN on a line nothing opens, which is not source at all:
/// a list item's continuation is spaces where the marker was, so there is
/// nothing to point at and the answer has to be built.
///
/// That is why `linePrefixSpan` returns `null` on a continuation line rather
/// than guessing — the guess an editor makes there (re-read the previous line's
/// bytes and copy what looks like a marker) is what turns `- a` plus Enter into
/// two items in a format where it should be one.
///
/// ── How it composes ────────────────────────────────────────────────────────
/// Each marker-prefixed ancestor on the caret's chain contributes the COLUMNS
/// ITS OWN MARKER OCCUPIES, on its own opening line — which may be a different
/// line for each of them, and is the reason this walks the tree instead of
/// reading one line's bytes:
///
///     > - a          quote `> ` (cols 0-2) + item `- ` (cols 2-4)  ->  ">   "
///     - a            outer item (cols 0-2)
///       - b          inner item (cols 2-4)                         ->  "    "
///
/// The widths compose because a nested container's marker STARTS where its
/// parent's content does, so each contribution picks up where the last left off.
/// A gap (a parent that recorded no marker) is padded with spaces rather than
/// silently closed, so the columns still line up.
///
/// Appends nothing and returns 0 at the top level, which is the correct prefix
/// there: none.
pub fn continuationPrefix(
    allocator: Allocator,
    doc: *const Document,
    offset: usize,
    out: *std.ArrayList(u8),
) Allocator.Error!usize {
    if (doc.ast.nodes.len == 0) return 0;
    const src = doc.source;
    var col: usize = 0;
    var cur = doc.ast.root;

    while (true) {
        if (isMarkerPrefixed(doc.ast.nodes[cur].kind)) {
            if (doc.markerSpan(cur)) |m| {
                const start_col = columnOf(src, m.start);
                const end_col = columnOf(src, m.end);
                // Pad any gap, then lay down this container's own columns. A
                // marker already behind us (a malformed or unrecorded chain)
                // contributes nothing rather than reaching backwards.
                while (col < start_col) : (col += 1) try out.append(allocator, ' ');
                if (end_col > col) {
                    if (repeatsVerbatim(doc.ast.nodes[cur].kind)) {
                        try out.appendSlice(allocator, src[m.start..m.end]);
                    } else {
                        try out.appendNTimes(allocator, ' ', end_col - col);
                    }
                    col = end_col;
                }
            }
        }
        cur = caretChildContaining(doc, cur, offset) orelse break;
    }
    return col;
}

/// What a BLANK line inside the containers at `offset` must carry — appended to
/// `out`, returning its width in columns.
///
/// A quote's blank line still has to carry its `>` or the quote ENDS there; a
/// list item's blank line must carry nothing, because a blank line between two
/// of an item's blocks is what makes its whole list loose and re-indenting it
/// changes nothing about that. So this is `continuationPrefix` with the trailing
/// spaces cut back — which drops a list item's indent entirely while leaving a
/// quote marker standing, exactly as `Syntax.ContainerSpelling.blank` states the
/// rule for the toggle gestures.
///
/// The one subtlety is that a quote's blank form is `>` and not `> `: the space
/// after the marker is content indentation, and a blank line has no content.
/// Trimming trailing spaces produces that for free.
pub fn blankLinePrefix(
    allocator: Allocator,
    doc: *const Document,
    offset: usize,
    out: *std.ArrayList(u8),
) Allocator.Error!usize {
    const base = out.items.len;
    _ = try continuationPrefix(allocator, doc, offset, out);
    const trimmed = std.mem.trimEnd(u8, out.items[base..], " \t");
    out.items.len = base + trimmed.len;
    return columnsIn(out.items[base..]);
}

/// The column width of an already-built prefix — `len` unless it holds a tab.
fn columnsIn(prefix: []const u8) usize {
    var col: usize = 0;
    for (prefix) |c| {
        col = if (c == '\t') col + (tab_stop - col % tab_stop) else col + 1;
    }
    return col;
}

/// The LINE-OWNING BLOCK holding `offset`: the child of the innermost
/// block-parent container on the descent, or `null` when no node covers the
/// offset at all (an empty document, or one whose root spans nothing).
///
/// This is what a gesture placing a NEW BLOCK BESIDE the caret's block needs,
/// and it is deliberately not `innermostBlock`. That one answers a narrower
/// question — which `para`/`heading`'s marker to rewrite — and returns `null`
/// for a caret in a code block or a table, because `setBlock` has nothing to do
/// there. A gesture that reads that `null` as "there is no block here" and
/// falls back to the caret's own line writes its block INTO the fence or
/// BETWEEN a table's header and its delimiter row, which loses the table.
///
/// `isBlockParent` is the right hinge because of what it already asserts: a
/// kind belongs to it only if its children EACH START ON THEIR OWN LINE. So the
/// child on the descent path is, by that list's own contract, a block owning
/// whole lines — and stopping there is what escapes a table (a `cell` is
/// deliberately not a block parent, so the walk stops at the `table` rather
/// than at a `para` inside a cell) while still descending into a quote or a
/// list item, whose contents genuinely do start their own lines.
/// `block` is the block itself; `parent` is the container holding it — which
/// `Editor.splitBlock` needs, because what a split writes between the halves is
/// the parent's business (a `list_item` parent means repeat the item's marker,
/// so the second half is an item and not a stray paragraph).
pub const LineBlock = struct { parent: AST.Node.Id, block: AST.Node.Id };

pub fn lineOwningBlock(doc: *const Document, offset: usize) ?LineBlock {
    var result: ?LineBlock = null;
    var cur = doc.ast.root;
    while (childContaining(doc, cur, offset)) |child| {
        if (isBlockParent(doc.ast.nodes[cur].kind)) result = .{ .parent = cur, .block = child };
        cur = child;
    }
    return result;
}

/// The innermost `heading`/`para` on the descent to `offset`, or `null` — the
/// block `Editor.setBlock` rewrites the marker of. For "the block the caret is
/// in" in the general sense, including code blocks and tables, see
/// `lineOwningBlock`.
pub fn innermostBlock(doc: *const Document, offset: usize) ?AST.Node.Id {
    var result: ?AST.Node.Id = null;
    var cur = doc.ast.root;
    while (true) {
        switch (std.meta.activeTag(doc.ast.nodes[cur].kind)) {
            .heading, .para => result = cur,
            else => {},
        }
        cur = childContaining(doc, cur, offset) orelse break;
    }
    return result;
}

/// True for a node whose children are blocks — the level a container op works
/// at. Everything else (a `para`, a `heading`) holds inlines.
///
/// ── Why this is NOT `kind.contentModel() == .blocks` ───────────────────────
/// It looks like it should be, and it was written that way first. It is not,
/// because a container gesture prefixes whole LINES, and "may hold blocks" does
/// not imply "its children each own their lines". A table `cell` may hold
/// blocks — HTML's `<td><p>x</p></td>` does — but a row's cells SHARE one line,
/// so treating a cell as a block parent makes the quote gesture wrap the header
/// row and not the separator under it:
///
///     | c | d |          > | c | d |
///     |---|---|    ->    |---|---|      <- broken table, not a quoted one
///
/// against the correct whole-table wrap the list below produces. The full test
/// suite passes either way, so this is deliberately a hand-kept list of the
/// LINE-OWNING containers, not a derivation. Adding a kind here means asserting
/// its children each start on their own line.
pub fn isBlockParent(kind: AST.Node.Kind) bool {
    return switch (kind) {
        .doc, .block_quote, .list_item, .task_list_item, .section => true,
        .container => |c| if (c.form) |f| f.isBlockForm() else false,
        else => false,
    };
}

/// True for the three container kinds a toggle targets.
pub fn isBlockContainer(tag: KindTag) bool {
    return switch (tag) {
        .block_quote, .bullet_list, .ordered_list => true,
        else => false,
    };
}

/// The innermost node on `chain` whose kind is `tag`, or `null`.
pub fn innermostOfKind(doc: *const Document, chain: []const AST.Node.Id, tag: KindTag) ?AST.Node.Id {
    var i = chain.len;
    while (i > 0) {
        i -= 1;
        if (std.meta.activeTag(doc.ast.nodes[chain[i]].kind) == tag) return chain[i];
    }
    return null;
}

/// The innermost node on `chain` of any kind in `tags` that wholly contains
/// `[start, end)`, or `null`. The containment test is what distinguishes this
/// from `innermostOfKind`: a node merely on the chain touches `start`, which is
/// not the same as covering the whole range.
pub fn innermostCovering(
    doc: *const Document,
    chain: []const AST.Node.Id,
    tags: []const KindTag,
    start: usize,
    end: usize,
) ?AST.Node.Id {
    var i = chain.len;
    while (i > 0) {
        i -= 1;
        const node = doc.ast.nodes[chain[i]];
        const tag = std.meta.activeTag(node.kind);
        const sp = doc.span(chain[i]);
        for (tags) |want| {
            if (tag == want and sp.start <= start and sp.end >= end) return chain[i];
        }
    }
    return null;
}

// ── Line scanning ──────────────────────────────────────────────────────────
// A block container prefixes every LINE it covers, so its gestures work in
// lines rather than spans. Pure byte scanning over the source.

/// The start of the line `at` sits on.
pub fn lineStartAt(src: []const u8, at: usize) usize {
    var i = @min(at, src.len);
    while (i > 0 and src[i - 1] != '\n') i -= 1;
    return i;
}

/// One past the newline terminating the line `at` sits on (or `src.len` at an
/// unterminated last line).
pub fn lineEndAt(src: []const u8, at: usize) usize {
    var i = @min(at, src.len);
    while (i < src.len and src[i] != '\n') i += 1;
    return if (i < src.len) i + 1 else i;
}

/// `line` without its trailing `\r\n` / `\n`.
pub fn lineBody(line: []const u8) []const u8 {
    var e = line.len;
    if (e > 0 and line[e - 1] == '\n') e -= 1;
    if (e > 0 and line[e - 1] == '\r') e -= 1;
    return line[0..e];
}

/// Only spaces/tabs (or nothing) — a line that separates blocks.
pub fn isBlankLine(body: []const u8) bool {
    for (body) |c| {
        if (c != ' ' and c != '\t') return false;
    }
    return true;
}

test "spanContains treats an unset span as containing nothing" {
    try std.testing.expect(!spanContains(Span.init(0, 0), 0, 10));
    try std.testing.expect(spanContains(Span.init(0, 5), 0, 10));
    try std.testing.expect(!spanContains(Span.init(0, 5), 5, 10));
    // A whole-source end position reads as inside the node that reaches the end.
    try std.testing.expect(spanContains(Span.init(5, 10), 10, 10));
    try std.testing.expect(!spanContains(Span.init(0, 5), 10, 10));
}

test "line scanning" {
    const src = "ab\ncd\n\nef";
    try std.testing.expectEqual(@as(usize, 0), lineStartAt(src, 1));
    try std.testing.expectEqual(@as(usize, 3), lineStartAt(src, 4));
    try std.testing.expectEqual(@as(usize, 3), lineEndAt(src, 1));
    try std.testing.expectEqual(@as(usize, 9), lineEndAt(src, 7));
    try std.testing.expectEqualStrings("ab", lineBody("ab\n"));
    try std.testing.expectEqualStrings("ab", lineBody("ab\r\n"));
    try std.testing.expectEqualStrings("ab", lineBody("ab"));
    try std.testing.expect(isBlankLine(""));
    try std.testing.expect(isBlankLine("  \t"));
    try std.testing.expect(!isBlankLine(" x"));
}

test "deepestContaining descends to the innermost node and chains to it" {
    const Xml = @import("../languages/xml/xml.zig");
    const gpa = std.testing.allocator;
    const src = "<r><a>hi</a></r>";
    var ast = try Xml.parse(gpa, src);
    defer ast.deinit();

    // The offset of `hi` lands inside <a>, which is deeper than <r>.
    const at = std.mem.indexOf(u8, src, "hi").?;
    const deep = deepestContaining(&ast, at).?;

    var chain: std.ArrayList(AST.Node.Id) = .empty;
    defer chain.deinit(gpa);
    try ancestorChain(gpa, &ast, at, &chain);

    // The chain ends at the deepest node and starts at the root.
    try std.testing.expectEqual(deep, chain.items[chain.items.len - 1]);
    try std.testing.expectEqual(ast.ast.root, chain.items[0]);
    try std.testing.expect(chain.items.len >= 2);
}

// ── Caret / prefix tests ───────────────────────────────────────────────────
// Every one of these runs BOTH authorable formats over the same source and
// asserts the same answer. That is the whole point of the two entry points: the
// spans underneath genuinely differ (djot ends a block after its newline,
// Markdown before it), and a consumer must not be able to tell which parser
// produced the tree it is holding.

const Markdown = @import("../languages/markdown/markdown.zig");
const Djot = @import("../languages/djot/djot.zig");

/// Parse `src` as both authorable formats and hand each `Document` to `check`.
fn forBothFormats(src: []const u8, check: fn (doc: *const Document, src: []const u8) anyerror!void) !void {
    const gpa = std.testing.allocator;

    var md = try Markdown.parse(gpa, src, .{});
    defer md.deinit();
    const md_doc = md.document();
    check(&md_doc, src) catch |e| {
        std.debug.print("  (markdown)\n", .{});
        return e;
    };

    var dj = try Djot.parse(gpa, src);
    defer dj.deinit();
    const dj_doc = dj.document();
    check(&dj_doc, src) catch |e| {
        std.debug.print("  (djot)\n", .{});
        return e;
    };
}

fn kindAt(doc: *const Document, offset: usize) ?AST.Node.Kind {
    const id = deepestContainingForCaret(doc, offset) orelse return null;
    return doc.ast.nodes[id].kind;
}

test "caret: a block's end is inside it, and a blank line between blocks is not" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            _ = src;
            // "a\n\nb\n" — offsets: 0 'a', 1 '\n', 2 '\n' (the blank line),
            // 3 'b', 4 '\n', 5 end-of-source.
            //
            // Half-open containment put 1 and 4 — the end of each paragraph,
            // and the commonest caret position there is — outside every block
            // in Markdown while djot reported the paragraph. Both now agree.
            try std.testing.expect(kindAt(doc, 0).? == .str);
            try std.testing.expect(kindAt(doc, 1).? == .str);
            try std.testing.expect(kindAt(doc, 3).? == .str);
            try std.testing.expect(kindAt(doc, 4).? == .str);
            // The blank line separating them belongs to no block, and neither
            // does the empty line after the final newline.
            try std.testing.expect(kindAt(doc, 2).? == .doc);
            try std.testing.expect(kindAt(doc, 5).? == .doc);
        }
    };
    try forBothFormats("a\n\nb\n", S.check);
}

test "caret: the chain from a caret at a paragraph's end reaches the paragraph" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            var chain: std.ArrayList(AST.Node.Id) = .empty;
            defer chain.deinit(std.testing.allocator);
            try caretChain(std.testing.allocator, doc, src.len - 1, &chain);

            var saw_para = false;
            var saw_item = false;
            for (chain.items) |id| {
                switch (std.meta.activeTag(doc.ast.nodes[id].kind)) {
                    .para => saw_para = true,
                    .list_item => saw_item = true,
                    else => {},
                }
            }
            try std.testing.expect(saw_para);
            try std.testing.expect(saw_item);
        }
    };
    // The caret sits after `b`, at the end of the nested item — the position
    // Enter and Backspace are pressed from.
    try forBothFormats("- a\n  - b\n", S.check);
}

test "linePrefixSpan: a nested item's prefix includes the indent it sits behind" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            // Caret in `b`, on the inner item's line. That item's own marker is
            // `- `, but the two spaces before it are hidden width too — they
            // are the OUTER item's content indent, which no node records as a
            // marker of its own. Reaching back to the line start is what picks
            // them up.
            const at = std.mem.indexOfScalar(u8, src, 'b').?;
            try std.testing.expectEqualStrings("  - ", linePrefixText(doc, at).?);
            // The outer item's own line is indent-free.
            const a = std.mem.indexOfScalar(u8, src, 'a').?;
            try std.testing.expectEqualStrings("- ", linePrefixText(doc, a).?);
        }
    };
    // Blank-line separated on purpose: `- a\n  - b` is a NESTED LIST in
    // Markdown and a paragraph holding the literal text `- b` in djot, because
    // djot does not let a list marker interrupt a paragraph. Sharing a source
    // whose STRUCTURE differs would be testing the parsers, not this walk — the
    // divergence has its own test below.
    try forBothFormats("- a\n\n  - b\n", S.check);
}

test "linePrefixSpan: no marker where the format read no item" {
    // The same bytes, the two formats genuinely disagreeing. In Markdown line
    // two opens a nested item and has a marker; in djot it is literal text
    // inside item `a`'s paragraph, and there is nothing hidden on it at all.
    //
    // Reporting `  - ` for djot here is exactly the bug this table exists to
    // stop: an editor that "outdents" that line restructures a document that
    // never had a nested item in it.
    const gpa = std.testing.allocator;
    const src = "- a\n  - b\n";

    var md = try Markdown.parse(gpa, src, .{});
    defer md.deinit();
    const md_doc = md.document();
    try std.testing.expectEqualStrings("  - ", linePrefixText(&md_doc, src.len - 2).?);

    var dj = try Djot.parse(gpa, src);
    defer dj.deinit();
    const dj_doc = dj.document();
    try std.testing.expect(linePrefixText(&dj_doc, src.len - 2) == null);
}

test "linePrefixSpan: every marker on the line, from quote to checkbox" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            const at = std.mem.indexOfScalar(u8, src, 'x').?;
            try std.testing.expectEqualStrings("> - [ ] ", linePrefixText(doc, at).?);
        }
    };
    try forBothFormats("> - [ ] x\n", S.check);
}

test "linePrefixSpan: a heading's marker, and none on a continuation line" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            try std.testing.expectEqualStrings("## ", linePrefixText(doc, 4).?);
            // Line three continues the quote but OPENS nothing, so there is no
            // marker to report. What a continuation line repeats is a separate
            // question — see `linePrefixSpan`'s doc comment.
            const at = std.mem.indexOfScalar(u8, src, 'd').?;
            try std.testing.expect(linePrefixText(doc, at) == null);
        }
    };
    try forBothFormats("## h\n\n> c\n> d\n", S.check);
}

/// `continuationPrefix` as an owned string, for the tests below.
fn contPrefix(doc: *const Document, offset: usize) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(std.testing.allocator);
    const cols = try continuationPrefix(std.testing.allocator, doc, offset, &out);
    // Every prefix these tests build is space/`>`-only, so columns and bytes
    // agree; a tab case asserts the divergence separately.
    try std.testing.expectEqual(out.items.len, cols);
    return out.toOwnedSlice(std.testing.allocator);
}

test "continuationPrefix: a quote repeats its marker, an item indents past it" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            const at = std.mem.indexOfScalar(u8, src, 'a').?;
            const p = try contPrefix(doc, at);
            defer std.testing.allocator.free(p);
            // The quote's `> ` REPRODUCED (dropping it would end the quote) and
            // the item's `- ` as WIDTH (repeating it would open a second item).
            try std.testing.expectEqualStrings(">   ", p);
        }
    };
    try forBothFormats("> - a\n", S.check);
}

test "continuationPrefix: nesting accumulates across different lines" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            const at = std.mem.indexOfScalar(u8, src, 'b').?;
            const p = try contPrefix(doc, at);
            defer std.testing.allocator.free(p);
            // The outer item's marker is on line one and the inner item's on
            // line two — two different lines, which is why this walks the tree
            // rather than reading the caret's own line.
            try std.testing.expectEqualStrings("    ", p);
        }
    };
    try forBothFormats("- a\n\n  - b\n", S.check);
}

test "continuationPrefix: an ordered marker's own width, not a fixed indent" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            const at = std.mem.indexOfScalar(u8, src, 'x').?;
            const p = try contPrefix(doc, at);
            defer std.testing.allocator.free(p);
            // `10. ` is four columns where `1. ` is three. A fixed indent is
            // the assumption that makes Tab wrong on the tenth item.
            try std.testing.expectEqualStrings("    ", p);
        }
    };
    try forBothFormats("10. x\n", S.check);
}

test "continuationPrefix: a continuation line answers for the line it is on" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            // The caret is on line two, which OPENS nothing — the case
            // `linePrefixSpan` declines. Each ancestor still answers from its
            // own opening line, so the quote's marker is found on line one.
            const at = std.mem.indexOfScalar(u8, src, 'd').?;
            try std.testing.expect(linePrefixText(doc, at) == null);
            const p = try contPrefix(doc, at);
            defer std.testing.allocator.free(p);
            try std.testing.expectEqualStrings("> ", p);
        }
    };
    try forBothFormats("> c\n> d\n", S.check);
}

test "continuationPrefix: nothing at the top level" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            const p = try contPrefix(doc, src.len - 1);
            defer std.testing.allocator.free(p);
            try std.testing.expectEqualStrings("", p);
        }
    };
    try forBothFormats("plain\n", S.check);
}

test "blankLinePrefix: a quote keeps its marker, an item keeps nothing" {
    const S = struct {
        fn check(doc: *const Document, src: []const u8) anyerror!void {
            var out: std.ArrayList(u8) = .empty;
            defer out.deinit(std.testing.allocator);
            const at = std.mem.indexOfScalar(u8, src, 'a').?;
            const cols = try blankLinePrefix(std.testing.allocator, doc, at, &out);
            // `>` and not `> `: the space after the marker is content indent,
            // and a blank line has no content. The item's indent goes entirely.
            try std.testing.expectEqualStrings(">", out.items);
            try std.testing.expectEqual(@as(usize, 1), cols);
        }
    };
    try forBothFormats("> - a\n", S.check);
}

test "continuationPrefix: a tab in a marker advances to a tab stop" {
    // Columns and bytes diverge here, which is why the width is returned rather
    // than left to the caller to take as `prefix.len`.
    const gpa = std.testing.allocator;
    var md = try Markdown.parse(gpa, "-\tx\n", .{});
    defer md.deinit();
    const doc = md.document();

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(gpa);
    const at = std.mem.indexOfScalar(u8, doc.source, 'x').?;
    const cols = try continuationPrefix(gpa, &doc, at, &out);
    // `-` then a tab to column 4, so the content sits at column 4 and a
    // continuation line needs four columns of indent — not the two bytes a
    // naive `marker.len` would report.
    try std.testing.expectEqual(@as(usize, 4), cols);
    try std.testing.expectEqual(@as(usize, 4), out.items.len);
}

test "an offset past every span resolves to nothing" {
    const Xml = @import("../languages/xml/xml.zig");
    const gpa = std.testing.allocator;
    var ast = try Xml.parse(gpa, "<r/>");
    defer ast.deinit();
    // Well past the source: no node can cover it.
    try std.testing.expect(deepestContaining(&ast, 999) == null);
}