twig-sys 3.4.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
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
//! The format registry: the single place a new Twig language plugs in.
//!
//! Each `Entry` bundles everything that varies by language behind one uniform
//! shape — a parser adapter (`parse`), the `Document` reparse adapter the
//! `Splicer` needs (`parseToAst`), an HTML renderer (`renderHtml`), optional
//! serializers, and an optional `Syntax` table — so no consumer needs a
//! per-language `switch` of its own. Adding a language is "write a few small
//! adapters, add one `registry` entry".
//!
//! ── Why this isn't in `cli/` ───────────────────────────────────────────────
//! It used to be. The C ABI can't import the CLI, so it grew its own parallel
//! copy: the same four `parseToAst` adapters (its own comment admitted
//! "Mirrors `cli/format.zig`'s"), its own `ParseConfig`, and a hand-written
//! `switch (format)` per operation where this table has a field. Two copies of
//! one table is a drift bug waiting to happen — and the second copy sat behind
//! an `extern` boundary, so only a C caller could reach or test it. Living
//! here, both the CLI and the C ABI read the same row.
//!
//! ── Optional fields are the raggedness ─────────────────────────────────────
//! Twig's languages are not interchangeable. Every one parses and renders, but
//! AsciiDoc has no serializer at all, nothing can be written INTO XML from
//! another format, and only djot and Markdown have a `syntax` — a `null` says so
//! once, in whichever of the two tables owns the question, and every caller
//! turns it into the same "unsupported" error instead of rediscovering the fact
//! in an `else =>` arm. See `syntax.zig` for that argument in full.
//!
//! ── Two axes: what Twig READS and what Twig WRITES ─────────────────────────
//! There are two tables here, not one. `registry` is keyed by `Format` — the
//! languages Twig can PARSE — and `targets` is keyed by `Target` — the formats
//! Twig can WRITE. Every `Format` is also a `Target`, so the two lists coincide
//! today, and they are still separate types.
//!
//! The reason is that the enums answer different questions and only one of them
//! can grow freely. `Format` is what `ParsedDoc.format` records: a variant
//! there must have a parser and a reparse adapter for the `Splicer`. A
//! `Target` needs none of that — it needs somewhere for bytes to go. An
//! EXPORT-ONLY target (one Twig can write and no parser can read back; PDF is
//! the motivating case) is expressible as a `Target` and is NOT expressible as a
//! `Format`, and before the split there was nowhere to put it that did not also
//! claim Twig could parse it.
//!
//! The split was already latent rather than hypothetical: `diagnostics.zig`'s
//! `fidelity(target, kind)` has always indexed its capability table on an output
//! axis while spelling the parameter `Format`, and `cli/format.zig` had already
//! named its `-i` re-export `InputFormat` to distinguish it from what `-o`
//! accepts. `serializeFromAst` moved with it, from `Entry` to `TargetEntry`: it
//! is keyed by where the bytes are going, not by what parsed them.
//! `serializeCanonical` stayed on `Entry`: it serializes a parsed `Document`
//! with the label tables its own parser filled, and so is a fact about the
//! input row — see its doc comment for what still separates it from
//! `serializeFromAst` now that both take the shared types.

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

const AST = @import("ast/ast.zig");
const Document = @import("document.zig");
const Djot = @import("languages/djot/djot.zig");
const Markdown = @import("languages/markdown/markdown.zig");
const Xml = @import("languages/xml/xml.zig");
const Html = @import("languages/html/html.zig");
const Asciidoc = @import("languages/asciidoc/asciidoc.zig");
const Splicer = @import("ast/splicer.zig").Splicer;
const syntax_mod = @import("syntax.zig");
const Syntax = syntax_mod.Syntax;

/// Markdown's spelling tables — plural, because Markdown's authorable subset
/// depends on its parse config. See `Entry.syntaxFor`.
const markdown_syntax = @import("languages/markdown/syntax.zig");

const djot_serializer = Djot.serializer;
const markdown_serializer = Markdown.serializer;
const asciidoc_serializer = Asciidoc.serializer;

/// Every language Twig can PARSE — the `-i`/`--input` vocabulary, what
/// `ParsedDoc.format` records, and what the C ABI's `TwigFormat` wire codes
/// decode to on the parse path. Deliberately has NO explicit values: the integers are
/// the C ABI's contract, so they live there (`c_abi.zig`'s `intToFormat`), not
/// here.
///
/// This is the INPUT axis only. What Twig can write is `Target` — see the
/// two-axes note at the top of this file.
pub const Format = enum {
    djot,
    markdown,
    xml,
    html,
    asciidoc,
    /// Strict CommonMark 0.31.2: Markdown with every extension off. A DIALECT
    /// of `markdown` — one parser, one serializer, one `Target` — that the
    /// registry carries as a row of its own so `-i commonmark` and
    /// `TWIG_FORMAT_COMMONMARK` name it, the way fig's `json`/`jsonc`/`json5`
    /// are three rows over one language. See `Entry.dialect_of`.
    commonmark,
    /// GitHub-Flavored Markdown: the spec's four extensions and GFM's HTML
    /// conventions (`align=` on a cell, not `style=`). A dialect of
    /// `markdown`, as `commonmark` is.
    gfm,
};

/// Every format Twig can WRITE — what `-o`/`--output` names beyond its three
/// `OutputMode` words, the axis `diagnostics.fidelity` is indexed by, and the
/// key of the `targets` table.
///
/// Spelled out rather than derived from `Format`, even though the two lists
/// coincide today. A generated enum would make the interesting case — a variant
/// that is a target and NOT a format — invisible at the point a reader looks for
/// it, and would put the subset relation in a comptime expression instead of in
/// a test that says what it is checking. The relation is enforced by `test
/// "every Format is also a Target"`: the same hand-maintained-table-plus-test
/// trust boundary `registry` itself relies on.
///
/// An export-only target appends HERE and nowhere else. It gets a `targets` row
/// with `reads_back_as = null` and no `Format` variant, no `registry` row, and
/// no `Syntax` — none of which it could honestly fill in. That is the whole
/// reason this enum exists apart from `Format`.
pub const Target = enum {
    djot,
    markdown,
    xml,
    html,
    asciidoc,

    /// The `Format` whose parser reads this target's own output back, or `null`
    /// for an export-only target. `null` is what makes a round-trip
    /// unstateable, which is why `diagnostics.zig`'s probe skips such a target
    /// rather than guessing at an answer for it.
    pub fn asFormat(self: Target) ?Format {
        return targetEntryFor(self).reads_back_as;
    }
};

