aozora 0.5.0

Aozora Bunko notation parser with incremental document snapshots
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
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
//! Lexer-emitted observations.
//!
//! A [`Diagnostic`] is non-fatal: the lexer always produces a
//! best-effort output and never aborts mid-stream. Callers decide how
//! to surface the diagnostics — the CLI can render them via
//! [`miette::Report`], tests can assert on the variants, library
//! consumers can ignore them.
//!
//! Every variant carries a byte-range [`Span`] in the *sanitized* source
//! — the sanitize-stage output (BOM stripped, CRLF→LF, 〔…〕 accents decomposed),
//! which is the text the later stages tokenize. To render a snippet,
//! attach that sanitized text (e.g. via `crate::pipeline::lexer::sanitize`)
//! so miette's caret lands on the right character; for input with no BOM /
//! CRLF / accent digraphs the sanitized text equals the original bytes.
//!
//! # Severity and source axes
//!
//! Diagnostics split along two orthogonal axes:
//!
//! - **[`Severity`]** — `Error` / `Warning` / `Note`. Determines how
//!   strictly a host (CLI, LSP, editor decorator) should treat the
//!   observation. Defaults to `Error` for genuine syntax issues and
//!   `Warning` for input that the parser can carry around but the
//!   user should be told about.
//! - **[`DiagnosticSource`]** — `Source` (problem traces back to
//!   user input) vs. `Internal` (a pipeline-invariant violation;
//!   appearance indicates a library bug). Hosts that filter by
//!   `Internal` get a clear "library bug" channel without having to
//!   match on individual variants.
//!
//! Each variant exposes both axes through accessors
//! ([`Diagnostic::severity`] / [`Diagnostic::source`]).
//!
//! # Internal variant
//!
//! The four library-bug sanity checks
//! (`ResidualAnnotationMarker`, `UnregisteredSentinel`,
//! `RegistryOutOfOrder`, `RegistryPositionMismatch`) live as a single
//! [`Diagnostic::Internal`] variant whose `code` field
//! ([`InternalCheckCode`]) tags the specific check. Tests and tooling
//! match on that code via [`codes`]. Consumers that want to filter
//! library-bug diagnostics out of the [`crate::spec::Diagnostic`] stream
//! reach for [`Diagnostic::source`].

use std::borrow::Cow;

use miette::Diagnostic as MietteDiagnostic;
use thiserror::Error;

use crate::spec::PairKind;
use crate::spec::Span;

/// Stable identifier strings for known [`Diagnostic`] variants.
///
/// [`Diagnostic::code`] returns one of these for any production
/// diagnostic. They are guaranteed stable across patch and minor
/// releases; major-release variant additions land new constants here
/// without touching existing ones.
pub(crate) mod codes {
    /// Source contains a lexer PUA sentinel codepoint.
    pub(crate) const SOURCE_CONTAINS_PUA: &str = "aozora::lex::source_contains_pua";

    /// Open delimiter reached end-of-input with no matching close.
    pub(crate) const UNCLOSED_BRACKET: &str = "aozora::lex::unclosed_bracket";

    /// Close delimiter saw an empty stack or a mismatched stack top.
    pub(crate) const UNMATCHED_CLOSE: &str = "aozora::lex::unmatched_close";

    /// A `〔…〕` accent digraph was decomposed during the sanitize stage.
    pub(crate) const ACCENT_DECOMPOSITION_APPLIED: &str =
        "aozora::lex::accent_decomposition_applied";

    /// A 外字 (gaiji) reference resolved to neither Unicode nor JIS X 0213.
    pub(crate) const UNRESOLVED_GAIJI: &str = "aozora::lex::unresolved_gaiji";

    /// A paired container was closed by a closer of a different kind.
    pub(crate) const MISMATCHED_CONTAINER_CLOSE: &str = "aozora::lex::mismatched_container_close";

    /// An explicit-base ruby (`|base《》`) had an empty reading.
    pub(crate) const EMPTY_RUBY_READING: &str = "aozora::lex::empty_ruby_reading";

    /// A ruby reading body itself opened another ruby (`《…《…》…》`).
    pub(crate) const NESTED_RUBY: &str = "aozora::lex::nested_ruby";

    /// A `[#ここから…]` opener matched no known container kind.
    pub(crate) const UNRECOGNISED_CONTAINER_DIRECTIVE: &str =
        "aozora::lex::unrecognised_container_directive";

    /// A 縦中横 forward reference whose target is absent from the look-back.
    pub(crate) const TCY_TARGET_NOT_FOUND: &str = "aozora::lex::tcy_target_not_found";

    /// A forward-reference bouten target occurs more than once before it.
    pub(crate) const BOUTEN_TARGET_AMBIGUOUS: &str = "aozora::lex::bouten_target_ambiguous";

    /// An inline-style forward reference whose present target cannot be styled
    /// in place.
    ///
    /// `X` is a ruby base, on an earlier line, inside another construct, or one
    /// of several targets. The directive is kept; the styling is not applied.
    pub(crate) const FORWARD_REFERENT_NOT_STYLABLE: &str =
        "aozora::lex::forward_referent_not_stylable";

    /// A page/section break appeared inside a single-line container.
    pub(crate) const BREAK_IN_SINGLE_LINE_CONTAINER: &str =
        "aozora::lex::break_in_single_line_container";

    /// A bracketed kaeriten (`[#二]`) has no matching lower-rank partner.
    pub(crate) const BRACKETED_KAERITEN_NO_PAIR: &str = "aozora::lex::bracketed_kaeriten_no_pair";

    /// A kaeriten appeared outside a 漢文-like context (lookahead heuristic).
    pub(crate) const KAERITEN_OUTSIDE_KANBUN: &str = "aozora::lex::kaeriten_outside_kanbun";

    /// A 傍点 range opener was closed by a 傍線 closer (or vice-versa).
    pub(crate) const MISMATCHED_BOUTEN_CONTAINER: &str = "aozora::lex::mismatched_bouten_container";

    /// A `[#…]` body spelled as a near-miss of a recognized directive.
    ///
    /// 送り仮名 drift, a synonym, or a malformed prefix / close, kept as
    /// Unknown; the notation-hygiene lint suggests its canonical spelling.
    /// The `aozora::lint::*` namespace marks an advisory authoring lint,
    /// distinct from the `aozora::lex::*` lex faults above.
    pub(crate) const NON_CANONICAL_DIRECTIVE: &str = "aozora::lint::non_canonical_directive";

    /// Prefix of every advisory notation-hygiene *lint* code.
    ///
    /// The single authority for the lint-vs-lex split that
    /// [`crate::spec::Diagnostic::is_lint`] and the LSP filter on: a code in this
    /// namespace is authoring guidance, not a malformed-input fault.
    pub(crate) const LINT_NAMESPACE: &str = "aozora::lint::";

    /// Pipeline-internal: an `[#` digraph survived classification
    /// into the normalized text. Indicates a missing recogniser for
    /// the keyword.
    pub(crate) const RESIDUAL_ANNOTATION_MARKER: &str = "aozora::lex::residual_annotation_marker";

    /// Pipeline-internal: a PUA sentinel codepoint is present in the
    /// normalized text at a position that is not recorded in the
    /// placeholder registry.
    ///
    /// Source-side PUA collisions emit [`SOURCE_CONTAINS_PUA`]
    /// upstream; this code is distinct.
    pub(crate) const UNREGISTERED_SENTINEL: &str = "aozora::lex::unregistered_sentinel";

    /// Pipeline-internal: a placeholder-registry vector is not
    /// strictly ordered by position. Indicates a normalizer driver
    /// bug.
    pub(crate) const REGISTRY_OUT_OF_ORDER: &str = "aozora::lex::registry_out_of_order";

    /// Pipeline-internal: a registry entry references a normalized
    /// byte position whose character does not match the expected
    /// sentinel kind.
    pub(crate) const REGISTRY_POSITION_MISMATCH: &str = "aozora::lex::registry_position_mismatch";
}

/// Severity of a [`Diagnostic`].
///
/// Hosts route diagnostics by severity: `Error` blocks downstream
/// rendering or fails CI, `Warning` decorates the editor surface,
/// `Note` is informational. The `aozora` library never panics on a
/// `Diagnostic` — the parser produces a best-effort output and
/// surfaces this enum as the host's policy hook.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Severity {
    /// Genuine error; downstream consumers should treat the parse as
    /// suspect.
    Error,
    /// Recoverable observation; parse continues and output is
    /// preserved, but the user should know.
    Warning,
    /// Informational note; editor surfaces may show it as a tooltip
    /// or annotation but it does not affect CI / build status.
    Note,
}

impl Severity {
    /// Every variant in declaration order. Used by codegen so
    /// downstream artefacts track the enum without drift.
    pub const ALL: [Self; 3] = [Self::Error, Self::Warning, Self::Note];

    /// Stable lowercase wire-format identifier ("error" / "warning"
    /// / "note"). The same string the driver wire format emits in
    /// the `severity` field of `Diagnostic`.
    #[must_use]
    pub const fn as_json_str(self) -> &'static str {
        match self {
            Self::Error => "error",
            Self::Warning => "warning",
            Self::Note => "note",
        }
    }
}

/// Origin of a [`Diagnostic`] — distinguishes user-input issues from
/// library-internal sanity-check failures.
///
/// Production parses on well-formed input never emit `Internal`
/// diagnostics. An `Internal` diagnostic indicates a bug in the
/// lex pipeline and SHOULD be reported upstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DiagnosticSource {
    /// Issue traces back to the user-provided source text.
    Source,
    /// Pipeline-internal invariant violation. Indicates a library
    /// bug; the parse is still completed best-effort but downstream
    /// tooling should surface this distinctly.
    Internal,
}

impl DiagnosticSource {
    /// Every variant in declaration order.
    pub const ALL: [Self; 2] = [Self::Source, Self::Internal];

    /// Stable lowercase wire-format identifier ("source" /
    /// "internal"). Matches the `source` field of `Diagnostic`.
    #[must_use]
    pub const fn as_json_str(self) -> &'static str {
        match self {
            Self::Source => "source",
            Self::Internal => "internal",
        }
    }
}

/// Identifier of a specific pipeline-internal sanity check.
///
/// Carried by the [`Diagnostic::Internal`] variant. Tooling that
/// wants per-check assertions matches on this enum; legacy callers
/// (logs, regex grep) can still reach for the stable
/// `aozora::lex::*` string via [`Self::as_code`].
///
/// `#[non_exhaustive]` so adding a new check variant is a minor
/// release.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum InternalCheckCode {
    /// An `[#` digraph survived classification into the normalized
    /// text. Indicates a missing recogniser for the keyword.
    ResidualAnnotationMarker,
    /// A PUA sentinel codepoint is present in the normalized text at
    /// a position that is not recorded in the placeholder registry.
    UnregisteredSentinel,
    /// A placeholder-registry vector is not strictly ordered by
    /// position. Indicates a normalizer driver bug.
    RegistryOutOfOrder,
    /// A registry entry references a normalized byte position whose
    /// character does not match the expected sentinel kind.
    RegistryPositionMismatch,
}