/// The output target that writes `fmt`'s own syntax. TOTAL — every input format
/// is also a target, including the ones with no serializer yet, because
/// `-o asciidoc` has to reach "not supported yet" rather than "unknown target".
///
/// A dialect writes as its LANGUAGE: Markdown has one serializer however it was
/// parsed, so `commonmark` and `gfm` land on `.markdown` and `Target` does not
/// grow a row per dialect. Those are the only arms spelled out; for every
/// language row totality is a compile error rather than a test, since `@field`
/// fails to resolve if a `Format` name is missing from `Target`. The dialect
/// arms agree with `Entry.dialect_of` by a test below.
pub fn targetFor(fmt: Format) Target {
    return switch (fmt) {
        .commonmark, .gfm => .markdown,
        inline else => |f| @field(Target, @tagName(f)),
    };
}

/// Whether `target` writes the syntax `fmt` was parsed from — the question
/// `-o canonical` and `twig_document_serialize` ask before choosing between
/// the input row's `serializeCanonical` (the parsed `Document` whole, spelling
/// and labels intact) and the target's bare-`AST` `serializeFromAst`. Asked
/// through `targetFor` rather than `==` so a GFM document serialized `-o
/// markdown` takes the faithful path: it IS Markdown. An export-only target is
/// never any format's own, and takes the cross-format path, the only one it has.
pub fn writesOwnSyntax(fmt: Format, target: Target) bool {
    return targetFor(fmt) == target;
}

/// Per-invocation parse configuration, threaded from a consumer's feature flags
/// into the `parse`/`parseToAst` adapters. Passed as an opaque `*const anyopaque`
/// (so `ast/splicer.zig` can carry it across reparses without depending on this
/// type — see `Splicer.ParseFn`); every adapter that reads it `@ptrCast`s it
/// back. Only Markdown's rows consult it today; other formats' adapters ignore it.
///
/// This is what a caller adds ON TOP of the row it chose, never the row itself.
/// Which Markdown dialect a document is — strict, GFM, or the default — is the
/// `Format`, and the row carries that preset; the config is the opt-in
/// `Extensions` laid over it. It used to hold the whole `ParseOptions`, and a
/// dialect was then a value a caller wrote into the config while the `Format`
/// still said plain `markdown` — the same fact stated in two places, which
/// `ParsedDoc`'s doc comment below argues against.
pub const ParseConfig = struct {
    markdown: Markdown.ParseOptions.Extensions = .{},

    /// Recover a `*const ParseConfig` from the opaque pointer the registry
    /// adapters / the splicer pass around.
    pub fn from(ctx: *const anyopaque) *const ParseConfig {
        return @ptrCast(@alignCast(ctx));
    }
};

/// A parsed document: the shared `Document` plus the two facts about HOW it
/// was parsed that the tree does not record — which `Format`'s parser
/// produced it, and the `ParseConfig` that parser was given.
///
/// This used to be a `union(Format)`, because djot and Markdown each returned
/// a wrapper of their own carrying label -> definition maps the shared
/// `Document` had no column for. Those maps are `Document.labels` now, so
/// every parser returns the one type and the union had nothing left to
/// distinguish. What remains is a thin wrapper, and both its fields earn
/// their place: `format` picks the `registry` row for every later operation
/// — and for Markdown that row IS the dialect, so `renderHtml` spells a
/// table's alignment the way the format the document was parsed as does,
/// and a caller cannot parse a document as GFM and print it as CommonMark
/// by forgetting to say so twice. `config` is the extensions laid over that
/// row, which the editor's reparse and `syntaxForConfig` need to agree on.
pub const ParsedDoc = struct {
    format: Format,
    config: ParseConfig,
    doc: Document,

    /// The shared `AST` underneath — MEANING only.
    pub fn ast(self: *const ParsedDoc) *const AST {
        return &self.doc.ast;
    }

    /// A BORROWED `Document` view — the tree plus the positions addressing
    /// `source`, by value. What the edit layer and
    /// `languages/xml/serializer.zig` take; must NOT be `deinit`ed (this
    /// struct owns the storage).
    pub fn document(self: *const ParsedDoc) Document {
        return self.doc;
    }

    pub fn deinit(self: *ParsedDoc) void {
        self.doc.deinit();
    }
};

fn parseDjot(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
    const cfg = ParseConfig.from(ctx);
    return .{ .format = .djot, .config = cfg.*, .doc = try Djot.parse(allocator, source) };
}

/// The adapters for one Markdown DIALECT: the parser under `preset`, with the
/// caller's `ParseConfig.markdown` extensions laid over it. One instantiation
/// per Markdown row in `registry`, so the preset is stated on the row and
/// nowhere else — the parse, the splicer's reparse, the HTML render's
/// conventions and the editor's spelling table all read it from here.
fn MarkdownDialect(comptime id: Format, comptime preset: Markdown.ParseOptions) type {
    return struct {
        /// The spelling table under a default config — what the row's
        /// `syntax` points at, by address, so the serializer and the editor
        /// cannot read two different answers for one document.
        const syntax: *const Syntax = markdown_syntax.forOptions(preset);

        fn options(ctx: *const anyopaque) Markdown.ParseOptions {
            return Markdown.ParseOptions.withExtensions(preset, ParseConfig.from(ctx).markdown);
        }

        fn parse(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
            const cfg = ParseConfig.from(ctx);
            return .{ .format = id, .config = cfg.*, .doc = try Markdown.parse(allocator, source, options(ctx)) };
        }

        fn parseToAst(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
            return Markdown.parse(allocator, source, options(ctx));
        }

        /// Markdown needs its own HTML rendering path (`Markdown.html.render`)
        /// rather than the generic printer for the same reason djot does
        /// (`renderHtmlDjot`'s doc comment): footnotes resolve/number/backlink
        /// entirely at RENDER time, against `Document.labels.footnotes` — see
        /// `markdown/html.zig`'s module doc comment. Using the generic printer
        /// here would silently drop footnotes (every `link`/`image`, by
        /// contrast, is already fully resolved at PARSE time, so those are
        /// unaffected either way). The render dialect is the row's, which is
        /// the one place it is stated.
        fn renderHtml(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
            try Markdown.html.render(allocator, &doc.doc, writer, .{ .dialect = preset.dialect });
        }

        /// The `Entry.syntaxFor` for this row: the extensions decide whether
        /// `==x==` reads back as a `mark`, and so whether an editor may write
        /// one, over whatever the preset already reads.
        fn syntaxFor(cfg: *const ParseConfig) *const Syntax {
            return markdown_syntax.forOptions(Markdown.ParseOptions.withExtensions(preset, cfg.markdown));
        }
    };
}

/// Twig's default Markdown: CommonMark plus the default-on extensions.
const MarkdownDefault = MarkdownDialect(.markdown, .{});
const Commonmark = MarkdownDialect(.commonmark, Markdown.ParseOptions.commonmark);
const Gfm = MarkdownDialect(.gfm, Markdown.ParseOptions.gfm);

fn parseXml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
    const cfg = ParseConfig.from(ctx);
    return .{ .format = .xml, .config = cfg.*, .doc = try Xml.parse(allocator, source) };
}

fn parseHtml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
    const cfg = ParseConfig.from(ctx);
    return .{ .format = .html, .config = cfg.*, .doc = try Html.parse(allocator, source) };
}

fn parseAsciidoc(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
    const cfg = ParseConfig.from(ctx);
    return .{ .format = .asciidoc, .config = cfg.*, .doc = try Asciidoc.parser.parse(allocator, source) };
}

// ── splicer reparse adapters ───────────────────────────────────────────────
// The span-splice engine (`Splicer`) reparses after every edit and holds the
// shared `Document` — spans, structure, and now the label tables too, since
// they are a column of it. Each adapter is the language's own `parse`, minus
// the `ParsedDoc` wrapper the splicer has no use for.

fn parseToAstDjot(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
    _ = ctx;
    return Djot.parse(allocator, source);
}

fn parseToAstXml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
    _ = ctx;
    return Xml.parse(allocator, source);
}

fn parseToAstHtml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
    _ = ctx;
    return Html.parse(allocator, source);
}

fn parseToAstAsciidoc(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
    _ = ctx;
    return Asciidoc.parser.parse(allocator, source);
}

/// Djot needs its own HTML rendering path (`Djot.html.render`) rather than the
/// generic printer: it resolves reference/footnote labels against
/// `Document.labels` at render time (see `djot/html.zig`'s module doc
/// comment), and the generic `Html.serialize` with `ctx = null` is handed no
/// tables. Using the generic printer here would silently drop footnotes and
/// reference-style links.
fn renderHtmlDjot(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
    try Djot.html.render(allocator, &doc.doc, writer, .{});
}

/// Every other language (XML and HTML) has no labels to resolve, so the
/// shared, language-neutral printer (`languages/html/serializer.zig`) is the
/// whole story — `ctx = null`.
fn renderHtmlGeneric(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
    try Html.serialize(allocator, doc.ast(), writer, null);
}

fn serializeCanonicalXml(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    return Xml.serializeAlloc(allocator, &doc.doc);
}

fn serializeCanonicalDjot(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    return djot_serializer.serializeAlloc(allocator, &doc.doc);
}

fn serializeCanonicalMarkdown(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    return markdown_serializer.serializeAlloc(allocator, &doc.doc);
}

/// HTML's printer renders the full shared vocabulary from a bare AST, so it
/// serves as both the round-trip and the cross-format path (`ctx = null`: this
/// is the label-free printer; `renderHtmlDjot`/`renderHtmlMarkdown` are the
/// richer, label-resolving renders).
///
/// These two are NEW to the registry and not new to Twig: the C ABI's
/// `serializeDocument` has always served HTML on both paths, while this table —
/// its other copy — claimed HTML had no serializer at all and made
/// `twig convert -i html -o canonical` fail. Neither copy was consulted by the
/// other, so nothing caught the disagreement. One table, one answer.
fn serializeCanonicalHtml(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    return Html.serializeAlloc(allocator, doc.ast(), null);
}

fn serializeFromAstHtml(allocator: Allocator, ast: *const AST) anyerror![]u8 {
    return Html.serializeAlloc(allocator, ast, null);
}

fn serializeFromAstDjot(allocator: Allocator, ast: *const AST) anyerror![]u8 {
    return djot_serializer.serializeAstAlloc(allocator, ast);
}

fn serializeFromAstMarkdown(allocator: Allocator, ast: *const AST) anyerror![]u8 {
    return markdown_serializer.serializeAstAlloc(allocator, ast);
}

fn serializeCanonicalAsciidoc(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    return asciidoc_serializer.serializeAlloc(allocator, &doc.doc);
}

fn serializeFromAstAsciidoc(allocator: Allocator, ast: *const AST) anyerror![]u8 {
    return asciidoc_serializer.serializeAstAlloc(allocator, ast);
}

/// One entry per `Format`. This IS the extensibility point Twig is built
/// around: consumers are written entirely against this table, never against a
/// per-language switch of their own.
pub const Entry = struct {
    id: Format,
    /// The language this row is a DIALECT of, or `null` for a language's own
    /// row. A dialect is a name for one configuration of another row's parser
    /// — strict CommonMark, GFM — carried as a row so that `-i`, `ParsedDoc`
    /// and the C ABI can say it in one word. It shares its language's `Target`
    /// (`targetFor`), declares no `extensions` of its own (a `.md` file is the
    /// default flavor until a caller says otherwise), and is listed under its
    /// language by `printSupportedInputFormats`.
    dialect_of: ?Format = null,
    /// Small source documents checked by the shared language harness.
    /// An empty declaration fails the harness; corpora belong to conformance tests.
    samples: []const []const u8 = &.{},
    /// Lowercase, dot-less extensions that infer this format (checked
    /// case-insensitively against a path's last `.`-separated segment).
    extensions: []const []const u8,
    /// Extra input names accepted besides `@tagName(id)` itself (which
    /// `parseFormatName` always accepts via `std.meta.stringToEnum`).
    aliases: []const []const u8 = &.{},
    parse: *const fn (*const anyopaque, Allocator, []const u8) anyerror!ParsedDoc,
    /// Source -> the shared `Document`, the reparse callback the span-splice
    /// engine (`Splicer`) needs: `parse` without the `ParsedDoc` wrapper,
    /// because the splicer already knows its format and config. Every format
    /// has one. Its shape matches `Splicer.ParseFn` (leading opaque
    /// `ParseConfig` context) so it can be handed straight to `Splicer.init`.
    parseToAst: Splicer.ParseFn,
    renderHtml: *const fn (Allocator, *const ParsedDoc, *Writer) anyerror!void,
    /// Round-trip serializer back to this format's own source syntax —
    /// `convert -o canonical`'s implementation. `null` means the language has no
    /// serializer yet; callers turn that into a clear "not supported yet" error
    /// rather than a crash.
    ///
    /// The one serializer that stays on the INPUT row. It takes the parsed
    /// `Document` whole — the `labels` its own parser filled (which say which
    /// of two same-labelled definitions wins), the `node_spelling` that puts
    /// an author's `*` bullets back, and for XML the interior spans that mark
    /// a self-closing tag — where `TargetEntry.serializeFromAst` takes a bare
    /// `AST` and rebuilds what it can (`Document.Labels.index`, canonical
    /// spelling). The two are the same printer under two amounts of
    /// knowledge; a caller with a `ParsedDoc` in hand should use this one.
    serializeCanonical: ?*const fn (Allocator, *const ParsedDoc) anyerror![]u8 = null,
    /// This format's surface spelling — the table the authoring gestures in
    /// `ast/editor.zig` consult. Defaults to `Syntax.none`, the table that
    /// spells nothing: a language that can be parsed and rendered but not
    /// AUTHORED into (XML, HTML) simply omits this field, and every gesture over
    /// it reports unsupported by finding the same `null` in the same table any
    /// other unspellable kind would. See `syntax.zig`.
    syntax: *const Syntax = &syntax_mod.none,
    /// This format's surface spelling for a PARSE CONFIG, where the two are not
    /// the same question. `null` — every row but Markdown's — means the
    /// spelling does not move with the extensions, so `syntax` is the whole
    /// answer.
    ///
    /// Markdown's does move, and in BOTH directions from its defaults: `==x==`
    /// is literal text until `ParseOptions.highlight` is turned on, `~~x~~` is
    /// a `delete` until `strikethrough` is turned off. Whether an editor may
    /// WRITE either depends on what the editor's own reparse will read back,
    /// and the alternative — one table stating a default as though it were a
    /// property of the format — mints bytes that come back as a `str`, giving
    /// a Cmd-B that cannot be undone by pressing it again. See
    /// `languages/markdown/syntax.zig`'s `forOptions`, which owns the choice;
    /// this field only says the row has one to make.
    ///
    /// `syntax` stays the DEFAULT-config table and is what a serializer reads,
    /// so a row carrying both must agree with itself under a default config —
    /// pinned by a test below rather than by convention. That agreement is by
    /// ADDRESS: Markdown's row points into the same table set `forOptions`
    /// indexes, so the two cannot become two answers.
    syntaxFor: ?*const fn (*const ParseConfig) *const Syntax = null,
};