impl InternalCheckCode {
    /// All known internal check codes in declaration order.
    pub const ALL: [Self; 4] = [
        Self::ResidualAnnotationMarker,
        Self::UnregisteredSentinel,
        Self::RegistryOutOfOrder,
        Self::RegistryPositionMismatch,
    ];

    /// Stable `aozora::lex::*` string identifier for this check.
    /// Equivalent to the corresponding internal code constant.
    #[must_use]
    pub const fn as_code(self) -> &'static str {
        match self {
            Self::ResidualAnnotationMarker => codes::RESIDUAL_ANNOTATION_MARKER,
            Self::UnregisteredSentinel => codes::UNREGISTERED_SENTINEL,
            Self::RegistryOutOfOrder => codes::REGISTRY_OUT_OF_ORDER,
            Self::RegistryPositionMismatch => codes::REGISTRY_POSITION_MISMATCH,
        }
    }
}

/// Observation emitted by any lexer stage.
#[derive(Debug, Clone, Error, MietteDiagnostic)]
#[non_exhaustive]
pub enum Diagnostic {
    /// Source contains a codepoint that collides with one of the
    /// lexer's PUA sentinel reservations
    /// reserved for parser placeholders.
    /// Downstream stages will emit those same codepoints into normalized
    /// text, so a collision means the placeholder registry can no longer
    /// distinguish source-text occurrences from lexer-inserted markers.
    #[error("source contains lexer PUA sentinel codepoint {codepoint:?}")]
    #[diagnostic(
        code("aozora::lex::source_contains_pua"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#source-contains-pua"),
        severity(Warning),
        help(
            "the lexer reserves U+E001..U+E004 as inline/block markers; \
             a source-side occurrence will confuse the placeholder registry"
        )
    )]
    SourceContainsPua {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("here")]
        at: miette::SourceSpan,
        /// The offending PUA codepoint found in the source.
        codepoint: char,
        /// Byte-range in the sanitized source for programmatic consumers
        /// that don't need miette's [`miette::SourceSpan`].
        span: Span,
    },

    /// An open delimiter reached end-of-input with no matching close on
    /// the pairing stack.
    #[error("unclosed Aozora {kind:?} bracket")]
    #[diagnostic(
        code("aozora::lex::unclosed_bracket"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#unclosed-bracket"),
        help(
            "the opener has no matching close delimiter — either the close \
             was omitted or an earlier close matched a nested opener"
        )
    )]
    UnclosedBracket {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("opened here")]
        at: miette::SourceSpan,
        /// Delimiter family of the unmatched opener.
        kind: PairKind,
        /// Byte-range of the unmatched *open* delimiter in the sanitized
        /// source.
        span: Span,
    },

    /// A close delimiter was seen with an empty stack, or with a stack
    /// top of a different [`PairKind`].
    #[error("unmatched Aozora {kind:?} close delimiter")]
    #[diagnostic(
        code("aozora::lex::unmatched_close"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#unmatched-close"),
        help(
            "no matching open on the pairing stack — either the open was \
             omitted or an inner unmatched close consumed it"
        )
    )]
    UnmatchedClose {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("close here")]
        at: miette::SourceSpan,
        /// Delimiter family of the stray closer.
        kind: PairKind,
        /// Byte-range of the stray *close* delimiter.
        span: Span,
    },

    /// A `〔…〕` accent digraph (e.g. `〔e'〕` → `é`) was decomposed into
    /// its Unicode-combined form during the sanitize stage. Purely
    /// informational: the decomposition is intended behaviour (ADR-0003),
    /// surfaced as a `Note` so an editor can show what changed. The
    /// serializer reconstructs the original `〔…〕` form, so the transform
    /// is loss-free.
    #[error("accent digraph decomposed in sanitize stage")]
    #[diagnostic(
        code("aozora::lex::accent_decomposition_applied"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#accent-decomposition-applied"
        ),
        severity(Advice),
        help(
            "the `〔…〕` accent span was rewritten to its combined Unicode form; \
             this is expected and round-trips back to the source on serialize"
        )
    )]
    AccentDecompositionApplied {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("decomposed here")]
        at: miette::SourceSpan,
        /// Byte-range of the `〔…〕` span in the sanitized (post-decomposition)
        /// source.
        span: Span,
    },

    /// A 外字 (gaiji) reference — `※[#…]` — resolved to neither a Unicode
    /// scalar nor a JIS X 0213 cell, so the renderer falls back to the
    /// description text rather than the intended glyph.
    #[error("gaiji reference resolved to neither Unicode nor JIS X 0213")]
    #[diagnostic(
        code("aozora::lex::unresolved_gaiji"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#unresolved-gaiji"),
        severity(Warning),
        help(
            "no JIS X 0213 men-ku-ten or U+XXXX reference matched and the \
             description is not a single resolvable character — the glyph \
             renders as its description text only"
        )
    )]
    UnresolvedGaiji {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("unresolved gaiji")]
        at: miette::SourceSpan,
        /// Byte-range of the `※[#…]` reference in the sanitized source.
        span: Span,
    },

    /// A paired container opened with one kind (`[#ここから2字下げ]`)
    /// was closed by a closer of a different kind
    /// (`[#ここで地付き終わり]`). The label points at the *close* marker.
    ///
    /// `open_kind` / `close_kind` are the stable lowercase container-family
    /// tags (`indent` / `warichu` / `keigakomi` / `align-end`); they are
    /// `&'static str` rather than the `crate::syntax::ContainerKind` enum
    /// because the spec layer sits below `crate::syntax`.
    #[error("container opened as `{open_kind}` closed by a `{close_kind}` closer")]
    #[diagnostic(
        code("aozora::lex::mismatched_container_close"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#mismatched-container-close"
        ),
        help(
            "the close directive names a different container family than the \
             open — pair `ここから字下げ` with `ここで字下げ終わり`, `ここから地付き` \
             with `ここで地付き終わり`, etc."
        )
    )]
    MismatchedContainerClose {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("mismatched close")]
        at: miette::SourceSpan,
        /// Container family of the *open* marker on the pairing stack.
        open_kind: &'static str,
        /// Container family named by the *close* marker.
        close_kind: &'static str,
        /// Byte-range of the close marker in the sanitized source.
        span: Span,
    },

    /// A `[#…]` body spelled as a verified near-miss of a recognized
    /// directive — kept as `DirectiveKind::Editorial`; the canonical spelling
    /// is offered as a fix. Advisory only, so it never blocks (exit 0 unless
    /// `--strict`).
    #[error("non-canonical directive; the canonical form is `{canonical}`")]
    #[diagnostic(
        code("aozora::lint::non_canonical_directive"),
        // ADR-0022, where the other sixteen point at the specification.
        // The spec cannot host this one and should not: it is aozora's
        // own hygiene layer, not a fact of the notation. `help` says how
        // to fix it; the ADR is the only thing that says why a lint
        // exists for it at all. Every catalogued code carries an https
        // url (`explain_covers_every_catalogued_code`)the url is the
        // best authority for *that* code, not always the spec.
        url(
            "https://github.com/P4suta/aozora/blob/main/docs/adr/\
             0022-notation-hygiene-layer-roles.md"
        ),
        severity(Warning),
        help(
            "this [#…] body matches a recognized directive spelled \
             non-canonically, so it was kept as an Unknown directive; rewrite \
             it to the canonical form (`aozora fmt --fix`)."
        )
    )]
    NonCanonicalDirective {
        /// Caret location for miette — the same byte-range as `span`.
        #[label("non-canonical directive")]
        at: miette::SourceSpan,
        /// The catalogue canonical spelling. Owned for the parameterized /
        /// forward-form entries, borrowed for the literal maps.
        canonical: Cow<'static, str>,
        /// Byte-range of the directive in the sanitized source.
        span: Span,
    },

    /// An explicit-base ruby — `|base《》` — supplied a base but an empty
    /// reading. The base is present (a `|` precedes the `《`), so this is
    /// a genuine authoring slip, not a bare `《》` literal run. The
    /// construct degrades to plain text. The label spans the whole
    /// `|base《》`.
    #[error("ruby base given but reading is empty")]
    #[diagnostic(
        code("aozora::lex::empty_ruby_reading"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#empty-ruby-reading"),
        help(
            "the `《…》` reading after the `|` base is empty — supply a reading \
             or remove the `|…《》` markers to keep the base as plain text"
        )
    )]
    EmptyRubyReading {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("empty reading")]
        at: miette::SourceSpan,
        /// Byte-range of the `|base《》` construct in the sanitized source.
        span: Span,
    },

    /// A ruby reading body opened another ruby (`|漢《字《かん》》`). Ruby
    /// does not nest; the inner `《…》` is the offending opener. The outer
    /// ruby is still parsed best-effort. The label points at the inner
    /// `《`.
    #[error("ruby reading contains a nested ruby")]
    #[diagnostic(
        code("aozora::lex::nested_ruby"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#nested-ruby"),
        help(
            "ruby cannot nest — close the outer reading before the inner `《`, \
             or remove the inner `《…》`"
        )
    )]
    NestedRuby {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("nested ruby opens here")]
        at: miette::SourceSpan,
        /// Byte-range of the inner `《` opener in the sanitized source.
        span: Span,
    },

    /// A `[#ここから…]` directive looked like a paired-container opener
    /// but named no known container kind (`字下げ` / `地付き` /
    /// `地から…字上げ`). It is kept as an `Directive{Unknown}` (so output
    /// is preserved) but not treated as a container. The label spans the
    /// directive.
    #[error("unrecognised container directive")]
    #[diagnostic(
        code("aozora::lex::unrecognised_container_directive"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#unrecognised-container-directive"
        ),
        severity(Warning),
        help(
            "`[#ここから…]` must name a known container — `字下げ`, `地付き`, \
             `地から N 字上げ`; this directive was kept as a plain annotation"
        )
    )]
    UnrecognisedContainerDirective {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("unrecognised directive")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#ここから…]` directive in the sanitized
        /// source.
        span: Span,
    },

    /// A 縦中横 forward reference (`[#「X」は縦中横]`) named a target `X`
    /// that does not appear anywhere in the preceding text, so it has no
    /// run to style. The directive degrades to an `Directive{Unknown}`.
    /// The label spans the directive.
    #[error("縦中横 target not found in the preceding text")]
    #[diagnostic(
        code("aozora::lex::tcy_target_not_found"),
        url("https://p4suta.github.io/aozora-notation-spec/diagnostics.html#tcy-target-not-found"),
        severity(Warning),
        help(
            "the quoted 縦中横 target must occur earlier in the line — check the \
             spelling, or place the `[#「X」は縦中横]` after the run it styles"
        )
    )]
    TcyTargetNotFound {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("target has no referent")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#「X」は縦中横]` directive in the sanitized
        /// source.
        span: Span,
    },

    /// A forward-reference bouten (`[#「X」に傍点]`) named a target `X`
    /// that occurs more than once in the preceding text, so which run it
    /// emphasises is ambiguous. The parser applies it to the match per its
    /// look-back rule, but the author should disambiguate. The label spans
    /// the directive.
    #[error("ambiguous bouten target: more than one candidate run precedes it")]
    #[diagnostic(
        code("aozora::lex::bouten_target_ambiguous"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#bouten-target-ambiguous"
        ),
        severity(Warning),
        help(
            "the quoted target appears more than once before the `[#…]` — the \
             styled run may not be the intended one; reword so the target is unique"
        )
    )]
    BoutenTargetAmbiguous {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("ambiguous target")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#「X」に傍点]` directive in the sanitized
        /// source.
        span: Span,
    },

    /// An inline-style forward reference (`[#「X」は太字/斜体]`, `[#「X」に傍点]`,
    /// `は縦中横`, `は「□」囲み`, …) named a target `X` that *is* present in the
    /// preceding text but cannot be styled in place: it is a ruby base
    /// (`我《われ》…[#「我」に傍点]`), on an earlier line, inside another construct,
    /// or one of several quoted targets. The directive is retained and the text
    /// round-trips, but the emphasis is **not** applied to the earlier run. The
    /// label spans the directive.
    #[error("forward-reference target found but not stylable in place")]
    #[diagnostic(
        code("aozora::lex::forward_referent_not_stylable"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#forward-referent-not-stylable"
        ),
        severity(Warning),
        help(
            "the quoted target is a ruby base, on an earlier line, inside another \
             construct, or one of several targets — move the `[#…]` next to a \
             plain occurrence of the target so the styling can be applied"
        )
    )]
    ForwardReferentNotStylable {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("target not stylable in place")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#「X」は…]` directive in the sanitized source.
        span: Span,
    },

    /// A page or section break (`[#改ページ]` / `[#改段]` / …) appeared
    /// inside a single-line container — a single-line layout directive
    /// (`[#地付き]` / `[#N字下げ]`) sharing a source line with a later
    /// break, or a break between `[#割り注]` and `[#割り注終わり]`. A
    /// single-line container governs only the rest of its line, so a break
    /// on that line drops the container's effect. The label points at the
    /// break. `container` is the stable family tag of the dropped container
    /// (`indent` / `align-end` / `warichu`).
    #[error("page/section break inside a single-line `{container}` container")]
    #[diagnostic(
        code("aozora::lex::break_in_single_line_container"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#break-in-single-line-container"
        ),
        severity(Warning),
        help(
            "a single-line container governs only the rest of its line — move \
             the break off the line, or use the paired `[#ここから…]` … \
             `[#ここで…終わり]` block form that persists across breaks"
        )
    )]
    BreakInSingleLineContainer {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("break drops the container")]
        at: miette::SourceSpan,
        /// Stable family tag of the dropped single-line container
        /// (`indent` / `align-end` / `warichu`).
        container: &'static str,
        /// Byte-range of the break directive in the sanitized source.
        span: Span,
    },

    /// A bracketed kaeriten of rank ≥ 2 (`[#二]` / `[#下]` / `[#乙]` …)
    /// appeared in a document whose matching family base (`[#一]` /
    /// `[#上]` / `[#甲]`) is absent entirely — there is nothing for the
    /// return mark to pair back to. The check is document-wide and
    /// base-only: kanbun return-mark groups routinely span `、` / `。` and
    /// line boundaries and 上下点 skips `中`, so any narrower scope flags
    /// valid kanbun. The label points at the unpaired mark.
    #[error("bracketed kaeriten has no matching base mark in the document")]
    #[diagnostic(
        code("aozora::lex::bracketed_kaeriten_no_pair"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#bracketed-kaeriten-no-pair"
        ),
        help(
            "a return mark needs its family base somewhere in the document — \
             a `[#二]`/`[#三]` needs a `[#一]`, a `[#下]`/`[#中]` needs \
             a `[#上]`, a `[#乙]`… needs a `[#甲]`"
        )
    )]
    BracketedKaeritenNoPair {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("unpaired kaeriten")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#…]` kaeriten directive in the sanitized
        /// source.
        span: Span,
    },

    /// A kaeriten (`[#二]` / `[#レ]` / …) appeared outside a 漢文-like
    /// context — it is the only kaeriten in the document and its
    /// surroundings read as ordinary kana prose, so the mark is most likely
    /// a stray annotation rather than a genuine return mark. Conservative
    /// lookahead heuristic: a document with a cluster of kaeriten is never
    /// flagged. The label points at the lone mark.
    #[error("kaeriten outside a 漢文-like context")]
    #[diagnostic(
        code("aozora::lex::kaeriten_outside_kanbun"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#kaeriten-outside-kanbun"
        ),
        severity(Warning),
        help(
            "this is the only kaeriten in the document and its surroundings \
             look like ordinary prose — check it is a genuine 返り点 and not a \
             stray `[#…]` annotation"
        )
    )]
    KaeritenOutsideKanbun {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("isolated kaeriten")]
        at: miette::SourceSpan,
        /// Byte-range of the `[#…]` kaeriten directive in the sanitized
        /// source.
        span: Span,
    },

    /// A 傍点 / 傍線 range form (`[#傍点] … [#傍点終わり]`) was opened with
    /// one family (点 / 線) and closed by the other — e.g. a `[#傍点]`
    /// opener closed by `[#傍線終わり]`. The two families render
    /// differently (dots vs a line), so the run's emphasis is ambiguous.
    /// The parser recovers by keying the run to the opener's variant. The
    /// label points at the close marker. `open_family` / `close_family`
    /// are the stable family tags (`傍点` / `傍線`).
    #[error("傍点 range opened as `{open_family}` closed by a `{close_family}` closer")]
    #[diagnostic(
        code("aozora::lex::mismatched_bouten_container"),
        url(
            "https://p4suta.github.io/aozora-notation-spec/diagnostics.html#mismatched-bouten-container"
        ),
        help(
            "close a 傍点 range with `[#傍点終わり]` (any 点 variant) and a 傍線 \
             range with `[#傍線終わり]` (any 線 variant) — match the opener's \
             family"
        )
    )]
    MismatchedBoutenContainer {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("mismatched close")]
        at: miette::SourceSpan,
        /// Family of the *open* marker (`傍点` / `傍線`).
        open_family: &'static str,
        /// Family named by the *close* marker.
        close_family: &'static str,
        /// Byte-range of the close marker in the sanitized source.
        span: Span,
    },

    /// Pipeline-internal sanity-check failure — production parses on
    /// well-formed input never emit this. The [`check`](Self::Internal)
    /// payload identifies the specific check via the typed
    /// [`InternalCheckCode`] enum; tooling that prefers the stable
    /// string identifier reaches via
    /// [`Self::code`](Self::code). Library consumers that just want
    /// to filter "library bugs" out of the stream check
    /// [`source`](Self::source) instead.
    #[error("internal aozora pipeline check failed: {}", check.as_code())]
    #[diagnostic(
        code("aozora::internal"),
        // The issue tracker, where the sixteen source-level diagnostics
        // point at the specification. This one is a bug in aozora, not a
        // property of the notation, so the spec neither describes it nor
        // should — and the only useful next step is to report it. Shared:
        // every `InternalCheckCode` in `ALL_CODES`
        // (residual_annotation_marker, unregistered_sentinel,) explains
        // through this one variant, so this url serves four catalogued
        // codes, not one.
        url("https://github.com/P4suta/aozora/issues"),
        help(
            "this is a pipeline-internal sanity check; appearance \
             indicates a bug in aozora — please report at \
             https://github.com/P4suta/aozora/issues with the source \
             that triggered it"
        )
    )]
    Internal {
        /// Caret location for miette rendering — the same byte-range as
        /// the `span` field below, as the `(offset, length)` pair miette
        /// wants.
        #[label("at this position")]
        at: miette::SourceSpan,
        /// Typed identifier for the specific check that fired. Pin
        /// per-check assertions on this rather than the stringly-typed
        /// [`code`](Self::code) accessor so the compiler enforces
        /// match exhaustiveness at the call site.
        check: InternalCheckCode,
        /// Byte-range covering the violation site.
        span: Span,
    },
}

/// Introspected metadata for a diagnostic code — the machine axes plus the
/// language-neutral example data behind `aozora explain <code>`.
///
/// Returned by [`Diagnostic::explain`]. `severity` / `source` come from the
/// inherent accessors and `help` / `url` from the live [`miette::Diagnostic`]
/// impl of a representative instance, so none of them can drift from what
/// `aozora check` renders. `repro` / `fixed` are the language-neutral example
/// pair from the crate-private `DOCS` table, and `body_args` are the
/// representative instance's [`Diagnostic::body_args`].
///
/// The localized *prose* — the one-line title and the long-form body — lives in
/// a separate localization catalog, keyed by `code`, so this crate stays a pure
/// machine contract. A consumer renders the title and body for a chosen
/// language from the code and the `info.body_args` placeables.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DiagnosticInfo {
    /// Stable `aozora::lex::*` code.
    pub code: &'static str,
    /// Severity routing axis.
    pub severity: Severity,
    /// Origin axis: user input vs. library-internal.
    pub source: DiagnosticSource,
    /// One-line remediation help — the `#[diagnostic(help(…))]` text.
    pub help: String,
    /// Documentation URL for the code, when the variant carries one.
    pub url: Option<String>,
    /// A minimal reproduction of the condition in Aozora notation.
    pub repro: &'static str,
    /// The corrected form corresponding to [`Self::repro`].
    pub fixed: &'static str,
    /// The representative instance's body placeables — the `(name, value)`
    /// pairs the localized `diag-<code>-body` message interpolates (empty for
    /// static-body variants). See [`Diagnostic::body_args`].
    pub body_args: Vec<(&'static str, String)>,
}

/// Static language-neutral example data for one diagnostic code.
///
/// A minimal reproduction in Aozora notation and its corrected form. The
/// localized title / body prose lives in a separate localization catalog keyed
/// by [`Self::code`].
struct DiagnosticDoc {
    /// The stable code this entry documents (a [`codes`] constant).
    code: &'static str,
    /// A minimal reproduction in Aozora notation.
    repro: &'static str,
    /// The corrected form of [`Self::repro`].
    fixed: &'static str,
}