pub const registry = [_]Entry{
    .{
        .id = .djot,
        .samples = Djot.samples,
        .extensions = &.{ "dj", "djot" },
        .aliases = &.{"dj"},
        .parse = parseDjot,
        .parseToAst = parseToAstDjot,
        .renderHtml = renderHtmlDjot,
        .serializeCanonical = serializeCanonicalDjot,
        .syntax = &@import("languages/djot/syntax.zig").table,
    },
    .{
        .id = .markdown,
        .samples = Markdown.samples,
        .extensions = &.{ "md", "markdown" },
        .aliases = &.{"md"},
        .parse = MarkdownDefault.parse,
        .parseToAst = MarkdownDefault.parseToAst,
        .renderHtml = MarkdownDefault.renderHtml,
        .serializeCanonical = serializeCanonicalMarkdown,
        .syntax = MarkdownDefault.syntax,
        // The rows whose authorable subset moves with the parse config: this
        // one and its two dialects below.
        .syntaxFor = MarkdownDefault.syntaxFor,
    },
    .{
        // Strict CommonMark — `markdown` with every extension off. Same
        // parser, same serializer, same `Target`; a different preset, and so
        // a different default `syntax`: `~~x~~` is two literal tildes here
        // and no gesture may mint it. No `extensions`: nothing about a file
        // name says it is strict.
        .id = .commonmark,
        .dialect_of = .markdown,
        .samples = Markdown.commonmark_samples,
        .extensions = &.{},
        .parse = Commonmark.parse,
        .parseToAst = Commonmark.parseToAst,
        .renderHtml = Commonmark.renderHtml,
        .serializeCanonical = serializeCanonicalMarkdown,
        .syntax = Commonmark.syntax,
        .syntaxFor = Commonmark.syntaxFor,
    },
    .{
        // GitHub-Flavored Markdown — the spec's four extensions and GFM's
        // HTML conventions, which is the one thing about this row a bare
        // flag set could not say (`ParseOptions.dialect`'s doc comment).
        .id = .gfm,
        .dialect_of = .markdown,
        .samples = Markdown.gfm_samples,
        .extensions = &.{},
        .parse = Gfm.parse,
        .parseToAst = Gfm.parseToAst,
        .renderHtml = Gfm.renderHtml,
        .serializeCanonical = serializeCanonicalMarkdown,
        .syntax = Gfm.syntax,
        .syntaxFor = Gfm.syntaxFor,
    },
    .{
        .id = .xml,
        .samples = Xml.samples,
        .extensions = &.{"xml"},
        .parse = parseXml,
        .parseToAst = parseToAstXml,
        .renderHtml = renderHtmlGeneric,
        .serializeCanonical = serializeCanonicalXml,
        // Why converting INTO xml from another format isn't meaningful yet is
        // now a fact about the xml TARGET row (`targets`, below), not this one.
        //
        // No `syntax`: XML has no lightweight inline markup to toggle and no
        // line-prefix containers, so it is parse-and-render only.
    },
    .{
        .id = .html,
        .samples = Html.samples,
        .extensions = &.{ "html", "htm" },
        .parse = parseHtml,
        .parseToAst = parseToAstHtml,
        .renderHtml = renderHtmlGeneric,
        .serializeCanonical = serializeCanonicalHtml,
        // A PARTIAL `syntax`, and the row that proves `Syntax`'s raggedness is
        // real rather than a two-formats-and-the-rest split. HTML has none of
        // the lightweight markup this table was built for, but a tag pair IS a
        // `Delims`: it spells seven of the nine inline marks, `<code>`, `<hr>`
        // and `<br>`, and its parser reads every one of them back. A heading
        // and a literal are spelled through its RENDERERS — a printed fragment
        // and entities — since neither is a marker or a backslash alphabet.
        // Its quote, list and fence spellings still have the wrong SHAPE for
        // the fields that hold them, so those stay `null` and their gestures
        // stay unsupported. See `languages/html/syntax.zig` for which and why.
        .syntax = &@import("languages/html/syntax.zig").table,
    },
    .{
        .id = .asciidoc,
        .samples = Asciidoc.samples,
        .extensions = &.{ "adoc", "asciidoc" },
        .aliases = &.{"adoc"},
        .parse = parseAsciidoc,
        .parseToAst = parseToAstAsciidoc,
        .renderHtml = renderHtmlGeneric,
        .serializeCanonical = serializeCanonicalAsciidoc,
        // AsciiDoc's parser covers the language as the ASG schema enumerates
        // it (`languages/asciidoc/parser.zig`'s doc comment lists the
        // constructs, and the handful it leaves unmodelled). What it does not
        // recognize survives as LITERAL SOURCE TEXT, so an unhandled
        // `menu:File[Save]` renders as those very characters — visibly
        // unhandled — rather than as a mangled tree. That property is what
        // let this row exist before the parser was complete, and it still
        // holds at the parser's edges.
        //
        // A PARTIAL `syntax`, ragged the way HTML's is and for the same
        // reason: the inline marks, the heading marker, the three container
        // prefixes, the fence, the task box and the escape alphabets all fit
        // the gesture algorithms' shapes; a link (`dest[text]`, the halves in
        // the other order from `[text](dest)`), a footnote (one macro, no
        // definition line) and a table (`|===`-fenced, no delimiter row) do
        // not, and those gestures stay unsupported. See
        // `languages/asciidoc/syntax.zig`.
        .syntax = &Asciidoc.syntax.table,
    },
};

/// One entry per `Target` — the WRITE half of the registry. Split from `Entry`
/// (the READ half) for the reason the two-axes note at the top of this file
/// gives: what a target needs is somewhere for bytes to go, which is strictly
/// less than what a language needs to be parsed.
pub const TargetEntry = struct {
    id: Target,
    /// The `Format` whose parser reads this target's own output back — set for
    /// every target that is also an input language, `null` for an export-only
    /// one. This single field is what distinguishes the two kinds of target, and
    /// it is what `diagnostics.zig`'s round-trip probe reparses with.
    reads_back_as: ?Format,
    /// Serialize a BARE shared `AST` (regardless of which format parsed it) as
    /// this target's own syntax — `convert -o <target>`'s cross-format
    /// implementation (e.g. `-i markdown -o djot`), and the C ABI's builder
    /// output path. Unlike `Entry.serializeCanonical` it needs no parse to
    /// have happened: it is handed whatever `ParsedDoc.ast()` returns and
    /// builds any label tables it needs from that bare tree.
    ///
    /// `null` means Twig cannot write this target yet, and every caller turns it
    /// into the same `error.UnsupportedFormat`. For an export-only target this
    /// is the ONLY function in either table that would be non-null.
    serializeFromAst: ?*const fn (Allocator, *const AST) anyerror![]u8 = null,
};

pub const targets = [_]TargetEntry{
    .{ .id = .djot, .reads_back_as = .djot, .serializeFromAst = serializeFromAstDjot },
    .{ .id = .markdown, .reads_back_as = .markdown, .serializeFromAst = serializeFromAstMarkdown },
    .{
        .id = .xml,
        .reads_back_as = .xml,
        // No `serializeFromAst`: XML's serializer only understands the
        // generic-markup kinds (`element`/`comment`/`doctype`/...) its own
        // parser produces (see `xml/serializer.zig`'s `else => unreachable`); it
        // has no mapping for djot/Markdown's semantic kinds
        // (`heading`/`emph`/`link`/...), so cross-format conversion INTO xml
        // from another format isn't meaningful yet. Same-format `-o canonical` /
        // `-o xml` still works, through the input row's `serializeCanonical`.
    },
    .{ .id = .html, .reads_back_as = .html, .serializeFromAst = serializeFromAstHtml },
    .{ .id = .asciidoc, .reads_back_as = .asciidoc, .serializeFromAst = serializeFromAstAsciidoc },
};

/// Look up `fmt`'s entry. Every `Format` variant has exactly one `registry`
/// entry (enforced by the test below rather than the type system — same trust
/// boundary fig's own hand-maintained tables rely on), so this never
/// legitimately misses.
pub fn entryFor(fmt: Format) *const Entry {
    for (&registry) |*e| {
        if (e.id == fmt) return e;
    }
    unreachable;
}

/// Look up `t`'s write-half entry. Every `Target` variant has exactly one
/// `targets` entry (enforced by the test below, same as `entryFor`), so this
/// never legitimately misses.
pub fn targetEntryFor(t: Target) *const TargetEntry {
    for (&targets) |*e| {
        if (e.id == t) return e;
    }
    unreachable;
}

/// `fmt`'s surface spelling — `Syntax.none` for a parse-only language, never
/// `null`. Ask `.authorable()` if you need to know which.
pub fn syntaxFor(fmt: Format) *const Syntax {
    return entryFor(fmt).syntax;
}

/// `fmt`'s surface spelling for a document parsed with `cfg` — what an EDITOR
/// must consult, because a gesture may only write bytes the reparse behind it
/// reads back the same way.
///
/// `syntaxFor` above is this under a default config, and stays the serializer's
/// question: converting into Markdown spells `==x==` for a `mark` whatever the
/// config says, while a toggle that minted the same bytes without
/// `ParseOptions.highlight` would produce text no reparse turns back into a
/// mark. The other direction — `~~x~~`, which the default flavor reads back
/// and strict CommonMark does not — is no longer this function's to answer:
/// that is a difference between two ROWS, and `syntaxFor(.commonmark)` says
/// so under any config. The two questions differ only for Markdown's rows
/// today, which is why every other row leaves `Entry.syntaxFor` null and gets
/// the same answer from both.
pub fn syntaxForConfig(fmt: Format, cfg: *const ParseConfig) *const Syntax {
    const e = entryFor(fmt);
    const pick = e.syntaxFor orelse return e.syntax;
    return pick(cfg);
}

/// The entry for whichever language produced `doc` — `ParsedDoc.format`
/// records it, so the document knows its own row.
pub fn entryForDoc(doc: *const ParsedDoc) *const Entry {
    return entryFor(doc.format);
}

/// Errors `renderHtmlAlloc` can produce beyond a language's own. Named because
/// the `unsafe_metadata` refusal is a real, reportable outcome and not an
/// internal failure — a `metadata` node whose body contains `</script` can't be
/// emitted into a raw-text `<script>` data island without breaking out of the
/// element.
pub const RenderError = error{ OutOfMemory, UnsafeMetadata };

/// Render `doc` to HTML as an owned buffer — the registry's writer-shaped
/// `renderHtml`, collected. `Writer.Allocating` only ever fails
/// (`error.WriteFailed`) when its own backing allocation does, so it collapses
/// to `error.OutOfMemory`.
pub fn renderHtmlAlloc(allocator: Allocator, doc: *const ParsedDoc) RenderError![]u8 {
    var out: Writer.Allocating = .init(allocator);
    defer out.deinit();
    entryForDoc(doc).renderHtml(allocator, doc, &out.writer) catch |err| switch (err) {
        error.WriteFailed, error.OutOfMemory => return error.OutOfMemory,
        error.UnsafeMetadata => return error.UnsafeMetadata,
        // The registry's adapters are `anyerror`-shaped only because they're
        // function pointers; rendering a already-parsed tree has no other
        // failure mode.
        else => return error.OutOfMemory,
    };
    return out.toOwnedSlice();
}