/// One [`DiagnosticDoc`] per [`Diagnostic::ALL_CODES`] entry, in the same
/// order. The single authority for the per-code language-neutral reproduction
/// / fixed example pair; a coverage test pins `DOCS.len() == ALL_CODES.len()`
/// and that every code resolves. The localized title / body prose lives in a
/// separate localization catalog, keyed by code.
const DOCS: [DiagnosticDoc; 21] = [
    DiagnosticDoc {
        code: codes::SOURCE_CONTAINS_PUA,
        repro: "(不可視の U+E001 などが混入した行)",
        fixed: "(その 1 文字を削除した行)",
    },
    DiagnosticDoc {
        code: codes::UNCLOSED_BRACKET,
        repro: "本文[#改ページ",
        fixed: "本文[#改ページ]",
    },
    DiagnosticDoc {
        code: codes::UNMATCHED_CLOSE,
        repro: "本文 ]",
        fixed: "本文",
    },
    DiagnosticDoc {
        code: codes::ACCENT_DECOMPOSITION_APPLIED,
        repro: "Cre〔e'〕vez",
        fixed: "(修正不要。保存時に元の〔…〕へ復元されます)",
    },
    DiagnosticDoc {
        code: codes::UNRESOLVED_GAIJI,
        repro: "※[#「ある字」の説明]",
        fixed: "※[#「ある字」、第3水準1-15-23](面区点か U+ を補う)",
    },
    DiagnosticDoc {
        code: codes::MISMATCHED_CONTAINER_CLOSE,
        repro: "[#ここから2字下げ]\n本文\n[#ここで地付き終わり]",
        fixed: "[#ここから2字下げ]\n本文\n[#ここで字下げ終わり]",
    },
    DiagnosticDoc {
        code: codes::EMPTY_RUBY_READING,
        repro: "|青空《》",
        fixed: "|青空《あおぞら》",
    },
    DiagnosticDoc {
        code: codes::NESTED_RUBY,
        repro: "|漢《字《かん》》",
        fixed: "|漢字《かんじ》",
    },
    DiagnosticDoc {
        code: codes::UNRECOGNISED_CONTAINER_DIRECTIVE,
        repro: "[#ここから謎レイアウト]",
        fixed: "[#ここから2字下げ](既知のコンテナ名にする)",
    },
    DiagnosticDoc {
        code: codes::TCY_TARGET_NOT_FOUND,
        repro: "本文[#「25」は縦中横]",
        fixed: "25[#「25」は縦中横](対象を注記より前に置く)",
    },
    DiagnosticDoc {
        code: codes::BOUTEN_TARGET_AMBIGUOUS,
        repro: "花と花[#「花」に傍点]",
        fixed: "赤い花と白い花[#「白い花」に傍点](対象を一意にする)",
    },
    DiagnosticDoc {
        code: codes::FORWARD_REFERENT_NOT_STYLABLE,
        repro: "|我《われ》は […][#「我」に傍点]",
        fixed: "我[#「我」に傍点](プレーンな出現の隣に置く)",
    },
    DiagnosticDoc {
        code: codes::BREAK_IN_SINGLE_LINE_CONTAINER,
        repro: "[#地付き]本文[#改ページ]",
        fixed: "本文[#改ページ]\n[#地付き]本文(改ページを行外に出す)",
    },
    DiagnosticDoc {
        code: codes::BRACKETED_KAERITEN_NO_PAIR,
        repro: "学而時習之[#二]",
        fixed: "学而[#一]時習之[#二](家系の基点[#一]を置く)",
    },
    DiagnosticDoc {
        code: codes::KAERITEN_OUTSIDE_KANBUN,
        repro: "ふつうの文章です[#レ]",
        fixed: "(返り点でないなら注記を削除、本物の漢文文脈で使う)",
    },
    DiagnosticDoc {
        code: codes::MISMATCHED_BOUTEN_CONTAINER,
        repro: "[#傍点]本文[#傍線終わり]",
        fixed: "[#傍点]本文[#傍点終わり]",
    },
    DiagnosticDoc {
        code: codes::NON_CANONICAL_DIRECTIVE,
        repro: "本文[#字下げ終わり]",
        fixed: "本文[#ここで字下げ終わり]",
    },
    DiagnosticDoc {
        code: codes::RESIDUAL_ANNOTATION_MARKER,
        repro: "本文[#なぞの注記]",
        fixed: "本文[#改ページ]",
    },
    DiagnosticDoc {
        code: codes::UNREGISTERED_SENTINEL,
        repro: "(通常のソースからは発生しません)",
        fixed: "(パイプラインのバグ。手元の修正では直せません)",
    },
    DiagnosticDoc {
        code: codes::REGISTRY_OUT_OF_ORDER,
        repro: "(通常のソースからは発生しません)",
        fixed: "(パイプラインのバグ。手元の修正では直せません)",
    },
    DiagnosticDoc {
        code: codes::REGISTRY_POSITION_MISMATCH,
        repro: "(通常のソースからは発生しません)",
        fixed: "(パイプラインのバグ。手元の修正では直せません)",
    },
];

/// The canonical example used in the unclosed-bracket body, per family.
const fn pair_example(kind: PairKind) -> &'static str {
    match kind {
        PairKind::Ruby => "|青空《あおぞら》",
        PairKind::AngleQuote => "≪重要≫",
        PairKind::Tortoise => "〔Crevez chiens〕",
        PairKind::Quote => "[#「青空」に傍点]",
        PairKind::Bracket => "[#改ページ]",
    }
}

/// Look up the static [`DiagnosticDoc`] for a stable `code`.
fn doc_for(code: &str) -> Option<&'static DiagnosticDoc> {
    DOCS.iter().find(|d| d.code == code)
}