/// Errors the serialize helpers report on top of a language's own.
pub const SerializeError = error{ OutOfMemory, UnsupportedFormat };

/// Serialize `doc` back to its OWN source syntax (`convert -o canonical`).
/// `error.UnsupportedFormat` when the language has no serializer yet.
pub fn serializeCanonicalAlloc(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
    const f = entryForDoc(doc).serializeCanonical orelse return error.UnsupportedFormat;
    return f(allocator, doc);
}

/// Serialize a bare `AST` as `target`'s syntax, regardless of which language
/// parsed it (`convert -o <target>`, and the C ABI's builder output).
/// `error.UnsupportedFormat` when `target` has no AST serializer.
pub fn serializeFromAstAlloc(allocator: Allocator, ast: *const AST, target: Target) anyerror![]u8 {
    const f = targetEntryFor(target).serializeFromAst orelse return error.UnsupportedFormat;
    return f(allocator, ast);
}

/// Map an input name to a `Format`: the enum's own tag name first
/// (`std.meta.stringToEnum`, so `"djot"`/`"markdown"`/`"gfm"` always work), then
/// each entry's `aliases` (`"dj"`, `"md"`). Returns `null` for an unrecognized
/// name so the caller can print a tailored error.
pub fn parseFormatName(name: []const u8) ?Format {
    if (std.meta.stringToEnum(Format, name)) |f| return f;
    for (&registry) |*e| {
        for (e.aliases) |alias| {
            if (std.mem.eql(u8, alias, name)) return e.id;
        }
    }
    return null;
}

/// Map an output name to a `Target`: the enum's own tag names first, then every
/// input format's aliases via `parseFormatName`, so `-o dj` / `-o md` keep
/// meaning what they always did. An export-only target has no input row to
/// inherit aliases from and so is spelled by its full name only — which is why
/// the aliases are not duplicated into a second list here.
pub fn parseTargetName(name: []const u8) ?Target {
    if (std.meta.stringToEnum(Target, name)) |t| return t;
    if (parseFormatName(name)) |f| return targetFor(f);
    return null;
}

/// Infer a `Format` from a file path's extension (the part after its last `.`),
/// matched case-insensitively against every `registry` entry's `extensions`.
/// Returns `null` when the path has no extension or it matches no known format.
pub fn detectFromExtension(file_path: []const u8) ?Format {
    const dot = std.mem.lastIndexOfScalar(u8, file_path, '.') orelse return null;
    const ext = file_path[dot + 1 ..];
    if (ext.len == 0) return null;
    for (&registry) |*e| {
        for (e.extensions) |known| {
            if (std.ascii.eqlIgnoreCase(known, ext)) return e.id;
        }
    }
    return null;
}

test "every Format has exactly one registry entry" {
    inline for (std.meta.fields(Format)) |f| {
        const fmt: Format = @enumFromInt(f.value);
        var seen: usize = 0;
        for (&registry) |*e| {
            if (e.id == fmt) seen += 1;
        }
        try std.testing.expectEqual(@as(usize, 1), seen);
    }
}

test "every Target has exactly one targets entry" {
    inline for (std.meta.fields(Target)) |f| {
        const t: Target = @enumFromInt(f.value);
        var seen: usize = 0;
        for (&targets) |*e| {
            if (e.id == t) seen += 1;
        }
        try std.testing.expectEqual(@as(usize, 1), seen);
    }
}

test "every Format is also a Target, and the two agree in both directions" {
    // The subset invariant the `Target` doc comment promises, checked rather
    // than generated. `targetFor` is total by construction (a missing name is a
    // compile error); what needs asserting is that the target it lands on says
    // the SAME format reads it back, so nothing can be wired to a row that
    // spells a different language. A dialect lands on its LANGUAGE's target,
    // and the language is what reads that target back — so the two arms
    // `targetFor` spells by hand are pinned to `Entry.dialect_of` here.
    inline for (std.meta.fields(Format)) |f| {
        const fmt: Format = @enumFromInt(f.value);
        const t = targetFor(fmt);
        const lang = entryFor(fmt).dialect_of orelse fmt;
        try std.testing.expectEqualStrings(@tagName(lang), @tagName(t));
        try std.testing.expectEqual(lang, t.asFormat().?);
        try std.testing.expect(writesOwnSyntax(fmt, t));
    }
}

test "a dialect is a row over its language's parser, not a language" {
    // What `dialect_of` promises: a language row is nobody's dialect, a
    // dialect's language is a language row, and the dialect declares no
    // extensions — a `.md` file is the default flavor until `-i` says
    // otherwise. And the name resolves as a format in its own right.
    for (&registry) |*e| {
        const lang = e.dialect_of orelse continue;
        try std.testing.expect(entryFor(lang).dialect_of == null);
        try std.testing.expectEqual(@as(usize, 0), e.extensions.len);
        try std.testing.expectEqual(targetFor(lang), targetFor(e.id));
        try std.testing.expectEqual(e.id, parseFormatName(@tagName(e.id)).?);
    }
    try std.testing.expectEqual(Format.markdown, entryFor(.gfm).dialect_of.?);
    try std.testing.expectEqual(Format.markdown, entryFor(.commonmark).dialect_of.?);
    try std.testing.expect(entryFor(.markdown).dialect_of == null);
    try std.testing.expectEqual(Target.markdown, parseTargetName("gfm").?);
    try std.testing.expect(detectFromExtension("a.md") == .markdown);
}

test "the dialect rows parse what their names say, and record it" {
    // Strict CommonMark reads `~~x~~` as text and a pipe table as a paragraph;
    // GFM reads both. The same bytes, three rows, and each `ParsedDoc` says
    // which it went through.
    const src = "a ~~b~~ c\n\n| x |\n| - |\n| 1 |\n";
    const cfg: ParseConfig = .{};
    inline for (.{
        .{ Format.commonmark, false },
        .{ Format.markdown, true },
        .{ Format.gfm, true },
    }) |case| {
        var doc = try entryFor(case[0]).parse(&cfg, std.testing.allocator, src);
        defer doc.deinit();
        try std.testing.expectEqual(case[0], doc.format);
        var has_delete = false;
        var has_table = false;
        for (doc.ast().nodes) |n| switch (n.kind) {
            .inline_mark => |m| has_delete = has_delete or m == .delete,
            .table => has_table = true,
            else => {},
        };
        try std.testing.expectEqual(case[1], has_delete);
        try std.testing.expectEqual(case[1], has_table);
    }
}

test "an extension lays over a dialect rather than replacing it" {
    // `-i gfm --math` is GFM plus math: the table still parses, and so does
    // the `$x$` that GFM proper does not read. And the editor's spelling table
    // for that pairing is the one the reparse agrees with.
    const src = "| a |\n| - |\n| $x$ |\n";
    const math: ParseConfig = .{ .markdown = .{ .math = true } };
    var doc = try entryFor(.gfm).parse(&math, std.testing.allocator, src);
    defer doc.deinit();
    var has_math = false;
    var has_table = false;
    for (doc.ast().nodes) |n| switch (n.kind) {
        .text_leaf => |t| has_math = has_math or t.kind == .inline_math,
        .table => has_table = true,
        else => {},
    };
    try std.testing.expect(has_math and has_table);

    const hi: ParseConfig = .{ .markdown = .{ .highlight = true } };
    const strict_hi = syntaxForConfig(.commonmark, &hi);
    strict_hi.assertCoherent();
    try std.testing.expect(strict_hi.inline_delims.get(.mark).?.authorable);
    try std.testing.expect(!strict_hi.inline_delims.get(.delete).?.authorable);
}

test "the write half is keyed on the target, not on what parsed it" {
    // `-i markdown -o djot` reaches djot's serializer without markdown's row
    // being consulted for it at all — the property that lets an export-only
    // target exist with no input row of its own. The `null` here is the same
    // "not supported yet" every caller reports, sourced from the TARGET table.
    try std.testing.expect(targetEntryFor(.djot).serializeFromAst != null);
    try std.testing.expect(targetEntryFor(.xml).serializeFromAst == null);
    try std.testing.expectEqual(Target.djot, parseTargetName("dj").?);
    try std.testing.expectEqual(Target.markdown, parseTargetName("markdown").?);
    try std.testing.expect(parseTargetName("nope") == null);
}

test "a config-varying row agrees with its own default table" {
    // `Entry.syntax` and `Entry.syntaxFor` are two spellings of one fact, so
    // the pair has to meet under a default config or the serializer and the
    // editor would be reading different tables for the same document.
    const default_cfg: ParseConfig = .{};
    for (&registry) |*e| {
        const pick = e.syntaxFor orelse continue;
        try std.testing.expectEqual(e.syntax, pick(&default_cfg));
    }
    // The answer moves in both directions from the defaults: the strict
    // CommonMark ROW takes strikethrough AWAY, where the `highlight`
    // extension below adds a mark to any row.
    const strict_syntax = syntaxFor(.commonmark);
    strict_syntax.assertCoherent();
    try std.testing.expect(syntaxFor(.markdown).inline_delims.get(.delete).?.authorable);
    try std.testing.expect(!strict_syntax.inline_delims.get(.delete).?.authorable);
    try std.testing.expect(syntaxFor(.gfm).inline_delims.get(.delete).?.authorable);

    // And the variant tables are tables like any other.
    var hi: ParseConfig = .{};
    hi.markdown.highlight = true;
    const hi_syntax = syntaxForConfig(.markdown, &hi);
    hi_syntax.assertCoherent();
    try std.testing.expect(hi_syntax != syntaxFor(.markdown));
    try std.testing.expect(hi_syntax.inline_delims.get(.mark).?.authorable);
    try std.testing.expect(hi_syntax.mark_colors == null);

    var colors = hi;
    colors.markdown.highlight_colors = true;
    const color_syntax = syntaxForConfig(.markdown, &colors);
    color_syntax.assertCoherent();
    try std.testing.expect(color_syntax.mark_colors != null);

    // A row with no `syntaxFor` answers the same either way — the raggedness
    // stated as a `null`, exactly like every other optional in these tables.
    try std.testing.expectEqual(syntaxFor(.djot), syntaxForConfig(.djot, &colors));
    try std.testing.expectEqual(syntaxFor(.xml), syntaxForConfig(.xml, &colors));
}

test "a ParsedDoc renders with the dialect it was parsed under" {
    // The `format` field's reason to exist: `-i gfm` is said once, at parse,
    // and the render reads it back from the document rather than being told
    // again. Both rows parse this table to the same nodes; only the
    // alignment spelling tells them apart.
    const src = "| a |\n| :-: |\n| 1 |\n";
    const gfm: ParseConfig = .{};
    var g = try entryFor(.gfm).parse(&gfm, std.testing.allocator, src);
    defer g.deinit();
    try std.testing.expectEqual(Format.gfm, g.format);
    const g_html = try renderHtmlAlloc(std.testing.allocator, &g);
    defer std.testing.allocator.free(g_html);
    try std.testing.expect(std.mem.indexOf(u8, g_html, "<th align=\"center\">") != null);

    const plain: ParseConfig = .{};
    var d = try entryFor(.markdown).parse(&plain, std.testing.allocator, src);
    defer d.deinit();
    const d_html = try renderHtmlAlloc(std.testing.allocator, &d);
    defer std.testing.allocator.free(d_html);
    try std.testing.expect(std.mem.indexOf(u8, d_html, "text-align: center") != null);
    try std.testing.expectEqual(Format.markdown, d.format);
}

test "every syntax table in the registry is coherent" {
    for (&registry) |*e| e.syntax.assertCoherent();
}

test "which formats can be authored into at all" {
    try std.testing.expect(syntaxFor(.djot).authorable());
    try std.testing.expect(syntaxFor(.markdown).authorable());
    // HTML joins them PARTIALLY: it spells inline marks, `<code>`, `<hr>`,
    // `<br>`, and — through its renderers rather than a marker or an alphabet
    // — a heading and a literal, so `authorable()` is true while
    // `toggleBlockContainer`, `toggleCodeBlock`, the task, link and footnote
    // gestures all still report unsupported. The predicate is "can ANY gesture
    // be spelled", not "can every one" — see `diagnostics.zig` for the per-kind
    // fidelity question, which is a different one again.
    try std.testing.expect(syntaxFor(.html).authorable());
    try std.testing.expect(syntaxFor(.html).heading_marker == null);
    try std.testing.expect(syntaxFor(.html).renderBlock != null);
    try std.testing.expect(syntaxFor(.html).text_escapes == null);
    try std.testing.expect(syntaxFor(.html).renderText != null);
    // XML alone still carries the table that spells nothing, so every
    // gesture over it is refused; AsciiDoc's table is ragged like HTML's.
    try std.testing.expect(!syntaxFor(.xml).authorable());
    try std.testing.expect(syntaxFor(.asciidoc).authorable());
    try std.testing.expect(syntaxFor(.asciidoc).link_text_escapes == null);
}