#[expect(
    clippy::same_name_method,
    reason = "intentional: our inherent severity() / code() return strongly-typed (Severity enum, &'static str) values that mirror miette::Diagnostic's loosely-typed defaults — callers prefer the inherent method"
)]
impl Diagnostic {
    /// Constructor for [`Diagnostic::SourceContainsPua`].
    #[must_use]
    pub fn source_contains_pua(at: Span, codepoint: char) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::SourceContainsPua {
            at: miette::SourceSpan::new(offset.into(), length),
            codepoint,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::UnclosedBracket`].
    #[must_use]
    pub fn unclosed_bracket(at: Span, kind: PairKind) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::UnclosedBracket {
            at: miette::SourceSpan::new(offset.into(), length),
            kind,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::UnmatchedClose`].
    #[must_use]
    pub fn unmatched_close(at: Span, kind: PairKind) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::UnmatchedClose {
            at: miette::SourceSpan::new(offset.into(), length),
            kind,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::NonCanonicalDirective`].
    #[must_use]
    pub fn non_canonical_directive(at: Span, canonical: impl Into<Cow<'static, str>>) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::NonCanonicalDirective {
            at: miette::SourceSpan::new(offset.into(), length),
            canonical: canonical.into(),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::AccentDecompositionApplied`].
    #[must_use]
    pub fn accent_decomposition_applied(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::AccentDecompositionApplied {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::UnresolvedGaiji`].
    #[must_use]
    pub fn unresolved_gaiji(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::UnresolvedGaiji {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::MismatchedContainerClose`]. The
    /// `open_kind` / `close_kind` are the stable container-family tags
    /// (`crate::syntax::ContainerKind::kind_str`).
    #[must_use]
    pub fn mismatched_container_close(
        at: Span,
        open_kind: &'static str,
        close_kind: &'static str,
    ) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::MismatchedContainerClose {
            at: miette::SourceSpan::new(offset.into(), length),
            open_kind,
            close_kind,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::EmptyRubyReading`].
    #[must_use]
    pub fn empty_ruby_reading(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::EmptyRubyReading {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::NestedRuby`].
    #[must_use]
    pub fn nested_ruby(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::NestedRuby {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::UnrecognisedContainerDirective`].
    #[must_use]
    pub fn unrecognised_container_directive(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::UnrecognisedContainerDirective {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::TcyTargetNotFound`].
    #[must_use]
    pub fn tcy_target_not_found(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::TcyTargetNotFound {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::BoutenTargetAmbiguous`].
    #[must_use]
    pub fn bouten_target_ambiguous(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::BoutenTargetAmbiguous {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::ForwardReferentNotStylable`].
    #[must_use]
    pub fn forward_referent_not_stylable(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::ForwardReferentNotStylable {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::BreakInSingleLineContainer`]. The
    /// `container` is the stable family tag of the dropped single-line
    /// container (`indent` / `align-end` / `warichu`).
    #[must_use]
    pub fn break_in_single_line_container(at: Span, container: &'static str) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::BreakInSingleLineContainer {
            at: miette::SourceSpan::new(offset.into(), length),
            container,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::BracketedKaeritenNoPair`].
    #[must_use]
    pub fn bracketed_kaeriten_no_pair(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::BracketedKaeritenNoPair {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::KaeritenOutsideKanbun`].
    #[must_use]
    pub fn kaeriten_outside_kanbun(at: Span) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::KaeritenOutsideKanbun {
            at: miette::SourceSpan::new(offset.into(), length),
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::MismatchedBoutenContainer`]. The
    /// `open_family` / `close_family` are the stable 点/線 family tags
    /// (`crate::syntax::BoutenKind::family_str`).
    #[must_use]
    pub fn mismatched_bouten_container(
        at: Span,
        open_family: &'static str,
        close_family: &'static str,
    ) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::MismatchedBoutenContainer {
            at: miette::SourceSpan::new(offset.into(), length),
            open_family,
            close_family,
            span: at,
        }
    }

    /// Constructor for [`Diagnostic::Internal`]. Takes a typed
    /// [`InternalCheckCode`] — the compiler enforces that every
    /// production emit-site classifies the check correctly.
    #[must_use]
    pub fn internal(at: Span, check: InternalCheckCode) -> Self {
        let (offset, length) = span_to_miette_parts(at);
        Self::Internal {
            at: miette::SourceSpan::new(offset.into(), length),
            check,
            span: at,
        }
    }

    /// Severity routing axis. See [`Severity`].
    ///
    /// `#[non_exhaustive]` puts the responsibility on every match
    /// here for adding-new-variant time, not on a catch-all arm —
    /// the compiler will refuse to build until the new variant is
    /// classified.
    #[must_use]
    pub fn severity(&self) -> Severity {
        match self {
            Self::SourceContainsPua { .. }
            | Self::UnresolvedGaiji { .. }
            | Self::UnrecognisedContainerDirective { .. }
            | Self::TcyTargetNotFound { .. }
            | Self::BoutenTargetAmbiguous { .. }
            | Self::ForwardReferentNotStylable { .. }
            | Self::BreakInSingleLineContainer { .. }
            | Self::KaeritenOutsideKanbun { .. }
            | Self::NonCanonicalDirective { .. } => Severity::Warning,
            Self::AccentDecompositionApplied { .. } => Severity::Note,
            Self::UnclosedBracket { .. }
            | Self::UnmatchedClose { .. }
            | Self::MismatchedContainerClose { .. }
            | Self::EmptyRubyReading { .. }
            | Self::NestedRuby { .. }
            | Self::BracketedKaeritenNoPair { .. }
            | Self::MismatchedBoutenContainer { .. }
            | Self::Internal { .. } => Severity::Error,
        }
    }

    /// Origin axis: user input vs. pipeline-internal. See
    /// [`DiagnosticSource`].
    #[must_use]
    pub fn source(&self) -> DiagnosticSource {
        match self {
            Self::SourceContainsPua { .. }
            | Self::UnclosedBracket { .. }
            | Self::UnmatchedClose { .. }
            | Self::AccentDecompositionApplied { .. }
            | Self::UnresolvedGaiji { .. }
            | Self::MismatchedContainerClose { .. }
            | Self::EmptyRubyReading { .. }
            | Self::NestedRuby { .. }
            | Self::UnrecognisedContainerDirective { .. }
            | Self::TcyTargetNotFound { .. }
            | Self::BoutenTargetAmbiguous { .. }
            | Self::ForwardReferentNotStylable { .. }
            | Self::BreakInSingleLineContainer { .. }
            | Self::BracketedKaeritenNoPair { .. }
            | Self::KaeritenOutsideKanbun { .. }
            | Self::MismatchedBoutenContainer { .. }
            | Self::NonCanonicalDirective { .. } => DiagnosticSource::Source,
            Self::Internal { .. } => DiagnosticSource::Internal,
        }
    }

    /// Byte-range covering the diagnostic.
    #[must_use]
    pub fn span(&self) -> Span {
        match self {
            Self::SourceContainsPua { span, .. }
            | Self::UnclosedBracket { span, .. }
            | Self::UnmatchedClose { span, .. }
            | Self::AccentDecompositionApplied { span, .. }
            | Self::UnresolvedGaiji { span, .. }
            | Self::MismatchedContainerClose { span, .. }
            | Self::EmptyRubyReading { span, .. }
            | Self::NestedRuby { span, .. }
            | Self::UnrecognisedContainerDirective { span, .. }
            | Self::TcyTargetNotFound { span, .. }
            | Self::BoutenTargetAmbiguous { span, .. }
            | Self::ForwardReferentNotStylable { span, .. }
            | Self::BreakInSingleLineContainer { span, .. }
            | Self::BracketedKaeritenNoPair { span, .. }
            | Self::KaeritenOutsideKanbun { span, .. }
            | Self::MismatchedBoutenContainer { span, .. }
            | Self::NonCanonicalDirective { span, .. }
            | Self::Internal { span, .. } => *span,
        }
    }

    /// Rebase this diagnostic's byte range by `by` bytes.
    ///
    /// Translates the `span` (and the miette `at` caret, which the
    /// constructors always derive from `span`) by `by`. Used by the
    /// incremental re-parse engine (#237) to lift a diagnostic produced
    /// by lexing a document *segment* — whose offsets are segment-local —
    /// back into whole-document coordinates by adding the segment's start
    /// offset.
    ///
    /// The single consolidated `|`-pattern arm relies on every variant
    /// sharing the `{ at, span, .. }` shape; the absence of a `_` arm
    /// means a future variant (the enum is `#[non_exhaustive]`) forces a
    /// compile error here rather than silently skipping the rebase —
    /// matching the [`span`](Self::span) / [`severity`](Self::severity)
    /// accessors' exhaustive style.
    #[must_use]
    pub fn shifted(mut self, by: i64) -> Self {
        let (at, span): (&mut miette::SourceSpan, &mut Span) = match &mut self {
            Self::SourceContainsPua { at, span, .. }
            | Self::UnclosedBracket { at, span, .. }
            | Self::UnmatchedClose { at, span, .. }
            | Self::AccentDecompositionApplied { at, span, .. }
            | Self::UnresolvedGaiji { at, span, .. }
            | Self::MismatchedContainerClose { at, span, .. }
            | Self::EmptyRubyReading { at, span, .. }
            | Self::NestedRuby { at, span, .. }
            | Self::UnrecognisedContainerDirective { at, span, .. }
            | Self::TcyTargetNotFound { at, span, .. }
            | Self::BoutenTargetAmbiguous { at, span, .. }
            | Self::ForwardReferentNotStylable { at, span, .. }
            | Self::BreakInSingleLineContainer { at, span, .. }
            | Self::BracketedKaeritenNoPair { at, span, .. }
            | Self::KaeritenOutsideKanbun { at, span, .. }
            | Self::MismatchedBoutenContainer { at, span, .. }
            | Self::NonCanonicalDirective { at, span, .. }
            | Self::Internal { at, span, .. } => (at, span),
        };
        *span = span.shifted(by);
        let (offset, length) = span_to_miette_parts(*span);
        *at = miette::SourceSpan::new(offset.into(), length);
        self
    }

    #[must_use]
    pub(crate) fn with_span(mut self, mapped: Span) -> Self {
        let (at, span): (&mut miette::SourceSpan, &mut Span) = match &mut self {
            Self::SourceContainsPua { at, span, .. }
            | Self::UnclosedBracket { at, span, .. }
            | Self::UnmatchedClose { at, span, .. }
            | Self::AccentDecompositionApplied { at, span, .. }
            | Self::UnresolvedGaiji { at, span, .. }
            | Self::MismatchedContainerClose { at, span, .. }
            | Self::EmptyRubyReading { at, span, .. }
            | Self::NestedRuby { at, span, .. }
            | Self::UnrecognisedContainerDirective { at, span, .. }
            | Self::TcyTargetNotFound { at, span, .. }
            | Self::BoutenTargetAmbiguous { at, span, .. }
            | Self::ForwardReferentNotStylable { at, span, .. }
            | Self::BreakInSingleLineContainer { at, span, .. }
            | Self::BracketedKaeritenNoPair { at, span, .. }
            | Self::KaeritenOutsideKanbun { at, span, .. }
            | Self::MismatchedBoutenContainer { at, span, .. }
            | Self::NonCanonicalDirective { at, span, .. }
            | Self::Internal { at, span, .. } => (at, span),
        };
        *span = mapped;
        let (offset, length) = span_to_miette_parts(mapped);
        *at = miette::SourceSpan::new(offset.into(), length);
        self
    }

    /// Stable string identifier for this diagnostic. Returns one of
    /// the canonical code strings for production variants, or the
    /// `Internal` payload's [`InternalCheckCode::as_code`] for
    /// pipeline-internal checks.
    #[must_use]
    pub fn code(&self) -> &'static str {
        match self {
            Self::SourceContainsPua { .. } => codes::SOURCE_CONTAINS_PUA,
            Self::UnclosedBracket { .. } => codes::UNCLOSED_BRACKET,
            Self::UnmatchedClose { .. } => codes::UNMATCHED_CLOSE,
            Self::AccentDecompositionApplied { .. } => codes::ACCENT_DECOMPOSITION_APPLIED,
            Self::UnresolvedGaiji { .. } => codes::UNRESOLVED_GAIJI,
            Self::MismatchedContainerClose { .. } => codes::MISMATCHED_CONTAINER_CLOSE,
            Self::EmptyRubyReading { .. } => codes::EMPTY_RUBY_READING,
            Self::NestedRuby { .. } => codes::NESTED_RUBY,
            Self::UnrecognisedContainerDirective { .. } => codes::UNRECOGNISED_CONTAINER_DIRECTIVE,
            Self::TcyTargetNotFound { .. } => codes::TCY_TARGET_NOT_FOUND,
            Self::BoutenTargetAmbiguous { .. } => codes::BOUTEN_TARGET_AMBIGUOUS,
            Self::ForwardReferentNotStylable { .. } => codes::FORWARD_REFERENT_NOT_STYLABLE,
            Self::BreakInSingleLineContainer { .. } => codes::BREAK_IN_SINGLE_LINE_CONTAINER,
            Self::BracketedKaeritenNoPair { .. } => codes::BRACKETED_KAERITEN_NO_PAIR,
            Self::KaeritenOutsideKanbun { .. } => codes::KAERITEN_OUTSIDE_KANBUN,
            Self::MismatchedBoutenContainer { .. } => codes::MISMATCHED_BOUTEN_CONTAINER,
            Self::NonCanonicalDirective { .. } => codes::NON_CANONICAL_DIRECTIVE,
            Self::Internal { check, .. } => check.as_code(),
        }
    }

    /// True when this diagnostic is an advisory notation-hygiene *lint*.
    ///
    /// A code in the `aozora::lint::*` namespace, surfaced by
    /// `aozora lint` and the LSP as authoring guidance, as opposed to the
    /// `aozora::lex::*` faults that report malformed input. The single
    /// authority for the lint-vs-lex split, so callers never string-match the
    /// prefix themselves.
    #[must_use]
    pub fn is_lint(&self) -> bool {
        self.code().starts_with(codes::LINT_NAMESPACE)
    }

    /// The instance placeables for this diagnostic's localized body message.
    ///
    /// The `(name, value)` pairs the `diag-<code>-body` Fluent message in the
    /// localization catalog interpolates for the diagnostic in hand.
    ///
    /// Plain data, no localization: variants that carry data (the offending
    /// codepoint, delimiter family, container tags, canonical spelling) yield
    /// their live values so a host renders the exact detail; static-body
    /// variants yield an empty vector. A consumer binds these into a Fluent
    /// args set and calls `aozora_i18n::diag_body(lang, self.code(), &args)` —
    /// `explain` from a representative sample, an editor from the live
    /// diagnostic, both reading the prose from the one catalog.
    ///
    /// The keys mirror the `$…` placeables in the `.ftl` body messages
    /// (`open` / `close` / `example` / `codepoint` / `char` / `open_kind` /
    /// `close_kind` / `container` / `open_family` / `close_family` /
    /// `canonical`). No `_` arm: like [`severity`](Self::severity) /
    /// [`code`](Self::code), a future `#[non_exhaustive]` variant must decide
    /// its placeables here rather than silently render a body with none.
    #[must_use]
    pub fn body_args(&self) -> Vec<(&'static str, Cow<'static, str>)> {
        match self {
            Self::SourceContainsPua { codepoint, .. } => vec![
                ("codepoint", format!("{:04X}", *codepoint as u32).into()),
                ("char", codepoint.to_string().into()),
            ],
            Self::UnclosedBracket { kind, .. } => vec![
                ("open", Cow::Borrowed(kind.open_str())),
                ("close", Cow::Borrowed(kind.close_str())),
                ("example", Cow::Borrowed(pair_example(*kind))),
            ],
            Self::UnmatchedClose { kind, .. } => vec![
                ("open", Cow::Borrowed(kind.open_str())),
                ("close", Cow::Borrowed(kind.close_str())),
            ],
            Self::MismatchedContainerClose {
                open_kind,
                close_kind,
                ..
            } => vec![
                ("open_kind", Cow::Borrowed(*open_kind)),
                ("close_kind", Cow::Borrowed(*close_kind)),
            ],
            Self::BreakInSingleLineContainer { container, .. } => {
                vec![("container", Cow::Borrowed(*container))]
            }
            Self::MismatchedBoutenContainer {
                open_family,
                close_family,
                ..
            } => vec![
                ("open_family", Cow::Borrowed(*open_family)),
                ("close_family", Cow::Borrowed(*close_family)),
            ],
            Self::NonCanonicalDirective { canonical, .. } => {
                vec![("canonical", canonical.clone())]
            }
            // Static-body variants carry no placeables; their `diag-*-body`
            // messages are plain prose. The four internal checks all reach
            // here through the single `Internal` variant — each maps to its own
            // distinct `diag-*-body` keyed by [`code`](Self::code).
            Self::AccentDecompositionApplied { .. }
            | Self::UnresolvedGaiji { .. }
            | Self::EmptyRubyReading { .. }
            | Self::NestedRuby { .. }
            | Self::UnrecognisedContainerDirective { .. }
            | Self::TcyTargetNotFound { .. }
            | Self::BoutenTargetAmbiguous { .. }
            | Self::ForwardReferentNotStylable { .. }
            | Self::BracketedKaeritenNoPair { .. }
            | Self::KaeritenOutsideKanbun { .. }
            | Self::Internal { .. } => Vec::new(),
        }
    }

    /// Whether an editor should mark this diagnostic's span as
    /// *unnecessary* (greyed out, `DiagnosticTag::UNNECESSARY`).
    ///
    /// A stable semantic property of the diagnostic — only the source-PUA
    /// collision, whose fix is to delete the redundant codepoint, is
    /// unnecessary today. Kept here (not in an LSP adapter) so the
    /// classification is single-sourced and `#[non_exhaustive]` forces a
    /// future variant to decide, mirroring [`severity`](Self::severity).
    #[must_use]
    pub fn is_unnecessary(&self) -> bool {
        match self {
            Self::SourceContainsPua { .. } => true,
            Self::UnclosedBracket { .. }
            | Self::UnmatchedClose { .. }
            | Self::AccentDecompositionApplied { .. }
            | Self::UnresolvedGaiji { .. }
            | Self::MismatchedContainerClose { .. }
            | Self::EmptyRubyReading { .. }
            | Self::NestedRuby { .. }
            | Self::UnrecognisedContainerDirective { .. }
            | Self::TcyTargetNotFound { .. }
            | Self::BoutenTargetAmbiguous { .. }
            | Self::ForwardReferentNotStylable { .. }
            | Self::BreakInSingleLineContainer { .. }
            | Self::BracketedKaeritenNoPair { .. }
            | Self::KaeritenOutsideKanbun { .. }
            | Self::MismatchedBoutenContainer { .. }
            | Self::NonCanonicalDirective { .. }
            | Self::Internal { .. } => false,
        }
    }

    /// Every stable diagnostic code [`Self::code`] can return, in
    /// catalogue order: the seventeen source-level codes followed by the
    /// four pipeline-internal check codes. Backs `aozora explain`'s
    /// catalogue and the round-trip coverage test.
    pub const ALL_CODES: [&'static str; 21] = [
        codes::SOURCE_CONTAINS_PUA,
        codes::UNCLOSED_BRACKET,
        codes::UNMATCHED_CLOSE,
        codes::ACCENT_DECOMPOSITION_APPLIED,
        codes::UNRESOLVED_GAIJI,
        codes::MISMATCHED_CONTAINER_CLOSE,
        codes::EMPTY_RUBY_READING,
        codes::NESTED_RUBY,
        codes::UNRECOGNISED_CONTAINER_DIRECTIVE,
        codes::TCY_TARGET_NOT_FOUND,
        codes::BOUTEN_TARGET_AMBIGUOUS,
        codes::FORWARD_REFERENT_NOT_STYLABLE,
        codes::BREAK_IN_SINGLE_LINE_CONTAINER,
        codes::BRACKETED_KAERITEN_NO_PAIR,
        codes::KAERITEN_OUTSIDE_KANBUN,
        codes::MISMATCHED_BOUTEN_CONTAINER,
        codes::NON_CANONICAL_DIRECTIVE,
        codes::RESIDUAL_ANNOTATION_MARKER,
        codes::UNREGISTERED_SENTINEL,
        codes::REGISTRY_OUT_OF_ORDER,
        codes::REGISTRY_POSITION_MISMATCH,
    ];

    /// Introspect the diagnostic identified by `code` — one of
    /// [`Self::ALL_CODES`] (equivalently a canonical code string or an
    /// [`InternalCheckCode::as_code`]). `None` for an unknown code.
    ///
    /// `severity` / `source` come from the inherent accessors; `help` /
    /// `url` are read from the live [`miette::Diagnostic`] impl of a
    /// representative instance, so they always agree with what `aozora check`
    /// prints for the same diagnostic. `repro` / `fixed` are the
    /// language-neutral example pair, and `body_args` the representative
    /// instance's [`body_args`](Self::body_args). The localized title / body
    /// prose is fetched separately from the localization catalog by
    /// [`Self::code`].
    #[must_use]
    pub fn explain(code: &str) -> Option<DiagnosticInfo> {
        let sample = Self::sample_for_code(code)?;
        // `sample.code()` is the canonical form of `code` (the four
        // internal codes map to their own strings), so `DOCS` is always
        // populated for a code `sample_for_code` accepted.
        let doc = doc_for(sample.code())?;
        Some(DiagnosticInfo {
            code: sample.code(),
            severity: sample.severity(),
            source: sample.source(),
            help: MietteDiagnostic::help(&sample)
                .map(|h| h.to_string())
                .unwrap_or_default(),
            url: MietteDiagnostic::url(&sample).map(|u| u.to_string()),
            repro: doc.repro,
            fixed: doc.fixed,
            body_args: sample
                .body_args()
                .into_iter()
                .map(|(name, value)| (name, value.into_owned()))
                .collect(),
        })
    }

    /// A representative instance of the variant a `code` names, for
    /// introspection (reading miette help/url without a real parse).
    /// Spans are placeholder-empty; the four internal codes all map to
    /// the single [`Self::Internal`] variant, which shares one help/url.
    fn sample_for_code(code: &str) -> Option<Self> {
        let at = Span::new(0, 0);
        Some(match code {
            codes::SOURCE_CONTAINS_PUA => Self::source_contains_pua(at, '\u{E001}'),
            codes::UNCLOSED_BRACKET => Self::unclosed_bracket(at, PairKind::Bracket),
            codes::UNMATCHED_CLOSE => Self::unmatched_close(at, PairKind::Bracket),
            codes::ACCENT_DECOMPOSITION_APPLIED => Self::accent_decomposition_applied(at),
            codes::UNRESOLVED_GAIJI => Self::unresolved_gaiji(at),
            codes::MISMATCHED_CONTAINER_CLOSE => {
                Self::mismatched_container_close(at, "indent", "align-end")
            }
            codes::EMPTY_RUBY_READING => Self::empty_ruby_reading(at),
            codes::NESTED_RUBY => Self::nested_ruby(at),
            codes::UNRECOGNISED_CONTAINER_DIRECTIVE => Self::unrecognised_container_directive(at),
            codes::TCY_TARGET_NOT_FOUND => Self::tcy_target_not_found(at),
            codes::BOUTEN_TARGET_AMBIGUOUS => Self::bouten_target_ambiguous(at),
            codes::FORWARD_REFERENT_NOT_STYLABLE => Self::forward_referent_not_stylable(at),
            codes::BREAK_IN_SINGLE_LINE_CONTAINER => {
                Self::break_in_single_line_container(at, "align-end")
            }
            codes::BRACKETED_KAERITEN_NO_PAIR => Self::bracketed_kaeriten_no_pair(at),
            codes::KAERITEN_OUTSIDE_KANBUN => Self::kaeriten_outside_kanbun(at),
            codes::MISMATCHED_BOUTEN_CONTAINER => {
                Self::mismatched_bouten_container(at, "傍点", "傍線")
            }
            codes::NON_CANONICAL_DIRECTIVE => Self::non_canonical_directive(at, "中央揃え"),
            codes::RESIDUAL_ANNOTATION_MARKER => {
                Self::internal(at, InternalCheckCode::ResidualAnnotationMarker)
            }
            codes::UNREGISTERED_SENTINEL => {
                Self::internal(at, InternalCheckCode::UnregisteredSentinel)
            }
            codes::REGISTRY_OUT_OF_ORDER => {
                Self::internal(at, InternalCheckCode::RegistryOutOfOrder)
            }
            codes::REGISTRY_POSITION_MISMATCH => {
                Self::internal(at, InternalCheckCode::RegistryPositionMismatch)
            }
            _ => return None,
        })
    }
}

/// Split a [`Span`] into the `(offset, length)` pair miette wants.
const fn span_to_miette_parts(span: Span) -> (usize, usize) {
    let offset = span.start as usize;
    let length = (span.end - span.start) as usize;
    (offset, length)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn source_contains_pua_round_trips_span() {
        let diag = Diagnostic::source_contains_pua(Span::new(5, 8), '\u{E001}');
        let Diagnostic::SourceContainsPua {
            codepoint, span, ..
        } = diag
        else {
            panic!("expected SourceContainsPua, got {diag:?}");
        };
        assert_eq!(codepoint, '\u{E001}');
        assert_eq!(span, Span::new(5, 8));
    }

    #[test]
    fn shifted_rebases_span_and_keeps_at_in_sync() {
        let diag = Diagnostic::source_contains_pua(Span::new(5, 8), '\u{E001}');
        let moved = diag.shifted(100);
        assert_eq!(moved.span(), Span::new(105, 108));
        // `at` is derived from `span`; confirm it tracks the shift so a
        // miette render points at the rebased location.
        let Diagnostic::SourceContainsPua { at, .. } = moved else {
            panic!("variant must survive the shift");
        };
        assert_eq!(at.offset(), 105);
        assert_eq!(at.len(), 3);
    }

    #[test]
    fn shifted_is_additive_inverse() {
        let diag = Diagnostic::unclosed_bracket(Span::new(40, 43), PairKind::Bracket);
        let there_and_back = diag.clone().shifted(1000).shifted(-1000);
        assert_eq!(there_and_back.span(), diag.span());
        assert_eq!(there_and_back.code(), diag.code());
    }

    #[test]
    fn source_contains_pua_is_warning_severity() {
        let diag = Diagnostic::source_contains_pua(Span::new(0, 3), '\u{E002}');
        assert_eq!(diag.severity(), Severity::Warning);
        assert_eq!(diag.source(), DiagnosticSource::Source);
        assert_eq!(diag.code(), codes::SOURCE_CONTAINS_PUA);
    }

    #[test]
    fn source_contains_pua_display_mentions_codepoint() {
        let diag = Diagnostic::source_contains_pua(Span::new(0, 3), '\u{E002}');
        let rendered = format!("{diag}");
        assert!(
            rendered.contains("E002")
                || rendered.contains("\\u{e002}")
                || rendered.contains('\u{E002}')
        );
    }

    #[test]
    fn unclosed_bracket_round_trips_span_and_kind() {
        let diag = Diagnostic::unclosed_bracket(Span::new(3, 6), PairKind::Bracket);
        match diag {
            Diagnostic::UnclosedBracket { kind, span, .. } => {
                assert_eq!(kind, PairKind::Bracket);
                assert_eq!(span, Span::new(3, 6));
            }
            other => panic!("expected UnclosedBracket, got {other:?}"),
        }
    }

    #[test]
    fn unclosed_bracket_is_error_severity_from_source() {
        let diag = Diagnostic::unclosed_bracket(Span::new(0, 3), PairKind::Bracket);
        assert_eq!(diag.severity(), Severity::Error);
        assert_eq!(diag.source(), DiagnosticSource::Source);
        assert_eq!(diag.code(), codes::UNCLOSED_BRACKET);
    }

    #[test]
    fn unmatched_close_round_trips_span_and_kind() {
        let diag = Diagnostic::unmatched_close(Span::new(7, 10), PairKind::Ruby);
        match diag {
            Diagnostic::UnmatchedClose { kind, span, .. } => {
                assert_eq!(kind, PairKind::Ruby);
                assert_eq!(span, Span::new(7, 10));
            }
            other => panic!("expected UnmatchedClose, got {other:?}"),
        }
    }

    #[test]
    fn unmatched_close_is_error_severity_from_source() {
        let diag = Diagnostic::unmatched_close(Span::new(0, 3), PairKind::Quote);
        assert_eq!(diag.severity(), Severity::Error);
        assert_eq!(diag.source(), DiagnosticSource::Source);
        assert_eq!(diag.code(), codes::UNMATCHED_CLOSE);
    }

    #[test]
    fn unclosed_bracket_display_mentions_kind() {
        let diag = Diagnostic::unclosed_bracket(Span::new(0, 3), PairKind::Tortoise);
        assert!(format!("{diag}").contains("Tortoise"));
    }

    #[test]
    fn unmatched_close_display_mentions_kind() {
        let diag = Diagnostic::unmatched_close(Span::new(0, 3), PairKind::Quote);
        assert!(format!("{diag}").contains("Quote"));
    }

    #[test]
    fn internal_round_trips_check_and_span() {
        let diag = Diagnostic::internal(Span::new(2, 5), InternalCheckCode::RegistryOutOfOrder);
        let Diagnostic::Internal { check, span, .. } = diag else {
            panic!("expected Internal, got {diag:?}");
        };
        assert_eq!(check, InternalCheckCode::RegistryOutOfOrder);
        assert_eq!(span, Span::new(2, 5));
    }

    #[test]
    fn internal_classified_as_internal_source() {
        let diag = Diagnostic::internal(Span::new(0, 1), InternalCheckCode::UnregisteredSentinel);
        assert_eq!(diag.severity(), Severity::Error);
        assert_eq!(diag.source(), DiagnosticSource::Internal);
        assert_eq!(diag.code(), codes::UNREGISTERED_SENTINEL);
    }

    #[test]
    fn internal_display_mentions_code() {
        let diag =
            Diagnostic::internal(Span::new(0, 1), InternalCheckCode::ResidualAnnotationMarker);
        let rendered = format!("{diag}");
        assert!(
            rendered.contains(codes::RESIDUAL_ANNOTATION_MARKER),
            "Internal Display should print the code; got {rendered:?}"
        );
    }

    #[test]
    fn internal_check_code_as_code_round_trips_constants() {
        for kind in InternalCheckCode::ALL {
            let diag = Diagnostic::internal(Span::new(0, 0), kind);
            assert_eq!(
                diag.code(),
                kind.as_code(),
                "code() must agree with as_code() for {kind:?}"
            );
        }
    }

    /// Codes are stable identifiers — pin every constant so accidental
    /// rename of one breaks this test rather than silently breaking
    /// downstream tooling that grep-matches on the string.
    #[test]
    fn code_constants_are_stable() {
        assert_eq!(
            codes::SOURCE_CONTAINS_PUA,
            "aozora::lex::source_contains_pua"
        );
        assert_eq!(codes::UNCLOSED_BRACKET, "aozora::lex::unclosed_bracket");
        assert_eq!(codes::UNMATCHED_CLOSE, "aozora::lex::unmatched_close");
        assert_eq!(
            codes::ACCENT_DECOMPOSITION_APPLIED,
            "aozora::lex::accent_decomposition_applied"
        );
        assert_eq!(codes::UNRESOLVED_GAIJI, "aozora::lex::unresolved_gaiji");
        assert_eq!(
            codes::MISMATCHED_CONTAINER_CLOSE,
            "aozora::lex::mismatched_container_close"
        );
        assert_eq!(codes::EMPTY_RUBY_READING, "aozora::lex::empty_ruby_reading");
        assert_eq!(codes::NESTED_RUBY, "aozora::lex::nested_ruby");
        assert_eq!(
            codes::UNRECOGNISED_CONTAINER_DIRECTIVE,
            "aozora::lex::unrecognised_container_directive"
        );
        assert_eq!(
            codes::TCY_TARGET_NOT_FOUND,
            "aozora::lex::tcy_target_not_found"
        );
        assert_eq!(
            codes::BOUTEN_TARGET_AMBIGUOUS,
            "aozora::lex::bouten_target_ambiguous"
        );
        assert_eq!(
            codes::FORWARD_REFERENT_NOT_STYLABLE,
            "aozora::lex::forward_referent_not_stylable"
        );
        assert_eq!(
            codes::BREAK_IN_SINGLE_LINE_CONTAINER,
            "aozora::lex::break_in_single_line_container"
        );
        assert_eq!(
            codes::BRACKETED_KAERITEN_NO_PAIR,
            "aozora::lex::bracketed_kaeriten_no_pair"
        );
        assert_eq!(
            codes::KAERITEN_OUTSIDE_KANBUN,
            "aozora::lex::kaeriten_outside_kanbun"
        );
        assert_eq!(
            codes::MISMATCHED_BOUTEN_CONTAINER,
            "aozora::lex::mismatched_bouten_container"
        );
        assert_eq!(
            codes::RESIDUAL_ANNOTATION_MARKER,
            "aozora::lex::residual_annotation_marker"
        );
        assert_eq!(
            codes::UNREGISTERED_SENTINEL,
            "aozora::lex::unregistered_sentinel"
        );
        assert_eq!(
            codes::REGISTRY_OUT_OF_ORDER,
            "aozora::lex::registry_out_of_order"
        );
        assert_eq!(
            codes::REGISTRY_POSITION_MISMATCH,
            "aozora::lex::registry_position_mismatch"
        );
    }

    /// Severity / source axes are independent — pin the cross-product
    /// for the four production variants so a future variant addition
    /// has to think about both axes deliberately.
    #[test]
    fn severity_source_cross_product_is_pinned() {
        let pua = Diagnostic::source_contains_pua(Span::new(0, 3), '\u{E001}');
        assert_eq!(pua.severity(), Severity::Warning);
        assert_eq!(pua.source(), DiagnosticSource::Source);

        let unclosed = Diagnostic::unclosed_bracket(Span::new(0, 3), PairKind::Bracket);
        assert_eq!(unclosed.severity(), Severity::Error);
        assert_eq!(unclosed.source(), DiagnosticSource::Source);

        let unmatched = Diagnostic::unmatched_close(Span::new(0, 3), PairKind::Bracket);
        assert_eq!(unmatched.severity(), Severity::Error);
        assert_eq!(unmatched.source(), DiagnosticSource::Source);

        let accent = Diagnostic::accent_decomposition_applied(Span::new(0, 9));
        assert_eq!(accent.severity(), Severity::Note);
        assert_eq!(accent.source(), DiagnosticSource::Source);
        assert_eq!(accent.code(), codes::ACCENT_DECOMPOSITION_APPLIED);

        let gaiji = Diagnostic::unresolved_gaiji(Span::new(0, 12));
        assert_eq!(gaiji.severity(), Severity::Warning);
        assert_eq!(gaiji.source(), DiagnosticSource::Source);
        assert_eq!(gaiji.code(), codes::UNRESOLVED_GAIJI);

        let mismatch =
            Diagnostic::mismatched_container_close(Span::new(0, 6), "indent", "align-end");
        assert_eq!(mismatch.severity(), Severity::Error);
        assert_eq!(mismatch.source(), DiagnosticSource::Source);
        assert_eq!(mismatch.code(), codes::MISMATCHED_CONTAINER_CLOSE);

        let empty_ruby = Diagnostic::empty_ruby_reading(Span::new(0, 15));
        assert_eq!(empty_ruby.severity(), Severity::Error);
        assert_eq!(empty_ruby.source(), DiagnosticSource::Source);
        assert_eq!(empty_ruby.code(), codes::EMPTY_RUBY_READING);

        let nested_ruby = Diagnostic::nested_ruby(Span::new(6, 9));
        assert_eq!(nested_ruby.severity(), Severity::Error);
        assert_eq!(nested_ruby.source(), DiagnosticSource::Source);
        assert_eq!(nested_ruby.code(), codes::NESTED_RUBY);

        let unrec = Diagnostic::unrecognised_container_directive(Span::new(0, 18));
        assert_eq!(unrec.severity(), Severity::Warning);
        assert_eq!(unrec.source(), DiagnosticSource::Source);
        assert_eq!(unrec.code(), codes::UNRECOGNISED_CONTAINER_DIRECTIVE);

        let tcy = Diagnostic::tcy_target_not_found(Span::new(0, 18));
        assert_eq!(tcy.severity(), Severity::Warning);
        assert_eq!(tcy.source(), DiagnosticSource::Source);
        assert_eq!(tcy.code(), codes::TCY_TARGET_NOT_FOUND);

        let bouten = Diagnostic::bouten_target_ambiguous(Span::new(0, 18));
        assert_eq!(bouten.severity(), Severity::Warning);
        assert_eq!(bouten.source(), DiagnosticSource::Source);
        assert_eq!(bouten.code(), codes::BOUTEN_TARGET_AMBIGUOUS);

        let not_stylable = Diagnostic::forward_referent_not_stylable(Span::new(0, 18));
        assert_eq!(not_stylable.severity(), Severity::Warning);
        assert_eq!(not_stylable.source(), DiagnosticSource::Source);
        assert_eq!(not_stylable.code(), codes::FORWARD_REFERENT_NOT_STYLABLE);

        let break_slc = Diagnostic::break_in_single_line_container(Span::new(0, 18), "align-end");
        assert_eq!(break_slc.severity(), Severity::Warning);
        assert_eq!(break_slc.source(), DiagnosticSource::Source);
        assert_eq!(break_slc.code(), codes::BREAK_IN_SINGLE_LINE_CONTAINER);

        let kaeriten_pair = Diagnostic::bracketed_kaeriten_no_pair(Span::new(0, 9));
        assert_eq!(kaeriten_pair.severity(), Severity::Error);
        assert_eq!(kaeriten_pair.source(), DiagnosticSource::Source);
        assert_eq!(kaeriten_pair.code(), codes::BRACKETED_KAERITEN_NO_PAIR);

        let kaeriten_kanbun = Diagnostic::kaeriten_outside_kanbun(Span::new(0, 9));
        assert_eq!(kaeriten_kanbun.severity(), Severity::Warning);
        assert_eq!(kaeriten_kanbun.source(), DiagnosticSource::Source);
        assert_eq!(kaeriten_kanbun.code(), codes::KAERITEN_OUTSIDE_KANBUN);

        let bouten_mismatch =
            Diagnostic::mismatched_bouten_container(Span::new(0, 12), "傍点", "傍線");
        assert_eq!(bouten_mismatch.severity(), Severity::Error);
        assert_eq!(bouten_mismatch.source(), DiagnosticSource::Source);
        assert_eq!(bouten_mismatch.code(), codes::MISMATCHED_BOUTEN_CONTAINER);

        let internal = Diagnostic::internal(Span::new(0, 3), InternalCheckCode::RegistryOutOfOrder);
        assert_eq!(internal.severity(), Severity::Error);
        assert_eq!(internal.source(), DiagnosticSource::Internal);
    }

    /// Every catalogued code resolves to a representative instance with
    /// non-empty help and an https URL — guards `explain` against a code
    /// that has no sample (and pins the catalogue length).
    #[test]
    fn explain_covers_every_catalogued_code() {
        assert_eq!(
            Diagnostic::ALL_CODES.len(),
            21,
            "ALL_CODES must list every code code() can return"
        );
        for &code in &Diagnostic::ALL_CODES {
            let info = Diagnostic::explain(code)
                .unwrap_or_else(|| panic!("catalogued code {code} is not explainable"));
            assert_eq!(
                info.code, code,
                "explain echoed a different code for {code}"
            );
            assert!(!info.help.trim().is_empty(), "{code}: empty help text");
            assert!(
                info.url
                    .as_deref()
                    .is_some_and(|u| u.starts_with("https://")),
                "{code}: missing or non-https url"
            );
            assert!(!info.repro.trim().is_empty(), "{code}: empty repro");
            assert!(!info.fixed.trim().is_empty(), "{code}: empty fixed");
        }
    }

    #[test]
    fn severity_as_json_str_is_stable_per_variant() {
        // The lowercase wire spelling emitted in the `severity` field —
        // pin each variant so a body-stubbing regression is caught rather
        // than silently drifting the JSON envelope.
        assert_eq!(Severity::Error.as_json_str(), "error");
        assert_eq!(Severity::Warning.as_json_str(), "warning");
        assert_eq!(Severity::Note.as_json_str(), "note");
    }

    #[test]
    fn diagnostic_source_as_json_str_is_stable_per_variant() {
        assert_eq!(DiagnosticSource::Source.as_json_str(), "source");
        assert_eq!(DiagnosticSource::Internal.as_json_str(), "internal");
    }

    #[test]
    fn pair_example_is_the_canonical_form_per_family() {
        // The example woven into the unclosed-bracket body — pin the
        // per-family literal so it is a real, resolvable construct and
        // never degrades to a stub string.
        assert_eq!(pair_example(PairKind::Ruby), "|青空《あおぞら》");
        assert_eq!(pair_example(PairKind::AngleQuote), "≪重要≫");
        assert_eq!(pair_example(PairKind::Tortoise), "〔Crevez chiens〕");
        assert_eq!(pair_example(PairKind::Quote), "[#「青空」に傍点]");
        assert_eq!(pair_example(PairKind::Bracket), "[#改ページ]");
    }

    #[test]
    fn doc_for_returns_the_entry_matching_the_requested_code() {
        // doc_for must return the DOCS entry whose `code` *equals* the
        // query — not merely some entry — so pin the identity of the
        // resolved doc rather than only its presence.
        for &code in &Diagnostic::ALL_CODES {
            assert_eq!(
                doc_for(code).expect("every catalogued code has a doc").code,
                code,
            );
        }
        assert!(doc_for("aozora::lex::does_not_exist").is_none());
    }

    #[test]
    fn docs_table_has_one_entry_per_code_in_order() {
        assert_eq!(
            DOCS.len(),
            Diagnostic::ALL_CODES.len(),
            "DOCS must have exactly one entry per ALL_CODES entry"
        );
        for (doc, &code) in DOCS.iter().zip(Diagnostic::ALL_CODES.iter()) {
            assert_eq!(doc.code, code, "DOCS order must match ALL_CODES order");
            assert!(doc_for(code).is_some(), "no DOCS entry for {code}");
        }
    }

    #[test]
    fn body_args_are_instance_specific_for_carrying_variants() {
        // The unclosed-bracket body placeables name the offending delimiter
        // family, so a Ruby opener and a Bracket opener yield different args —
        // the localized body then interpolates the exact glyphs / example.
        let bracket = Diagnostic::unclosed_bracket(Span::new(0, 1), PairKind::Bracket).body_args();
        let ruby = Diagnostic::unclosed_bracket(Span::new(0, 1), PairKind::Ruby).body_args();
        assert_ne!(bracket, ruby);
        // The `example` arg is the per-family canonical construct.
        assert!(
            bracket
                .iter()
                .any(|(k, v)| *k == "example" && v.contains('')),
            "bracket example arg: {bracket:?}"
        );
        assert!(
            ruby.iter()
                .any(|(k, v)| *k == "example" && v.contains('')),
            "ruby example arg: {ruby:?}"
        );
        // The `open` / `close` args carry the delimiter glyphs.
        assert!(bracket.iter().any(|(k, v)| *k == "open" && v == ""));
        assert!(bracket.iter().any(|(k, v)| *k == "close" && v == ""));
    }

    #[test]
    fn body_args_are_empty_for_static_body_variants() {
        // A static-body variant (and every internal check) supplies no
        // placeables — its `diag-*-body` message is plain prose.
        assert!(
            Diagnostic::empty_ruby_reading(Span::new(0, 1))
                .body_args()
                .is_empty()
        );
        assert!(
            Diagnostic::internal(Span::new(0, 0), InternalCheckCode::ResidualAnnotationMarker)
                .body_args()
                .is_empty()
        );
    }

    #[test]
    fn source_pua_body_args_format_the_codepoint_as_hex() {
        // The `codepoint` arg is the 4-digit uppercase hex the `U+{$codepoint}`
        // placeable expects; `char` is the offending scalar itself.
        let args = Diagnostic::source_contains_pua(Span::new(0, 3), '\u{E002}').body_args();
        assert!(
            args.iter().any(|(k, v)| *k == "codepoint" && v == "E002"),
            "codepoint arg: {args:?}"
        );
        assert!(
            args.iter().any(|(k, v)| *k == "char" && v == "\u{E002}"),
            "char arg: {args:?}"
        );
    }

    #[test]
    fn only_source_pua_is_unnecessary() {
        assert!(Diagnostic::source_contains_pua(Span::new(0, 1), '\u{E001}').is_unnecessary());
        assert!(!Diagnostic::unclosed_bracket(Span::new(0, 1), PairKind::Bracket).is_unnecessary());
        assert!(
            !Diagnostic::internal(Span::new(0, 0), InternalCheckCode::ResidualAnnotationMarker)
                .is_unnecessary()
        );
    }

    #[test]
    fn explain_rejects_unknown_and_unprefixed_codes() {
        // explain wants the full code; the CLI expands short forms.
        assert!(Diagnostic::explain(codes::UNCLOSED_BRACKET).is_some());
        assert!(Diagnostic::explain("unclosed_bracket").is_none());
        assert!(Diagnostic::explain("ruby").is_none());
        assert!(Diagnostic::explain("aozora::lex::does_not_exist").is_none());
    }

    #[test]
    fn explain_internal_codes_share_help_but_keep_distinct_codes() {
        let resid = Diagnostic::explain(codes::RESIDUAL_ANNOTATION_MARKER).unwrap();
        let unreg = Diagnostic::explain(codes::UNREGISTERED_SENTINEL).unwrap();
        assert_eq!(resid.source, DiagnosticSource::Internal);
        assert_eq!(resid.code, codes::RESIDUAL_ANNOTATION_MARKER);
        assert_eq!(unreg.code, codes::UNREGISTERED_SENTINEL);
        // All four internal checks are one Diagnostic::Internal variant,
        // so they share the umbrella help/url.
        assert_eq!(resid.help, unreg.help);
        assert_eq!(resid.url, unreg.url);
    }

    #[test]
    fn is_lint_selects_only_the_lint_namespace() {
        // The one lint code today is `aozora::lint::non_canonical_directive`.
        let lint = Diagnostic::non_canonical_directive(Span::new(0, 3), "ここで字下げ終わり");
        assert!(lint.is_lint(), "notation-hygiene lint must be a lint");
        assert!(lint.code().starts_with(codes::LINT_NAMESPACE));

        // Lex faults and internal checks are not lints.
        let lex = Diagnostic::unclosed_bracket(Span::new(0, 1), PairKind::Bracket);
        assert!(!lex.is_lint(), "a lex fault is not a lint: {}", lex.code());
        let internal =
            Diagnostic::internal(Span::new(0, 0), InternalCheckCode::ResidualAnnotationMarker);
        assert!(!internal.is_lint(), "an internal check is not a lint");
    }
}