test "AsciiDoc parses, renders and serializes in both directions" {
    const entry = entryFor(.asciidoc);
    try std.testing.expect(entry.serializeCanonical != null);
    try std.testing.expect(targetEntryFor(.asciidoc).serializeFromAst != null);
    try std.testing.expectEqual(Format.asciidoc, parseFormatName("adoc").?);
    try std.testing.expectEqual(Format.asciidoc, detectFromExtension("guide.ADOC").?);

    // Canonical: the input row's own serializer, over the parsed document.
    var doc = try parseAsciidoc(&ParseConfig{}, std.testing.allocator, "= Title\n\nsome *bold* text\n");
    defer doc.deinit();
    const canonical = try serializeCanonicalAlloc(std.testing.allocator, &doc);
    defer std.testing.allocator.free(canonical);
    try std.testing.expectEqualStrings("= Title\n\nsome *bold* text\n", canonical);

    // Cross-format: a Markdown tree written as AsciiDoc.
    var md = try MarkdownDefault.parse(&ParseConfig{}, std.testing.allocator, "# Title\n\nsome **bold** text\n");
    defer md.deinit();
    const converted = try serializeFromAstAlloc(std.testing.allocator, md.ast(), .asciidoc);
    defer std.testing.allocator.free(converted);
    try std.testing.expectEqualStrings("= Title\n\nsome *bold* text\n", converted);
}

test "an unimplemented AsciiDoc construct renders as literal source, not as a mangled tree" {
    // The property the registry entry rested on before the parser was
    // complete, and still holds at its edges — see the `.asciidoc` row. The
    // `menu:` macro is one of the few things `asciidoc/parser.zig` leaves
    // unmodelled; what matters is that its source SURVIVES to the output
    // instead of being half-consumed into some other node.
    var doc = try parseAsciidoc(&ParseConfig{}, std.testing.allocator, "menu:File[Save]\n");
    defer doc.deinit();
    const html = try renderHtmlAlloc(std.testing.allocator, &doc);
    defer std.testing.allocator.free(html);
    try std.testing.expectEqualStrings("<p>menu:File[Save]</p>\n", html);
}

// ── cross-format round-trips ───────────────────────────────────────────────
//
// Markdown -> Djot -> HTML is where a serializer gap shows up as silent data
// loss rather than an error: a construct the Djot serializer can't spell is
// written as something that reparses into a DIFFERENT node, and only rendering
// the reparse catches it. Asserting on the Djot text alone would not — the
// output looks like a plausible document either way.

/// Markdown source -> Djot text -> reparsed as Djot -> HTML.
fn markdownThroughDjotToHtml(allocator: Allocator, source: []const u8) ![]u8 {
    var md = try Markdown.parse(allocator, source, .{});
    defer md.deinit();
    const djot_src = try djot_serializer.serializeAstAlloc(allocator, &md.ast);
    defer allocator.free(djot_src);
    var dj = try Djot.parse(allocator, djot_src);
    defer dj.deinit();
    return Djot.html.renderAlloc(allocator, &dj, .{});
}

test "round-trip: a Markdown table's header row survives the Djot leg" {
    const out = try markdownThroughDjotToHtml(std.testing.allocator, "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
    defer std.testing.allocator.free(out);
    // Djot marks a header by the separator line under it, so dropping the
    // separator on the way out turns every `<th>` back into a `<td>`.
    try std.testing.expect(std.mem.indexOf(u8, out, "<th>a</th>") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "<td>1</td>") != null);
}

test "round-trip: column alignment survives the Djot leg" {
    const out = try markdownThroughDjotToHtml(std.testing.allocator, "| a | b |\n| :-- | --: |\n| 1 | 2 |\n");
    defer std.testing.allocator.free(out);
    try std.testing.expect(std.mem.indexOf(u8, out, "<th style=\"text-align: left;\">a</th>") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "<td style=\"text-align: right;\">2</td>") != null);
}

test "round-trip: a table survives Djot -> HTML -> Djot" {
    // The other direction: HTML is a real input format, so a table has to
    // survive being read back out of the printer's own output — row groups,
    // header, alignment, caption and all.
    const source = "| a | b |\n|:--|--:|\n| 1 | 2 |\n^ Cap\n";
    var dj = try Djot.parse(std.testing.allocator, source);
    defer dj.deinit();
    const rendered = try Djot.html.renderAlloc(std.testing.allocator, &dj, .{});
    defer std.testing.allocator.free(rendered);

    var page = try Html.parse(std.testing.allocator, rendered);
    defer page.deinit();
    const back = try djot_serializer.serializeAstAlloc(std.testing.allocator, &page.ast);
    defer std.testing.allocator.free(back);
    try std.testing.expectEqualStrings(source, back);
}

test "round-trip: raw inline HTML survives the Djot leg" {
    const out = try markdownThroughDjotToHtml(std.testing.allocator, "x <sub>y</sub> z\n");
    defer std.testing.allocator.free(out);
    // Written as bare text, the tags reparse as ordinary characters and come
    // back escaped (`&lt;sub&gt;`) with no error raised anywhere.
    try std.testing.expect(std.mem.indexOf(u8, out, "<p>x <sub>y</sub> z</p>") != null);
}

test "round-trip: a raw HTML block survives the Djot leg" {
    const out = try markdownThroughDjotToHtml(std.testing.allocator, "<div>\nhi\n</div>\n");
    defer std.testing.allocator.free(out);
    // Spelled as a language (` ```html `) rather than djot's raw form
    // (` ```=html `), this reparses as a code block and renders as escaped
    // text inside `<pre>`.
    try std.testing.expect(std.mem.indexOf(u8, out, "<div>") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "<pre>") == null);
}

test "format names and extensions resolve" {
    try std.testing.expectEqual(Format.djot, parseFormatName("dj").?);
    try std.testing.expectEqual(Format.markdown, parseFormatName("markdown").?);
    try std.testing.expectEqual(Format.gfm, parseFormatName("gfm").?);
    try std.testing.expectEqual(Format.commonmark, parseFormatName("commonmark").?);
    try std.testing.expect(parseFormatName("nope") == null);
    try std.testing.expectEqual(Format.markdown, detectFromExtension("a/b.MD").?);
    try std.testing.expect(detectFromExtension("noext") == null);
}