Port grok-build's streaming markdown engine into coder-lite

3b3a7b46fb1a · AtlantisPleb · · parent 7a9a80e11d33

Port grok-build's streaming markdown engine into coder-lite

coder-lite rendered assistant output with `ratatui-markdown`: basic bold,
italic, lists and code fences, re-parsed from byte zero on every token.
#105 makes coder-lite the coder UI a person sits down and drives, so its
rendering quality is the product. Replace the renderer with the engine
from grok-build.

What arrives, in `crates/coder-lite/src/markdown/`:

- `StreamingMarkdownRenderer` with checkpoint freezing. Content before a
  top-level block boundary is frozen; each chunk reparses only the tail.
- syntect highlighting over two-face's 250-language set, anstyle +
  supports-color adaptation for 16/256-colour terminals, URL scanning,
  LaTeX-to-Unicode (`$E=mc^2$` becomes `E=mc²`), tables, mermaid, and
  `MarkdownStyle` per-element styling.
- CJK-aware `word_wrap_lines_with_joiners()`. Joiners are the exact text
  skipped at each wrap point, so `MarkdownContent::unwrapped_text` puts a
  wrapped paragraph back together for copy.
- OSC 8 hyperlinks. ratatui has no link concept, so `src/osc8.rs`
  repaints the link runs after the frame flushes, reading the characters
  back out of the buffer — it can change what a cell links to, never what
  it says.

The palette does not move. `markdown/theme.rs` is ours, not ported: one
amber `#FFB000` on one `#080600`, with markdown elements and syntax
highlighting told apart by weight and dimming rather than hue. Colour
arriving from syntect is flattened by `amberize` before it is painted.
The spinner frames and the `Entry` / `CoderUi` frame are unchanged.

Two rules are held by tests that fail when the property breaks, verified
by mutating the implementation:

- Streaming is measured against a clock. A batching renderer records
  `first_visible >= stream_finished` and fails.
- Freezing is measured as cost. `reparsed_bytes` (added here, not
  upstream) and `WrapStats` report the real work; with freezing disabled
  the suite reports 4,612,595 bytes reparsed for a 23,090-byte document
  and fails.

Fidelity is its own suite: an unterminated fence shows its body, an
unknown language shows its code, malformed emphasis shows its text. A
renderer that silently drops content is the same defect class as a
command that fabricates data. The mirror of that rule caught a bug in
this port during the rebase: `Entry::push_text` appended the chunk to
`Entry::text` before `markdown_mut()` seeded a fresh renderer from
`text`, so the first chunk of every stream was seeded and then pushed
again and the reader saw it twice. Held now by
`streaming_an_entry_renders_each_chunk_exactly_once`.

Two more rendering bugs found and fixed while wiring the theme: a hidden
`link_outer` glued the URL to its label (`the forgehttps://…`), and a
visible `code_language` ate the newline after the fence, printing
`rustfn main() {`.

Rebased onto four coder-lite commits that landed while this was in
flight. All four are preserved:

- 727ab02ece flush-left Notice/Reasoning bullet — re-expressed through
  the new `render_entry`, which had reverted it.
- c9a6c21bbd ACP delegate errors in the TUI — `runtime.rs` untouched.
- 2185306d80 `/export` — `Entry` keeps `tool` and `at`; the constructors
  stamp `at`, and `export.rs` is untouched.
- de4c32b7fe child tool call titles — `acp_harness.rs` untouched.

`tests/rebase_contract.rs` and `tests/export_atif.rs` hold those four
against a future rewrite of the transcript renderer; each was confirmed
to fail against a deliberate revert of the behaviour it defends.

The crate moves to edition 2024 (its own Cargo.toml, no other crate's
edition changes) because the ported code uses let-chains.

Upstream is Apache-2.0, © 2023-2026 SpaceXAI, read at
07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8. `LICENSE-APACHE-xai` beside the
ported tree records per-file provenance, what changed in porting, and the
licence text.

`cargo test -p coder-lite`: 642 passing, 0 failing.
`cargo run -p coder-lite -- --dev` boots, paints, answers a live turn,
and `/export` writes an ATIF document.

Closes #104.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#104

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified INVARIANTS.md
  • modified crates/coder-lite/Cargo.toml
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs
  • modified crates/coder-lite/src/main.rs
  • added crates/coder-lite/src/markdown/LICENSE-APACHE-xai
  • added crates/coder-lite/src/markdown/assets/tokyo-night.tmTheme
  • added crates/coder-lite/src/markdown/buffers.rs
  • added crates/coder-lite/src/markdown/checkpoint.rs
  • added crates/coder-lite/src/markdown/colors.rs
  • added crates/coder-lite/src/markdown/core.rs
  • added crates/coder-lite/src/markdown/hyperlinks.rs
  • added crates/coder-lite/src/markdown/latex/commands.rs
  • added crates/coder-lite/src/markdown/latex/cursor.rs
  • added crates/coder-lite/src/markdown/latex/environments.rs
  • added crates/coder-lite/src/markdown/latex/math_box.rs
  • added crates/coder-lite/src/markdown/latex/mod.rs
  • added crates/coder-lite/src/markdown/latex/symbols.rs
  • added crates/coder-lite/src/markdown/latex/tests.rs
  • added crates/coder-lite/src/markdown/latex_delimiters.rs
  • added crates/coder-lite/src/markdown/line_utils.rs
  • added crates/coder-lite/src/markdown/mermaid.rs
  • added crates/coder-lite/src/markdown/mod.rs
  • added crates/coder-lite/src/markdown/open_code_highlighter.rs
  • added crates/coder-lite/src/markdown/output.rs
  • added crates/coder-lite/src/markdown/parse.rs
  • added crates/coder-lite/src/markdown/render.rs
  • added crates/coder-lite/src/markdown/source_map.rs
  • added crates/coder-lite/src/markdown/streaming.rs
  • added crates/coder-lite/src/markdown/style.rs
  • added crates/coder-lite/src/markdown/syntax.rs
  • added crates/coder-lite/src/markdown/theme.rs
  • added crates/coder-lite/src/markdown/url_scan.rs
  • added crates/coder-lite/src/markdown/util.rs
  • added crates/coder-lite/src/markdown/wrapping.rs
  • added crates/coder-lite/src/osc8.rs
  • added crates/coder-lite/src/transcript.rs
  • modified crates/coder-lite/src/tui.rs
  • added crates/coder-lite/tests/export_atif.rs
  • modified crates/coder-lite/tests/markdown.rs
  • added crates/coder-lite/tests/rebase_contract.rs
  • modified crates/coder-lite/tests/smoke.rs
  • added crates/coder-lite/tests/streaming.rs
  • modified crates/coder-lite/tests/tool_box.rs

Diff

The diff is larger than the display bound; the tail is cut.

23 files changed, +13518 -100

Cargo.lock modified +341 -11

@@ -11,6 +11,12 @@ dependencies = [

11 11
 "gimli",
12 12
]
13 13
14
[[package]]
15
name = "adler2"
16
version = "2.0.1"
17
source = "registry+https://github.com/rust-lang/crates.io-index"
18
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
14 20
[[package]]
15 21
name = "aho-corasick"
16 22
version = "1.1.4"

@@ -69,6 +75,15 @@ version = "1.0.14"

69 75
source = "registry+https://github.com/rust-lang/crates.io-index"
70 76
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
71 77
78
[[package]]
79
name = "anstyle-lossy"
80
version = "1.1.5"
81
source = "registry+https://github.com/rust-lang/crates.io-index"
82
checksum = "d9ca7d0f520afcd6d817970d0b2d5fd7c630c75e7783cae046b8b8a783c5befa"
83
dependencies = [
84
 "anstyle",
85
]
86
72 87
[[package]]
73 88
name = "anstyle-parse"
74 89
version = "1.0.0"

@@ -87,6 +102,18 @@ dependencies = [

87 102
 "windows-sys 0.61.2",
88 103
]
89 104
105
[[package]]
106
name = "anstyle-syntect"
107
version = "1.0.5"
108
source = "registry+https://github.com/rust-lang/crates.io-index"
109
checksum = "bcf88d752cd1ae75d086e36561212ac12f9456bd5df22fb86281f1171aa3a98a"
110
dependencies = [
111
 "anstyle",
112
 "same-file",
113
 "syntect",
114
 "thiserror 1.0.69",
115
]
116
90 117
[[package]]
91 118
name = "anstyle-wincon"
92 119
version = "3.0.11"

@@ -180,6 +207,15 @@ version = "0.11.1"

180 207
source = "registry+https://github.com/rust-lang/crates.io-index"
181 208
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
182 209
210
[[package]]
211
name = "bincode"
212
version = "1.3.3"
213
source = "registry+https://github.com/rust-lang/crates.io-index"
214
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
215
dependencies = [
216
 "serde",
217
]
218
183 219
[[package]]
184 220
name = "bindgen"
185 221
version = "0.72.1"

@@ -230,6 +266,21 @@ dependencies = [

230 266
 "unicode-normalization",
231 267
]
232 268
269
[[package]]
270
name = "bit-set"
271
version = "0.8.0"
272
source = "registry+https://github.com/rust-lang/crates.io-index"
273
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
274
dependencies = [
275
 "bit-vec",
276
]
277
278
[[package]]
279
name = "bit-vec"
280
version = "0.8.0"
281
source = "registry+https://github.com/rust-lang/crates.io-index"
282
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
283
233 284
[[package]]
234 285
name = "bitcoin_hashes"
235 286
version = "0.14.101"

@@ -421,14 +472,27 @@ dependencies = [

421 472
name = "coder-lite"
422 473
version = "0.1.0"
423 474
dependencies = [
475
 "anstyle",
476
 "anstyle-lossy",
477
 "anstyle-syntect",
424 478
 "crossterm",
425 479
 "futures",
480
 "html-escape",
481
 "linkify",
426 482
 "openresponses-rust",
483
 "pretty_assertions",
484
 "pulldown-cmark",
427 485
 "ratatui",
428
 "ratatui-markdown",
429 486
 "serde",
430 487
 "serde_json",
488
 "supports-color",
489
 "syntect",
490
 "textwrap",
431 491
 "tokio",
492
 "two-face",
493
 "unicode-segmentation",
494
 "unicode-width 0.2.0",
495
 "url",
432 496
]
433 497
434 498
[[package]]

@@ -830,6 +894,18 @@ dependencies = [

830 894
 "zeroize",
831 895
]
832 896
897
[[package]]
898
name = "deranged"
899
version = "0.5.8"
900
source = "registry+https://github.com/rust-lang/crates.io-index"
901
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
902
903
[[package]]
904
name = "diff"
905
version = "0.1.13"
906
source = "registry+https://github.com/rust-lang/crates.io-index"
907
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
908
833 909
[[package]]
834 910
name = "digest"
835 911
version = "0.10.7"

@@ -950,6 +1026,17 @@ version = "0.3.0"

950 1026
source = "registry+https://github.com/rust-lang/crates.io-index"
951 1027
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
952 1028
1029
[[package]]
1030
name = "fancy-regex"
1031
version = "0.16.2"
1032
source = "registry+https://github.com/rust-lang/crates.io-index"
1033
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
1034
dependencies = [
1035
 "bit-set",
1036
 "regex-automata",
1037
 "regex-syntax",
1038
]
1039
953 1040
[[package]]
954 1041
name = "fastrand"
955 1042
version = "2.5.0"

@@ -972,6 +1059,16 @@ version = "0.1.9"

972 1059
source = "registry+https://github.com/rust-lang/crates.io-index"
973 1060
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
974 1061
1062
[[package]]
1063
name = "flate2"
1064
version = "1.1.9"
1065
source = "registry+https://github.com/rust-lang/crates.io-index"
1066
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1067
dependencies = [
1068
 "crc32fast",
1069
 "miniz_oxide",
1070
]
1071
975 1072
[[package]]
976 1073
name = "fnv"
977 1074
version = "1.0.7"

@@ -1225,6 +1322,12 @@ dependencies = [

1225 1322
 "digest",
1226 1323
]
1227 1324
1325
[[package]]
1326
name = "html-escape"
1327
version = "0.2.15"
1328
source = "registry+https://github.com/rust-lang/crates.io-index"
1329
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
1330
1228 1331
[[package]]
1229 1332
name = "http"
1230 1333
version = "1.4.0"

@@ -1475,6 +1578,12 @@ version = "2.12.0"

1475 1578
source = "registry+https://github.com/rust-lang/crates.io-index"
1476 1579
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
1477 1580
1581
[[package]]
1582
name = "is_ci"
1583
version = "1.2.0"
1584
source = "registry+https://github.com/rust-lang/crates.io-index"
1585
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
1586
1478 1587
[[package]]
1479 1588
name = "is_terminal_polyfill"
1480 1589
version = "1.70.2"

@@ -1647,6 +1756,21 @@ version = "0.2.16"

1647 1756
source = "registry+https://github.com/rust-lang/crates.io-index"
1648 1757
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1649 1758
1759
[[package]]
1760
name = "linked-hash-map"
1761
version = "0.5.6"
1762
source = "registry+https://github.com/rust-lang/crates.io-index"
1763
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
1764
1765
[[package]]
1766
name = "linkify"
1767
version = "0.10.0"
1768
source = "registry+https://github.com/rust-lang/crates.io-index"
1769
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
1770
dependencies = [
1771
 "memchr",
1772
]
1773
1650 1774
[[package]]
1651 1775
name = "linux-raw-sys"
1652 1776
version = "0.4.15"

@@ -1740,6 +1864,16 @@ version = "0.2.1"

1740 1864
source = "registry+https://github.com/rust-lang/crates.io-index"
1741 1865
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1742 1866
1867
[[package]]
1868
name = "miniz_oxide"
1869
version = "0.8.9"
1870
source = "registry+https://github.com/rust-lang/crates.io-index"
1871
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1872
dependencies = [
1873
 "adler2",
1874
 "simd-adler32",
1875
]
1876
1743 1877
[[package]]
1744 1878
name = "mio"
1745 1879
version = "1.2.0"

@@ -1809,6 +1943,12 @@ dependencies = [

1809 1943
 "windows-sys 0.61.2",
1810 1944
]
1811 1945
1946
[[package]]
1947
name = "num-conv"
1948
version = "0.2.2"
1949
source = "registry+https://github.com/rust-lang/crates.io-index"
1950
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1951
1812 1952
[[package]]
1813 1953
name = "num-derive"
1814 1954
version = "0.4.2"

@@ -1975,6 +2115,28 @@ version = "1.70.2"

1975 2115
source = "registry+https://github.com/rust-lang/crates.io-index"
1976 2116
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1977 2117
2118
[[package]]
2119
name = "onig"
2120
version = "6.5.3"
2121
source = "registry+https://github.com/rust-lang/crates.io-index"
2122
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
2123
dependencies = [
2124
 "bitflags 2.11.1",
2125
 "libc",
2126
 "once_cell",
2127
 "onig_sys",
2128
]
2129
2130
[[package]]
2131
name = "onig_sys"
2132
version = "69.9.3"
2133
source = "registry+https://github.com/rust-lang/crates.io-index"
2134
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
2135
dependencies = [
2136
 "cc",
2137
 "pkg-config",
2138
]
2139
1978 2140
[[package]]
1979 2141
name = "openagents-all-work-contract"
1980 2142
version = "0.1.0"

@@ -2111,6 +2273,19 @@ version = "0.3.33"

2111 2273
source = "registry+https://github.com/rust-lang/crates.io-index"
2112 2274
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
2113 2275
2276
[[package]]
2277
name = "plist"
2278
version = "1.10.0"
2279
source = "registry+https://github.com/rust-lang/crates.io-index"
2280
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
2281
dependencies = [
2282
 "base64",
2283
 "indexmap",
2284
 "quick-xml",
2285
 "serde",
2286
 "time",
2287
]
2288
2114 2289
[[package]]
2115 2290
name = "postcard"
2116 2291
version = "1.1.3"

@@ -2132,6 +2307,12 @@ dependencies = [

2132 2307
 "zerovec",
2133 2308
]
2134 2309
2310
[[package]]
2311
name = "powerfmt"
2312
version = "0.2.0"
2313
source = "registry+https://github.com/rust-lang/crates.io-index"
2314
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2315
2135 2316
[[package]]
2136 2317
name = "ppv-lite86"
2137 2318
version = "0.2.21"

@@ -2141,6 +2322,16 @@ dependencies = [

2141 2322
 "zerocopy",
2142 2323
]
2143 2324
2325
[[package]]
2326
name = "pretty_assertions"
2327
version = "1.4.1"
2328
source = "registry+https://github.com/rust-lang/crates.io-index"
2329
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
2330
dependencies = [
2331
 "diff",
2332
 "yansi",
2333
]
2334
2144 2335
[[package]]
2145 2336
name = "proc-macro-crate"
2146 2337
version = "3.5.0"

@@ -2159,6 +2350,24 @@ dependencies = [

2159 2350
 "unicode-ident",
2160 2351
]
2161 2352
2353
[[package]]
2354
name = "pulldown-cmark"
2355
version = "0.13.4"
2356
source = "registry+https://github.com/rust-lang/crates.io-index"
2357
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
2358
dependencies = [
2359
 "bitflags 2.11.1",
2360
 "memchr",
2361
 "pulldown-cmark-escape",
2362
 "unicase",
2363
]
2364
2365
[[package]]
2366
name = "pulldown-cmark-escape"
2367
version = "0.11.0"
2368
source = "registry+https://github.com/rust-lang/crates.io-index"
2369
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
2370
2162 2371
[[package]]
2163 2372
name = "pulley-interpreter"
2164 2373
version = "36.0.14"

@@ -2182,6 +2391,15 @@ dependencies = [

2182 2391
 "syn 2.0.117",
2183 2392
]
2184 2393
2394
[[package]]
2395
name = "quick-xml"
2396
version = "0.41.0"
2397
source = "registry+https://github.com/rust-lang/crates.io-index"
2398
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
2399
dependencies = [
2400
 "memchr",
2401
]
2402
2185 2403
[[package]]
2186 2404
name = "quinn"
2187 2405
version = "0.11.9"

@@ -2339,16 +2557,6 @@ dependencies = [

2339 2557
 "unicode-width 0.2.0",
2340 2558
]
2341 2559
2342
[[package]]
2343
name = "ratatui-markdown"
2344
version = "0.3.6"
2345
source = "registry+https://github.com/rust-lang/crates.io-index"
2346
checksum = "e44e5c1fcb6b71a3e639b5218ea181ef42b8b05ae87ba4afdc962b48548079ab"
2347
dependencies = [
2348
 "ratatui",
2349
 "unicode-width 0.2.0",
2350
]
2351
2352 2560
[[package]]
2353 2561
name = "rayon"
2354 2562
version = "1.12.0"

@@ -2885,6 +3093,12 @@ dependencies = [

2885 3093
 "rand_core 0.6.4",
2886 3094
]
2887 3095
3096
[[package]]
3097
name = "simd-adler32"
3098
version = "0.3.10"
3099
source = "registry+https://github.com/rust-lang/crates.io-index"
3100
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3101
2888 3102
[[package]]
2889 3103
name = "simd_cesu8"
2890 3104
version = "1.2.0"

@@ -2916,6 +3130,12 @@ dependencies = [

2916 3130
 "serde",
2917 3131
]
2918 3132
3133
[[package]]
3134
name = "smawk"
3135
version = "0.3.3"
3136
source = "registry+https://github.com/rust-lang/crates.io-index"
3137
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
3138
2919 3139
[[package]]
2920 3140
name = "socket2"
2921 3141
version = "0.6.3"

@@ -2972,6 +3192,15 @@ version = "2.6.1"

2972 3192
source = "registry+https://github.com/rust-lang/crates.io-index"
2973 3193
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2974 3194
3195
[[package]]
3196
name = "supports-color"
3197
version = "3.0.2"
3198
source = "registry+https://github.com/rust-lang/crates.io-index"
3199
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
3200
dependencies = [
3201
 "is_ci",
3202
]
3203
2975 3204
[[package]]
2976 3205
name = "syn"
2977 3206
version = "2.0.117"

@@ -3014,6 +3243,28 @@ dependencies = [

3014 3243
 "syn 2.0.117",
3015 3244
]
3016 3245
3246
[[package]]
3247
name = "syntect"
3248
version = "5.3.0"
3249
source = "registry+https://github.com/rust-lang/crates.io-index"
3250
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
3251
dependencies = [
3252
 "bincode",
3253
 "fancy-regex",
3254
 "flate2",
3255
 "fnv",
3256
 "once_cell",
3257
 "onig",
3258
 "plist",
3259
 "regex-syntax",
3260
 "serde",
3261
 "serde_derive",
3262
 "serde_json",
3263
 "thiserror 2.0.18",
3264
 "walkdir",
3265
 "yaml-rust",
3266
]
3267
3017 3268
[[package]]
3018 3269
name = "sysinfo"
3019 3270
version = "0.37.2"

@@ -3077,6 +3328,17 @@ dependencies = [

3077 3328
 "winapi-util",
3078 3329
]
3079 3330
3331
[[package]]
3332
name = "textwrap"
3333
version = "0.16.2"
3334
source = "registry+https://github.com/rust-lang/crates.io-index"
3335
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
3336
dependencies = [
3337
 "smawk",
3338
 "unicode-linebreak",
3339
 "unicode-width 0.2.0",
3340
]
3341
3080 3342
[[package]]
3081 3343
name = "thiserror"
3082 3344
version = "1.0.69"

@@ -3126,6 +3388,36 @@ dependencies = [

3126 3388
 "cfg-if",
3127 3389
]
3128 3390
3391
[[package]]
3392
name = "time"
3393
version = "0.3.55"
3394
source = "registry+https://github.com/rust-lang/crates.io-index"
3395
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3396
dependencies = [
3397
 "deranged",
3398
 "num-conv",
3399
 "powerfmt",
3400
 "serde_core",
3401
 "time-core",
3402
 "time-macros",
3403
]
3404
3405
[[package]]
3406
name = "time-core"
3407
version = "0.1.9"
3408
source = "registry+https://github.com/rust-lang/crates.io-index"
3409
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3410
3411
[[package]]
3412
name = "time-macros"
3413
version = "0.2.32"
3414
source = "registry+https://github.com/rust-lang/crates.io-index"
3415
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
3416
dependencies = [
3417
 "num-conv",
3418
 "time-core",
3419
]
3420
3129 3421
[[package]]
3130 3422
name = "tinystr"
3131 3423
version = "0.8.3"

@@ -3365,18 +3657,41 @@ dependencies = [

3365 3657
 "utf-8",
3366 3658
]
3367 3659
3660
[[package]]
3661
name = "two-face"
3662
version = "0.4.5"
3663
source = "registry+https://github.com/rust-lang/crates.io-index"
3664
checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2"
3665
dependencies = [
3666
 "serde",
3667
 "serde_derive",
3668
 "syntect",
3669
]
3670
3368 3671
[[package]]
3369 3672
name = "typenum"
3370 3673
version = "1.20.0"
3371 3674
source = "registry+https://github.com/rust-lang/crates.io-index"
3372 3675
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
3373 3676
3677
[[package]]
3678
name = "unicase"
3679
version = "2.9.0"
3680
source = "registry+https://github.com/rust-lang/crates.io-index"
3681
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
3682
3374 3683
[[package]]
3375 3684
name = "unicode-ident"
3376 3685
version = "1.0.24"
3377 3686
source = "registry+https://github.com/rust-lang/crates.io-index"
3378 3687
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
3379 3688
3689
[[package]]
3690
name = "unicode-linebreak"
3691
version = "0.1.5"
3692
source = "registry+https://github.com/rust-lang/crates.io-index"
3693
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
3694
3380 3695
[[package]]
3381 3696
name = "unicode-normalization"
3382 3697
version = "0.1.25"

@@ -4320,6 +4635,21 @@ version = "0.6.3"

4320 4635
source = "registry+https://github.com/rust-lang/crates.io-index"
4321 4636
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
4322 4637
4638
[[package]]
4639
name = "yaml-rust"
4640
version = "0.4.5"
4641
source = "registry+https://github.com/rust-lang/crates.io-index"
4642
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
4643
dependencies = [
4644
 "linked-hash-map",
4645
]
4646
4647
[[package]]
4648
name = "yansi"
4649
version = "1.0.1"
4650
source = "registry+https://github.com/rust-lang/crates.io-index"
4651
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
4652
4323 4653
[[package]]
4324 4654
name = "yoke"
4325 4655
version = "0.8.2"
INVARIANTS.md modified +39

@@ -2563,3 +2563,42 @@ renders as the bare product name `Coder`, never as its id.

2563 2563
  be a worse lie than the vendor name it replaced.
2564 2564
- Held by `packages/openagents-cli/test/coder-tiers.test.ts`. Issue
2565 2565
  OpenAgentsInc/openagents#40.
2566
2567
## coder-lite Transcript Rendering
2568
2569
`crates/coder-lite` renders assistant markdown with the streaming engine
2570
ported from grok-build (`crates/coder-lite/src/markdown/`, Apache-2.0, see
2571
`LICENSE-APACHE-xai` there). Three things about that rendering are fixed.
2572
2573
- **Nothing the model sent may disappear.** A construct either renders or
2574
  renders literally. An unterminated fence shows its body, an unknown language
2575
  shows its code, malformed emphasis shows its text. Silently dropping content
2576
  is the same class of defect as a command that fabricates data, and it is
2577
  refused on the same grounds. The mirror of it also holds: content the model
2578
  sent once is rendered once. `Entry` seeds its renderer lazily from
2579
  `Entry::text`, so a chunk must reach the renderer before it joins `text` —
2580
  the other order renders the first chunk of every stream twice. Held by
2581
  `crates/coder-lite/tests/markdown.rs::no_construct_swallows_its_content`,
2582
  `malformed_markdown_renders_literally_rather_than_disappearing`, and
2583
  `crates/coder-lite/tests/streaming.rs::streaming_an_entry_renders_each_chunk_exactly_once`.
2584
- **One amber on one background.** Every painted cell in the transcript is
2585
  `#FFB000` on `#080600`. Markdown elements and syntax highlighting are told
2586
  apart by effect — bold, dim, italic, underline — never by hue. Colour
2587
  arriving from syntect or from a themed style is flattened by
2588
  `markdown::theme::amberize` before it reaches the screen. The braille
2589
  spinner frames and the `Entry` / `CoderUi` frame are part of the same
2590
  identity. Held by
2591
  `crates/coder-lite/tests/markdown.rs::every_painted_cell_keeps_the_amber_palette`,
2592
  `syntax_highlighting_shows_up_as_weight_not_hue`, and
2593
  `spinner_frames_still_animate`. Role markers sit at column 0 — `>` for a
2594
  user message, `⏺` for a notice, reasoning line, or tool call — with a
2595
  two-column hanging indent on wrapped lines. Held by
2596
  `crates/coder-lite/tests/rebase_contract.rs`, which also holds the `/export`
2597
  payload and the delegate-error box against a future rewrite of the
2598
  transcript renderer.
2599
- **Streaming is incremental, and that is measured.** A chunk is on screen
2600
  before the stream closes, and streaming an `n`-byte answer reparses `O(n)`
2601
  bytes rather than `O(n²)`. `StreamingMarkdownRenderer::reparsed_bytes` and
2602
  `transcript::WrapStats` exist so the saving is asserted as cost, not assumed
2603
  from output that happens to look right. Held by
2604
  `crates/coder-lite/tests/streaming.rs`. Issue OpenAgentsInc/openagents#104.
crates/coder-lite/Cargo.toml modified +23 -2

@@ -1,7 +1,10 @@

1 1
[package]
2 2
name = "coder-lite"
3 3
version = "0.1.0"
4
edition.workspace = true
4
# The vendored markdown engine (src/markdown, ported from xAI grok-build) is
5
# written against edition 2024 — chiefly let-chains. Held here rather than in
6
# [workspace.package] so no other crate's edition moves.
7
edition = "2024"
5 8
license.workspace = true
6 9
repository.workspace = true
7 10

@@ -17,4 +20,22 @@ openresponses-rust = "2026.7.26"

17 20
futures = "0.3"
18 21
serde = { workspace = true, features = ["derive"] }
19 22
serde_json = { workspace = true }
20
ratatui-markdown = { version = "0.3.6", default-features = false, features = ["markdown"] }
23
24
# Streaming markdown engine (src/markdown), ported from xAI grok-build.
25
# Versions match grok-build's workspace pins.
26
anstyle = "1.0"
27
anstyle-lossy = "1.1.4"
28
anstyle-syntect = "1.0.4"
29
html-escape = "0.2"
30
linkify = "0.10"
31
pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
32
supports-color = "3.0"
33
syntect = "5.3"
34
textwrap = "0.16"
35
two-face = { version = "0.4", default-features = false, features = ["syntect-fancy"] }
36
unicode-segmentation = "1.12"
37
unicode-width = "0.2"
38
url = "2"
39
40
[dev-dependencies]
41
pretty_assertions = "1"
crates/coder-lite/src/interactive.rs modified +56 -82

@@ -7,20 +7,20 @@

7 7
use crate::acp;
8 8
use crate::export::{export_trajectory, git_info};
9 9
use crate::runtime::{CoderRuntimeSession, Control};
10
use crate::tui::{now_ms, CoderUi, Entry, Role, ToolCall};
11
use std::env;
12
use std::sync::mpsc;
10
use crate::tui::{CoderUi, Entry, Role, ToolCall};
13 11
use crossterm::{
12
    ExecutableCommand,
14 13
    event::{
15
        self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
16
        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
14
        self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, PopKeyboardEnhancementFlags,
15
        PushKeyboardEnhancementFlags,
17 16
    },
18
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
19
    ExecutableCommand,
17
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
20 18
};
21
use ratatui::backend::CrosstermBackend;
22 19
use ratatui::Terminal;
20
use ratatui::backend::CrosstermBackend;
21
use std::env;
23 22
use std::io::{stderr, stdout};
23
use std::sync::mpsc;
24 24
use std::time::Duration;
25 25
26 26
pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

@@ -53,23 +53,15 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

53 53
                .map(|a| a.id.as_str())
54 54
                .collect::<Vec<_>>()
55 55
                .join(", ");
56
            ui.entries.push(Entry {
57
                role: Role::Notice,
58
                text: format!("found ACP agents: {}", list),
59
                output: None,
60
                tool: None,
61
                at: now_ms(),
62
            });
56
            ui.entries.push(Entry::new(
57
                Role::Notice,
58
                format!("found ACP agents: {}", list),
59
            ));
63 60
            ui.agents = agents;
64 61
        }
65 62
        Err(_) => {
66
            ui.entries.push(Entry {
67
                role: Role::Notice,
68
                text: "found ACP agents: none".to_string(),
69
                output: None,
70
                tool: None,
71
                at: now_ms(),
72
            });
63
            ui.entries
64
                .push(Entry::new(Role::Notice, "found ACP agents: none"));
73 65
        }
74 66
    }
75 67

@@ -83,43 +75,41 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

83 75
            match control {
84 76
                Control::Chunk(chunk) => {
85 77
                    // Append text to the current assistant entry.
86
                    if let Some(last) = ui
87
                        .entries
88
                        .iter_mut()
89
                        .rfind(|e| e.role == Role::Assistant)
90
                    {
91
                        last.text.push_str(&chunk);
78
                    if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
79
                        last.push_text(&chunk);
92 80
                        ui.scroll_override = None;
93 81
                    }
94 82
                }
95
                Control::Done => ui.loading = false,
83
                Control::Done => {
84
                    // Tell the markdown engine the stream closed so it flushes
85
                    // any bytes held back at a chunk boundary.
86
                    if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
87
                        last.finish_text();
88
                    }
89
                    ui.loading = false;
90
                }
96 91
                Control::Tool {
97 92
                    function_name,
98 93
                    arguments,
99 94
                    title,
100 95
                } => {
101
                    let parsed = serde_json::from_str(&arguments).unwrap_or_else(|_| {
102
                        serde_json::json!({ "unparsed_arguments": arguments })
103
                    });
96
                    let parsed = serde_json::from_str(&arguments)
97
                        .unwrap_or_else(|_| serde_json::json!({ "unparsed_arguments": arguments }));
104 98
                    let call_id = format!("call-{}", ui.entries.len());
105 99
                    let agent = parsed
106 100
                        .get("agent")
107 101
                        .and_then(|v| v.as_str())
108 102
                        .unwrap_or("unknown")
109 103
                        .to_string();
110
                    ui.entries.push(Entry {
111
                        role: Role::Tool,
112
                        text: format!("delegate {}: {}", agent, title),
113
                        output: Some(String::new()),
114
                        tool: Some(ToolCall {
115
                            call_id,
116
                            function_name,
117
                            arguments: parsed,
118
                            output: None,
119
                            error: None,
120
                        }),
121
                        at: now_ms(),
104
                    let mut entry = Entry::tool_call(format!("delegate {}: {}", agent, title));
105
                    entry.tool = Some(ToolCall {
106
                        call_id,
107
                        function_name,
108
                        arguments: parsed,
109
                        output: None,
110
                        error: None,
122 111
                    });
112
                    ui.entries.push(entry);
123 113
                    ui.scroll_override = None;
124 114
                }
125 115
                Control::ToolTitle(title) => {

@@ -140,9 +130,7 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

140 130
                Control::ToolText(chunk) => {
141 131
                    if let Some(last) = ui.entries.last_mut() {
142 132
                        if last.role == Role::Tool {
143
                            last.output
144
                                .get_or_insert_with(String::new)
145
                                .push_str(&chunk);
133
                            last.output.get_or_insert_with(String::new).push_str(&chunk);
146 134
                            if let Some(ref mut tool) = last.tool {
147 135
                                tool.output = last.output.clone();
148 136
                            }

@@ -159,6 +147,17 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

159 147
            ui.render(f, size);
160 148
        })?;
161 149
150
        // ratatui has no hyperlink concept, so repaint the link runs as OSC 8
151
        // sequences over the frame it just flushed. `emit` re-reads the text
152
        // out of the buffer, so this can never change what a cell says.
153
        if !ui.links.is_empty() {
154
            let buffer = terminal.current_buffer_mut().clone();
155
            let mut out = std::io::stdout();
156
            let _ = crate::osc8::emit(&mut out, &ui.links, &buffer);
157
            let cursor = terminal.get_cursor_position()?;
158
            terminal.set_cursor_position(cursor)?;
159
        }
160
162 161
        if event::poll(Duration::from_millis(50))? {
163 162
            if let Event::Key(key) = event::read()? {
164 163
                if key.kind != KeyEventKind::Press {

@@ -166,8 +165,7 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

166 165
                }
167 166
                match key {
168 167
                    KeyEvent {
169
                        code: KeyCode::Esc,
170
                        ..
168
                        code: KeyCode::Esc, ..
171 169
                    }
172 170
                    | KeyEvent {
173 171
                        code: KeyCode::Char('q' | 'c' | 'd'),

@@ -187,43 +185,20 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

187 185
                            ui.scroll_override = None;
188 186
189 187
                            if prompt.trim() == "/export" {
190
                                ui.entries.push(Entry {
191
                                    role: Role::You,
192
                                    text: prompt,
193
                                    output: None,
194
                                    tool: None,
195
                                    at: now_ms(),
196
                                });
188
                                ui.entries.push(Entry::new(Role::You, prompt));
197 189
                                let model = ui.model.clone();
198 190
                                let result =
199 191
                                    export_trajectory(&ui.entries, &model, &ui.repo, &ui.branch);
200
                                ui.entries.push(Entry {
201
                                    role: Role::Notice,
202
                                    text: format!(
192
                                ui.entries.push(Entry::new(
193
                                    Role::Notice,
194
                                    format!(
203 195
                                        "exported {} steps to {} (copied: {})",
204
                                        result.steps,
205
                                        result.path,
206
                                        result.copied
196
                                        result.steps, result.path, result.copied
207 197
                                    ),
208
                                    output: None,
209
                                    tool: None,
210
                                    at: now_ms(),
211
                                });
198
                                ));
212 199
                            } else {
213
                                ui.entries.push(Entry {
214
                                    role: Role::You,
215
                                    text: prompt.clone(),
216
                                    output: None,
217
                                    tool: None,
218
                                    at: now_ms(),
219
                                });
220
                                ui.entries.push(Entry {
221
                                    role: Role::Assistant,
222
                                    text: String::new(),
223
                                    output: None,
224
                                    tool: None,
225
                                    at: now_ms(),
226
                                });
200
                                ui.entries.push(Entry::new(Role::You, prompt.clone()));
201
                                ui.entries.push(Entry::new(Role::Assistant, String::new()));
227 202
                                ui.loading = true;
228 203
229 204
                                let mut session = CoderRuntimeSession::new();

@@ -242,8 +217,7 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

242 217
                        ui.composer.pop();
243 218
                    }
244 219
                    KeyEvent {
245
                        code: KeyCode::Up,
246
                        ..
220
                        code: KeyCode::Up, ..
247 221
                    } => ui.scroll_by(-1),
248 222
                    KeyEvent {
249 223
                        code: KeyCode::Down,
crates/coder-lite/src/lib.rs modified +3

@@ -4,5 +4,8 @@ pub mod acp;

4 4
pub mod acp_harness;
5 5
pub mod export;
6 6
pub mod interactive;
7
pub mod markdown;
8
pub mod osc8;
7 9
pub mod runtime;
10
pub mod transcript;
8 11
pub mod tui;
crates/coder-lite/src/main.rs modified +10 -5

@@ -16,11 +16,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

16 16
17 17
    if dev {
18 18
        boot_dev_server().await?;
19
        if env::var("OPENAGENTS_BASE_URL").is_err() {
20
            env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
21
        }
22
        if env::var("OPENAGENTS_API_KEY").is_err() {
23
            env::set_var("OPENAGENTS_API_KEY", DEV_API_KEY);
19
        // SAFETY: edition 2024 marks `set_var` unsafe because another thread
20
        // reading the environment concurrently is UB. This runs before the TUI
21
        // and its tokio tasks start, so no other thread exists yet.
22
        unsafe {
23
            if env::var("OPENAGENTS_BASE_URL").is_err() {
24
                env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
25
            }
26
            if env::var("OPENAGENTS_API_KEY").is_err() {
27
                env::set_var("OPENAGENTS_API_KEY", DEV_API_KEY);
28
            }
24 29
        }
25 30
    }
26 31
crates/coder-lite/src/markdown/LICENSE-APACHE-xai added +240

@@ -0,0 +1,240 @@

1
The Rust sources in this directory are ported from grok-build,
2
(c) 2023-2026 SpaceXAI, Apache-2.0, read at commit
3
07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8 (SOURCE_REV
4
956313d459bee15ae8f17bf73e0633605e18dddd). Provenance per file:
5
6
  buffers.rs, checkpoint.rs, colors.rs, hyperlinks.rs, latex/,
7
  latex_delimiters.rs, mermaid.rs, open_code_highlighter.rs, output.rs,
8
  parse.rs, render.rs, source_map.rs, streaming.rs, style.rs, syntax.rs,
9
  url_scan.rs, assets/tokyo-night.tmTheme, and mod.rs (from lib.rs)
10
      <- crates/codegen/xai-grok-markdown/
11
12
  core.rs
13
      <- crates/codegen/xai-grok-markdown-core/src/lib.rs
14
15
  wrapping.rs, line_utils.rs
16
      <- crates/codegen/xai-grok-pager-render/src/render/
17
18
  util.rs
19
      <- crates/codegen/xai-grok-pager-render/src/util.rs (the two
20
         display-width helpers line_utils.rs depends on)
21
22
Changes made in porting:
23
24
  - Module paths rewritten from three crates into one module tree.
25
  - line_utils.rs lost its re-export of the pager's tool-path helpers and
26
    the test that covered them; util.rs carries only the two width
27
    helpers it still needs.
28
  - wrapping.rs tests now build their MarkdownStyle from coder-lite's
29
    theme instead of the pager's, and paint through ratatui's set_line
30
    instead of the pager's SafeBuf.
31
  - streaming.rs gained a reparsed_bytes counter (not upstream) so
32
    checkpoint freezing can be measured in tests.
33
  - theme.rs is coder-lite's own: it is not ported code.
34
35
The upstream licence follows.
36
37
Copyright 2023-2026 SpaceXAI
38
39
40
                                 Apache License
41
                           Version 2.0, January 2004
42
                        http://www.apache.org/licenses/
43
44
   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
45
46
   1. Definitions.
47
48
      "License" shall mean the terms and conditions for use, reproduction,
49
      and distribution as defined by Sections 1 through 9 of this document.
50
51
      "Licensor" shall mean the copyright owner or entity authorized by
52
      the copyright owner that is granting the License.
53
54
      "Legal Entity" shall mean the union of the acting entity and all
55
      other entities that control, are controlled by, or are under common
56
      control with that entity. For the purposes of this definition,
57
      "control" means (i) the power, direct or indirect, to cause the
58
      direction or management of such entity, whether by contract or
59
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
60
      outstanding shares, or (iii) beneficial ownership of such entity.
61
62
      "You" (or "Your") shall mean an individual or Legal Entity
63
      exercising permissions granted by this License.
64
65
      "Source" form shall mean the preferred form for making modifications,
66
      including but not limited to software source code, documentation
67
      source, and configuration files.
68
69
      "Object" form shall mean any form resulting from mechanical
70
      transformation or translation of a Source form, including but
71
      not limited to compiled object code, generated documentation,
72
      and conversions to other media types.
73
74
      "Work" shall mean the work of authorship, whether in Source or
75
      Object form, made available under the License, as indicated by a
76
      copyright notice that is included in or attached to the work
77
      (an example is provided in the Appendix below).
78
79
      "Derivative Works" shall mean any work, whether in Source or Object
80
      form, that is based on (or derived from) the Work and for which the
81
      editorial revisions, annotations, elaborations, or other modifications
82
      represent, as a whole, an original work of authorship. For the purposes
83
      of this License, Derivative Works shall not include works that remain
84
      separable from, or merely link (or bind by name) to the interfaces of,
85
      the Work and Derivative Works thereof.
86
87
      "Contribution" shall mean any work of authorship, including
88
      the original version of the Work and any modifications or additions
89
      to that Work or Derivative Works thereof, that is intentionally
90
      submitted to Licensor for inclusion in the Work by the copyright owner
91
      or by an individual or Legal Entity authorized to submit on behalf of
92
      the copyright owner. For the purposes of this definition, "submitted"
93
      means any form of electronic, verbal, or written communication sent
94
      to the Licensor or its representatives, including but not limited to
95
      communication on electronic mailing lists, source code control systems,
96
      and issue tracking systems that are managed by, or on behalf of, the
97
      Licensor for the purpose of discussing and improving the Work, but
98
      excluding communication that is conspicuously marked or otherwise
99
      designated in writing by the copyright owner as "Not a Contribution."
100
101
      "Contributor" shall mean Licensor and any individual or Legal Entity
102
      on behalf of whom a Contribution has been received by Licensor and
103
      subsequently incorporated within the Work.
104
105
   2. Grant of Copyright License. Subject to the terms and conditions of
106
      this License, each Contributor hereby grants to You a perpetual,
107
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
108
      copyright license to reproduce, prepare Derivative Works of,
109
      publicly display, publicly perform, sublicense, and distribute the
110
      Work and such Derivative Works in Source or Object form.
111
112
   3. Grant of Patent License. Subject to the terms and conditions of
113
      this License, each Contributor hereby grants to You a perpetual,
114
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
115
      (except as stated in this section) patent license to make, have made,
116
      use, offer to sell, sell, import, and otherwise transfer the Work,
117
      where such license applies only to those patent claims licensable
118
      by such Contributor that are necessarily infringed by their
119
      Contribution(s) alone or by combination of their Contribution(s)
120
      with the Work to which such Contribution(s) was submitted. If You
121
      institute patent litigation against any entity (including a
122
      cross-claim or counterclaim in a lawsuit) alleging that the Work
123
      or a Contribution incorporated within the Work constitutes direct
124
      or contributory patent infringement, then any patent licenses
125
      granted to You under this License for that Work shall terminate
126
      as of the date such litigation is filed.
127
128
   4. Redistribution. You may reproduce and distribute copies of the
129
      Work or Derivative Works thereof in any medium, with or without
130
      modifications, and in Source or Object form, provided that You
131
      meet the following conditions:
132
133
      (a) You must give any other recipients of the Work or
134
          Derivative Works a copy of this License; and
135
136
      (b) You must cause any modified files to carry prominent notices
137
          stating that You changed the files; and
138
139
      (c) You must retain, in the Source form of any Derivative Works
140
          that You distribute, all copyright, patent, trademark, and
141
          attribution notices from the Source form of the Work,
142
          excluding those notices that do not pertain to any part of
143
          the Derivative Works; and
144
145
      (d) If the Work includes a "NOTICE" text file as part of its
146
          distribution, then any Derivative Works that You distribute must
147
          include a readable copy of the attribution notices contained
148
          within such NOTICE file, excluding those notices that do not
149
          pertain to any part of the Derivative Works, in at least one
150
          of the following places: within a NOTICE text file distributed
151
          as part of the Derivative Works; within the Source form or
152
          documentation, if provided along with the Derivative Works; or,
153
          within a display generated by the Derivative Works, if and
154
          wherever such third-party notices normally appear. The contents
155
          of the NOTICE file are for informational purposes only and
156
          do not modify the License. You may add Your own attribution
157
          notices within Derivative Works that You distribute, alongside
158
          or as an addendum to the NOTICE text from the Work, provided
159
          that such additional attribution notices cannot be construed
160
          as modifying the License.
161
162
      You may add Your own copyright statement to Your modifications and
163
      may provide additional or different license terms and conditions
164
      for use, reproduction, or distribution of Your modifications, or
165
      for any such Derivative Works as a whole, provided Your use,
166
      reproduction, and distribution of the Work otherwise complies with
167
      the conditions stated in this License.
168
169
   5. Submission of Contributions. Unless You explicitly state otherwise,
170
      any Contribution intentionally submitted for inclusion in the Work
171
      by You to the Licensor shall be under the terms and conditions of
172
      this License, without any additional terms or conditions.
173
      Notwithstanding the above, nothing herein shall supersede or modify
174
      the terms of any separate license agreement you may have executed
175
      with Licensor regarding such Contributions.
176
177
   6. Trademarks. This License does not grant permission to use the trade
178
      names, trademarks, service marks, or product names of the Licensor,
179
      except as required for reasonable and customary use in describing the
180
      origin of the Work and reproducing the content of the NOTICE file.
181
182
   7. Disclaimer of Warranty. Unless required by applicable law or
183
      agreed to in writing, Licensor provides the Work (and each
184
      Contributor provides its Contributions) on an "AS IS" BASIS,
185
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
186
      implied, including, without limitation, any warranties or conditions
187
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
188
      PARTICULAR PURPOSE. You are solely responsible for determining the
189
      appropriateness of using or redistributing the Work and assume any
190
      risks associated with Your exercise of permissions under this License.
191
192
   8. Limitation of Liability. In no event and under no legal theory,
193
      whether in tort (including negligence), contract, or otherwise,
194
      unless required by applicable law (such as deliberate and grossly
195
      negligent acts) or agreed to in writing, shall any Contributor be
196
      liable to You for damages, including any direct, indirect, special,
197
      incidental, or consequential damages of any character arising as a
198
      result of this License or out of the use or inability to use the
199
      Work (including but not limited to damages for loss of goodwill,
200
      work stoppage, computer failure or malfunction, or any and all
201
      other commercial damages or losses), even if such Contributor
202
      has been advised of the possibility of such damages.
203
204
   9. Accepting Warranty or Additional Liability. While redistributing
205
      the Work or Derivative Works thereof, You may choose to offer,
206
      and charge a fee for, acceptance of support, warranty, indemnity,
207
      or other liability obligations and/or rights consistent with this
208
      License. However, in accepting such obligations, You may act only
209
      on Your own behalf and on Your sole responsibility, not on behalf
210
      of any other Contributor, and only if You agree to indemnify,
211
      defend, and hold each Contributor harmless for any liability
212
      incurred by, or claims asserted against, such Contributor by reason
213
      of your accepting any such warranty or additional liability.
214
215
   END OF TERMS AND CONDITIONS
216
217
   APPENDIX: How to apply the Apache License to your work.
218
219
      To apply the Apache License to your work, attach the following
220
      boilerplate notice, with the fields enclosed by brackets "[]"
221
      replaced with your own identifying information. (Don't include
222
      the brackets!)  The text should be enclosed in the appropriate
223
      comment syntax for the file format. We also recommend that a
224
      file or class name and description of purpose be included on the
225
      same "printed page" as the copyright notice for easier
226
      identification within third-party archives.
227
228
   Copyright [yyyy] [name of copyright owner]
229
230
   Licensed under the Apache License, Version 2.0 (the "License");
231
   you may not use this file except in compliance with the License.
232
   You may obtain a copy of the License at
233
234
       http://www.apache.org/licenses/LICENSE-2.0
235
236
   Unless required by applicable law or agreed to in writing, software
237
   distributed under the License is distributed on an "AS IS" BASIS,
238
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
239
   See the License for the specific language governing permissions and
240
   limitations under the License.
crates/coder-lite/src/markdown/assets/tokyo-night.tmTheme added +1312

@@ -0,0 +1,1312 @@

1
<?xml version="1.0" encoding="UTF-8"?>
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
<plist version="1.0">
4
<dict>
5
	<key>name</key>
6
	<string>Tokyo Night</string>
7
	<key>settings</key>
8
	<array>
9
		<dict>
10
			<key>settings</key>
11
			<dict>
12
				<key>background</key>
13
				<string>#1a1b26</string>
14
				<key>caret</key>
15
				<string>#c0caf5</string>
16
				<key>foreground</key>
17
				<string>#a9b1d6</string>
18
				<key>invisibles</key>
19
				<string>#363b54</string>
20
				<key>lineHighlight</key>
21
				<string>#1e202e</string>
22
				<key>selection</key>
23
				<string>#515c7e4d</string>
24
			</dict>
25
		</dict>
26
		<dict>
27
			<key>name</key>
28
			<string>Italics - Comments, Storage, Keyword Flow, Vue attributes, Decorators</string>
29
			<key>scope</key>
30
			<string>comment, meta.var.expr storage.type, keyword.control.flow, keyword.control.return, meta.directive.vue punctuation.separator.key-value.html, meta.directive.vue entity.other.attribute-name.html, tag.decorator.js entity.name.tag.js, tag.decorator.js punctuation.definition.tag.js, storage.modifier, string.quoted.docstring.multi, string.quoted.docstring.multi.python punctuation.definition.string.begin, string.quoted.docstring.multi.python punctuation.definition.string.end, string.quoted.docstring.multi.python constant.character.escape</string>
31
			<key>settings</key>
32
			<dict>
33
				<key>fontStyle</key>
34
				<string>italic</string>
35
			</dict>
36
		</dict>
37
		<dict>
38
			<key>name</key>
39
			<string>Fix YAML block scalar, Python Logical</string>
40
			<key>scope</key>
41
			<string>keyword.control.flow.block-scalar.literal, keyword.control.flow.python</string>
42
			<key>settings</key>
43
			<dict>
44
				<key>fontStyle</key>
45
				<string></string>
46
			</dict>
47
		</dict>
48
		<dict>
49
			<key>name</key>
50
			<string>Comment</string>
51
			<key>scope</key>
52
			<string>comment, comment.block.documentation, punctuation.definition.comment, comment.block.documentation punctuation, string.quoted.docstring.multi, string.quoted.docstring.multi.python punctuation.definition.string.begin, string.quoted.docstring.multi.python punctuation.definition.string.end, string.quoted.docstring.multi.python constant.character.escape</string>
53
			<key>settings</key>
54
			<dict>
55
				<key>foreground</key>
56
				<string>#51597d</string>
57
			</dict>
58
		</dict>
59
		<dict>
60
			<key>name</key>
61
			<string>Comment Doc</string>
62
			<key>scope</key>
63
			<string>keyword.operator.assignment.jsdoc, comment.block.documentation variable, comment.block.documentation storage, comment.block.documentation keyword, comment.block.documentation support, comment.block.documentation markup, comment.block.documentation markup.inline.raw.string.markdown, meta.other.type.phpdoc.php keyword.other.type.php, meta.other.type.phpdoc.php support.other.namespace.php, meta.other.type.phpdoc.php punctuation.separator.inheritance.php, meta.other.type.phpdoc.php support.class, keyword.other.phpdoc.php, log.date</string>
64
			<key>settings</key>
65
			<dict>
66
				<key>foreground</key>
67
				<string>#5a638c</string>
68
			</dict>
69
		</dict>
70
		<dict>
71
			<key>name</key>
72
			<string>Comment Doc Emphasized</string>
73
			<key>scope</key>
74
			<string>meta.other.type.phpdoc.php support.class, comment.block.documentation storage.type, comment.block.documentation punctuation.definition.block.tag, comment.block.documentation entity.name.type.instance</string>
75
			<key>settings</key>
76
			<dict>
77
				<key>foreground</key>
78
				<string>#646e9c</string>
79
			</dict>
80
		</dict>
81
		<dict>
82
			<key>name</key>
83
			<string>Number, Boolean, Undefined, Null</string>
84
			<key>scope</key>
85
			<string>variable.other.constant, punctuation.definition.constant, constant.language, constant.numeric, support.constant, constant.other.caps</string>
86
			<key>settings</key>
87
			<dict>
88
				<key>foreground</key>
89
				<string>#ff9e64</string>
90
			</dict>
91
		</dict>
92
		<dict>
93
			<key>name</key>
94
			<string>String, Symbols</string>
95
			<key>scope</key>
96
			<string>string, constant.other.symbol, constant.other.key, meta.attribute-selector, string constant.character</string>
97
			<key>settings</key>
98
			<dict>
99
				<key>fontStyle</key>
100
				<string></string>
101
				<key>foreground</key>
102
				<string>#9ece6a</string>
103
			</dict>
104
		</dict>
105
		<dict>
106
			<key>name</key>
107
			<string>Colors</string>
108
			<key>scope</key>
109
			<string>constant.other.color, constant.other.color.rgb-value.hex punctuation.definition.constant</string>
110
			<key>settings</key>
111
			<dict>
112
				<key>foreground</key>
113
				<string>#9aa5ce</string>
114
			</dict>
115
		</dict>
116
		<dict>
117
			<key>name</key>
118
			<string>Invalid</string>
119
			<key>scope</key>
120
			<string>invalid, invalid.illegal</string>
121
			<key>settings</key>
122
			<dict>
123
				<key>foreground</key>
124
				<string>#ff5370</string>
125
			</dict>
126
		</dict>
127
		<dict>
128
			<key>name</key>
129
			<string>Invalid deprecated</string>
130
			<key>scope</key>
131
			<string>invalid.deprecated</string>
132
			<key>settings</key>
133
			<dict>
134
				<key>foreground</key>
135
				<string>#bb9af7</string>
136
			</dict>
137
		</dict>
138
		<dict>
139
			<key>name</key>
140
			<string>Storage Type</string>
141
			<key>scope</key>
142
			<string>storage.type</string>
143
			<key>settings</key>
144
			<dict>
145
				<key>foreground</key>
146
				<string>#bb9af7</string>
147
			</dict>
148
		</dict>
149
		<dict>
150
			<key>name</key>
151
			<string>Storage - modifier, var, const, let</string>
152
			<key>scope</key>
153
			<string>meta.var.expr storage.type, storage.modifier</string>
154
			<key>settings</key>
155
			<dict>
156
				<key>foreground</key>
157
				<string>#9d7cd8</string>
158
			</dict>
159
		</dict>
160
		<dict>
161
			<key>name</key>
162
			<string>Interpolation, PHP tags, Smarty tags</string>
163
			<key>scope</key>
164
			<string>punctuation.definition.template-expression, punctuation.section.embedded, meta.embedded.line.tag.smarty, support.constant.handlebars, punctuation.section.tag.twig</string>
165
			<key>settings</key>
166
			<dict>
167
				<key>foreground</key>
168
				<string>#7dcfff</string>
169
			</dict>
170
		</dict>
171
		<dict>
172
			<key>name</key>
173
			<string>Blade, Twig, Smarty Handlebars keywords</string>
174
			<key>scope</key>
175
			<string>keyword.control.smarty, keyword.control.twig, support.constant.handlebars keyword.control, keyword.operator.comparison.twig, keyword.blade, entity.name.function.blade, meta.tag.blade keyword.other.type.php</string>
176
			<key>settings</key>
177
			<dict>
178
				<key>foreground</key>
179
				<string>#0db9d7</string>
180
			</dict>
181
		</dict>
182
		<dict>
183
			<key>name</key>
184
			<string>Spread</string>
185
			<key>scope</key>
186
			<string>keyword.operator.spread, keyword.operator.rest</string>
187
			<key>settings</key>
188
			<dict>
189
				<key>foreground</key>
190
				<string>#f7768e</string>
191
				<key>fontStyle</key>
192
				<string>bold</string>
193
			</dict>
194
		</dict>
195
		<dict>
196
			<key>name</key>
197
			<string>Operator, Misc</string>
198
			<key>scope</key>
199
			<string>keyword.operator, keyword.control.as, keyword.other, keyword.operator.bitwise.shift, punctuation, expression.embbeded.vue punctuation.definition.tag, text.html.twig meta.tag.inline.any.html, meta.tag.template.value.twig meta.function.arguments.twig, meta.directive.vue punctuation.separator.key-value.html, punctuation.definition.constant.markdown, punctuation.definition.string, punctuation.support.type.property-name, text.html.vue-html meta.tag, meta.attribute.directive, punctuation.definition.keyword, punctuation.terminator.rule, punctuation.definition.entity, punctuation.separator.inheritance.php, keyword.other.template, keyword.other.substitution, entity.name.operator, meta.property-list punctuation.separator.key-value, meta.at-rule.mixin punctuation.separator.key-value, meta.at-rule.function variable.parameter.url, meta.embedded.inline.phpx punctuation.definition.tag.begin.html, meta.embedded.inline.phpx punctuation.definition.tag.end.html</string>
200
			<key>settings</key>
201
			<dict>
202
				<key>foreground</key>
203
				<string>#89ddff</string>
204
			</dict>
205
		</dict>
206
		<dict>
207
			<key>name</key>
208
			<string>Import, Export, From, Default</string>
209
			<key>scope</key>
210
			<string>keyword.control.module.js, keyword.control.import, keyword.control.export, keyword.control.from, keyword.control.default, meta.import keyword.other</string>
211
			<key>settings</key>
212
			<dict>
213
				<key>foreground</key>
214
				<string>#7dcfff</string>
215
			</dict>
216
		</dict>
217
		<dict>
218
			<key>name</key>
219
			<string>Keyword</string>
220
			<key>scope</key>
221
			<string>keyword, keyword.control, keyword.other.important</string>
222
			<key>settings</key>
223
			<dict>
224
				<key>foreground</key>
225
				<string>#bb9af7</string>
226
			</dict>
227
		</dict>
228
		<dict>
229
			<key>name</key>
230
			<string>Keyword SQL</string>
231
			<key>scope</key>
232
			<string>keyword.other.DML</string>
233
			<key>settings</key>
234
			<dict>
235
				<key>foreground</key>
236
				<string>#7dcfff</string>
237
			</dict>
238
		</dict>
239
		<dict>
240
			<key>name</key>
241
			<string>Keyword Operator Logical, Arrow, Ternary, Comparison</string>
242
			<key>scope</key>
243
			<string>keyword.operator.logical, storage.type.function, keyword.operator.bitwise, keyword.operator.ternary, keyword.operator.comparison, keyword.operator.relational, keyword.operator.or.regexp</string>
244
			<key>settings</key>
245
			<dict>
246
				<key>foreground</key>
247
				<string>#bb9af7</string>
248
			</dict>
249
		</dict>
250
		<dict>
251
			<key>name</key>
252
			<string>Tag</string>
253
			<key>scope</key>
254
			<string>entity.name.tag</string>
255
			<key>settings</key>
256
			<dict>
257
				<key>foreground</key>
258
				<string>#f7768e</string>
259
			</dict>
260
		</dict>
261
		<dict>
262
			<key>name</key>
263
			<string>Tag - Custom / Unrecognized</string>
264
			<key>scope</key>
265
			<string>entity.name.tag support.class.component, meta.tag.custom entity.name.tag, meta.tag.other.unrecognized.html.derivative entity.name.tag, meta.tag</string>
266
			<key>settings</key>
267
			<dict>
268
				<key>foreground</key>
269
				<string>#de5971</string>
270
			</dict>
271
		</dict>
272
		<dict>
273
			<key>name</key>
274
			<string>Tag Punctuation</string>
275
			<key>scope</key>
276
			<string>punctuation.definition.tag, text.html.php meta.embedded.block.html meta.tag.metadata.script.end.html punctuation.definition.tag.begin.html text.html.basic</string>
277
			<key>settings</key>
278
			<dict>
279
				<key>foreground</key>
280
				<string>#ba3c97</string>
281
			</dict>
282
		</dict>
283
		<dict>
284
			<key>name</key>
285
			<string>Globals, PHP Constants, etc</string>
286
			<key>scope</key>
287
			<string>constant.other.php, variable.other.global.safer, variable.other.global.safer punctuation.definition.variable, variable.other.global, variable.other.global punctuation.definition.variable, constant.other</string>
288
			<key>settings</key>
289
			<dict>
290
				<key>foreground</key>
291
				<string>#e0af68</string>
292
			</dict>
293
		</dict>
294
		<dict>
295
			<key>name</key>
296
			<string>Variables</string>
297
			<key>scope</key>
298
			<string>variable, support.variable, string constant.other.placeholder, variable.parameter.handlebars, variable.other.object, meta.fstring, meta.function-call meta.function-call.arguments, meta.embedded.inline.phpx constant.other.php</string>
299
			<key>settings</key>
300
			<dict>
301
				<key>foreground</key>
302
				<string>#c0caf5</string>
303
			</dict>
304
		</dict>
305
		<dict>
306
			<key>name</key>
307
			<string>Variable Array Key</string>
308
			<key>scope</key>
309
			<string>meta.array.literal variable</string>
310
			<key>settings</key>
311
			<dict>
312
				<key>foreground</key>
313
				<string>#7dcfff</string>
314
			</dict>
315
		</dict>
316
		<dict>
317
			<key>name</key>
318
			<string>Object Key</string>
319
			<key>scope</key>
320
			<string>meta.object-literal.key, entity.name.type.hcl, string.alias.graphql, string.unquoted.graphql, string.unquoted.alias.graphql, meta.group.braces.curly constant.other.object.key.js string.unquoted.label.js, meta.field.declaration.ts variable.object.property, meta.block entity.name.label</string>
321
			<key>settings</key>
322
			<dict>
323
				<key>foreground</key>
324
				<string>#73daca</string>
325
			</dict>
326
		</dict>
327
		<dict>
328
			<key>name</key>
329
			<string>Object Property</string>
330
			<key>scope</key>
331
			<string>variable.other.property, support.variable.property, support.variable.property.dom, meta.function-call variable.other.object.property</string>
332
			<key>settings</key>
333
			<dict>
334
				<key>foreground</key>
335
				<string>#7dcfff</string>
336
			</dict>
337
		</dict>
338
		<dict>
339
			<key>name</key>
340
			<string>Object Property</string>
341
			<key>scope</key>
342
			<string>variable.other.object.property</string>
343
			<key>settings</key>
344
			<dict>
345
				<key>foreground</key>
346
				<string>#c0caf5</string>
347
			</dict>
348
		</dict>
349
		<dict>
350
			<key>name</key>
351
			<string>Object Literal Member lvl 3 (Vue Prop Validation)</string>
352
			<key>scope</key>
353
			<string>meta.objectliteral meta.object.member meta.objectliteral meta.object.member meta.objectliteral meta.object.member meta.object-literal.key</string>
354
			<key>settings</key>
355
			<dict>
356
				<key>foreground</key>
357
				<string>#41a6b5</string>
358
			</dict>
359
		</dict>
360
		<dict>
361
			<key>name</key>
362
			<string>C-related Block Level Variables</string>
363
			<key>scope</key>
364
			<string>source.cpp meta.block variable.other</string>
365
			<key>settings</key>
366
			<dict>
367
				<key>foreground</key>
368
				<string>#f7768e</string>
369
			</dict>
370
		</dict>
371
		<dict>
372
			<key>name</key>
373
			<string>Other Variable</string>
374
			<key>scope</key>
375
			<string>support.other.variable</string>
376
			<key>settings</key>
377
			<dict>
378
				<key>foreground</key>
379
				<string>#f7768e</string>
380
			</dict>
381
		</dict>
382
		<dict>
383
			<key>name</key>
384
			<string>Methods</string>
385
			<key>scope</key>
386
			<string>meta.class-method.js entity.name.function.js, entity.name.method.js, variable.function.constructor, keyword.other.special-method, storage.type.cs</string>
387
			<key>settings</key>
388
			<dict>
389
				<key>foreground</key>
390
				<string>#7aa2f7</string>
391
			</dict>
392
		</dict>
393
		<dict>
394
			<key>name</key>
395
			<string>Function Definition</string>
396
			<key>scope</key>
397
			<string>entity.name.function, variable.other.enummember, meta.function-call, meta.function-call entity.name.function, variable.function, meta.definition.method entity.name.function, meta.object-literal entity.name.function</string>
398
			<key>settings</key>
399
			<dict>
400
				<key>foreground</key>
401
				<string>#7aa2f7</string>
402
			</dict>
403
		</dict>
404
		<dict>
405
			<key>name</key>
406
			<string>Function Argument</string>
407
			<key>scope</key>
408
			<string>variable.parameter.function.language.special, variable.parameter, meta.function.parameters punctuation.definition.variable, meta.function.parameter variable</string>
409
			<key>settings</key>
410
			<dict>
411
				<key>foreground</key>
412
				<string>#e0af68</string>
413
			</dict>
414
		</dict>
415
		<dict>
416
			<key>name</key>
417
			<string>Constant, Tag Attribute</string>
418
			<key>scope</key>
419
			<string>keyword.other.type.php, storage.type.php, constant.character, constant.escape, keyword.other.unit</string>
420
			<key>settings</key>
421
			<dict>
422
				<key>foreground</key>
423
				<string>#bb9af7</string>
424
			</dict>
425
		</dict>
426
		<dict>
427
			<key>name</key>
428
			<string>Variable Definition</string>
429
			<key>scope</key>
430
			<string>meta.definition.variable variable.other.constant, meta.definition.variable variable.other.readwrite, variable.declaration.hcl variable.other.readwrite.hcl, meta.mapping.key.hcl variable.other.readwrite.hcl, variable.other.declaration</string>
431
			<key>settings</key>
432
			<dict>
433
				<key>foreground</key>
434
				<string>#bb9af7</string>
435
			</dict>
436
		</dict>
437
		<dict>
438
			<key>name</key>
439
			<string>Inherited Class</string>
440
			<key>scope</key>
441
			<string>entity.other.inherited-class</string>
442
			<key>settings</key>
443
			<dict>
444
				<key>fontStyle</key>
445
				<string></string>
446
				<key>foreground</key>
447
				<string>#bb9af7</string>
448
			</dict>
449
		</dict>
450
		<dict>
451
			<key>name</key>
452
			<string>Class, Support, DOM, etc</string>
453
			<key>scope</key>
454
			<string>support.class, support.type, variable.other.readwrite.alias, support.orther.namespace.use.php, meta.use.php, support.other.namespace.php, support.type.sys-types, support.variable.dom, support.constant.math, support.type.object.module, support.constant.json, entity.name.namespace, meta.import.qualifier, variable.other.constant.object</string>
455
			<key>settings</key>
456
			<dict>
457
				<key>foreground</key>
458
				<string>#0db9d7</string>
459
			</dict>
460
		</dict>
461
		<dict>
462
			<key>name</key>
463
			<string>Class Name</string>
464
			<key>scope</key>
465
			<string>entity.name</string>
466
			<key>settings</key>
467
			<dict>
468
				<key>foreground</key>
469
				<string>#c0caf5</string>
470
			</dict>
471
		</dict>
472
		<dict>
473
			<key>name</key>
474
			<string>Support Function</string>
475
			<key>scope</key>
476
			<string>support.function</string>
477
			<key>settings</key>
478
			<dict>
479
				<key>foreground</key>
480
				<string>#0db9d7</string>
481
			</dict>
482
		</dict>
483
		<dict>
484
			<key>name</key>
485
			<string>CSS Class and Support</string>
486
			<key>scope</key>
487
			<string>source.css support.type.property-name, source.sass support.type.property-name, source.scss support.type.property-name, source.less support.type.property-name, source.stylus support.type.property-name, source.postcss support.type.property-name, support.type.property-name.css, support.type.vendored.property-name, support.type.map.key</string>
488
			<key>settings</key>
489
			<dict>
490
				<key>foreground</key>
491
				<string>#7aa2f7</string>
492
			</dict>
493
		</dict>
494
		<dict>
495
			<key>name</key>
496
			<string>CSS Font</string>
497
			<key>scope</key>
498
			<string>support.constant.font-name, meta.definition.variable</string>
499
			<key>settings</key>
500
			<dict>
501
				<key>foreground</key>
502
				<string>#9ece6a</string>
503
			</dict>
504
		</dict>
505
		<dict>
506
			<key>name</key>
507
			<string>CSS Class</string>
508
			<key>scope</key>
509
			<string>entity.other.attribute-name.class, meta.at-rule.mixin.scss entity.name.function.scss</string>
510
			<key>settings</key>
511
			<dict>
512
				<key>foreground</key>
513
				<string>#9ece6a</string>
514
			</dict>
515
		</dict>
516
		<dict>
517
			<key>name</key>
518
			<string>CSS ID</string>
519
			<key>scope</key>
520
			<string>entity.other.attribute-name.id</string>
521
			<key>settings</key>
522
			<dict>
523
				<key>foreground</key>
524
				<string>#fc7b7b</string>
525
			</dict>
526
		</dict>
527
		<dict>
528
			<key>name</key>
529
			<string>CSS Tag</string>
530
			<key>scope</key>
531
			<string>entity.name.tag.css</string>
532
			<key>settings</key>
533
			<dict>
534
				<key>foreground</key>
535
				<string>#0db9d7</string>
536
			</dict>
537
		</dict>
538
		<dict>
539
			<key>name</key>
540
			<string>CSS Tag Reference, Pseudo &amp; Class Punctuation</string>
541
			<key>scope</key>
542
			<string>entity.other.attribute-name.pseudo-class punctuation.definition.entity, entity.other.attribute-name.pseudo-element punctuation.definition.entity, entity.other.attribute-name.class punctuation.definition.entity, entity.name.tag.reference</string>
543
			<key>settings</key>
544
			<dict>
545
				<key>foreground</key>
546
				<string>#e0af68</string>
547
			</dict>
548
		</dict>
549
		<dict>
550
			<key>name</key>
551
			<string>CSS Punctuation</string>
552
			<key>scope</key>
553
			<string>meta.property-list</string>
554
			<key>settings</key>
555
			<dict>
556
				<key>foreground</key>
557
				<string>#9abdf5</string>
558
			</dict>
559
		</dict>
560
		<dict>
561
			<key>name</key>
562
			<string>CSS at-rule fix</string>
563
			<key>scope</key>
564
			<string>meta.property-list meta.at-rule.if, meta.at-rule.return variable.parameter.url, meta.property-list meta.at-rule.else</string>
565
			<key>settings</key>
566
			<dict>
567
				<key>foreground</key>
568
				<string>#ff9e64</string>
569
			</dict>
570
		</dict>
571
		<dict>
572
			<key>name</key>
573
			<string>CSS Parent Selector Entity</string>
574
			<key>scope</key>
575
			<string>entity.other.attribute-name.parent-selector-suffix punctuation.definition.entity.css</string>
576
			<key>settings</key>
577
			<dict>
578
				<key>foreground</key>
579
				<string>#73daca</string>
580
			</dict>
581
		</dict>
582
		<dict>
583
			<key>name</key>
584
			<string>CSS Punctuation comma fix</string>
585
			<key>scope</key>
586
			<string>meta.property-list meta.property-list</string>
587
			<key>settings</key>
588
			<dict>
589
				<key>foreground</key>
590
				<string>#9abdf5</string>
591
			</dict>
592
		</dict>
593
		<dict>
594
			<key>name</key>
595
			<string>SCSS @</string>
596
			<key>scope</key>
597
			<string>meta.at-rule.mixin keyword.control.at-rule.mixin, meta.at-rule.include entity.name.function.scss, meta.at-rule.include keyword.control.at-rule.include</string>
598
			<key>settings</key>
599
			<dict>
600
				<key>foreground</key>
601
				<string>#bb9af7</string>
602
			</dict>
603
		</dict>
604
		<dict>
605
			<key>name</key>
606
			<string>SCSS Mixins, Extends, Include Keyword</string>
607
			<key>scope</key>
608
			<string>keyword.control.at-rule.include punctuation.definition.keyword, keyword.control.at-rule.mixin punctuation.definition.keyword, meta.at-rule.include keyword.control.at-rule.include, keyword.control.at-rule.extend punctuation.definition.keyword, meta.at-rule.extend keyword.control.at-rule.extend, entity.other.attribute-name.placeholder.css punctuation.definition.entity.css, meta.at-rule.media keyword.control.at-rule.media, meta.at-rule.mixin keyword.control.at-rule.mixin, meta.at-rule.function keyword.control.at-rule.function, keyword.control punctuation.definition.keyword</string>
609
			<key>settings</key>
610
			<dict>
611
				<key>foreground</key>
612
				<string>#9d7cd8</string>
613
			</dict>
614
		</dict>
615
		<dict>
616
			<key>name</key>
617
			<string>SCSS Include Mixin Argument</string>
618
			<key>scope</key>
619
			<string>meta.property-list meta.at-rule.include</string>
620
			<key>settings</key>
621
			<dict>
622
				<key>foreground</key>
623
				<string>#c0caf5</string>
624
			</dict>
625
		</dict>
626
		<dict>
627
			<key>name</key>
628
			<string>CSS value</string>
629
			<key>scope</key>
630
			<string>support.constant.property-value</string>
631
			<key>settings</key>
632
			<dict>
633
				<key>foreground</key>
634
				<string>#ff9e64</string>
635
			</dict>
636
		</dict>
637
		<dict>
638
			<key>name</key>
639
			<string>Sub-methods</string>
640
			<key>scope</key>
641
			<string>entity.name.module.js, variable.import.parameter.js, variable.other.class.js</string>
642
			<key>settings</key>
643
			<dict>
644
				<key>foreground</key>
645
				<string>#c0caf5</string>
646
			</dict>
647
		</dict>
648
		<dict>
649
			<key>name</key>
650
			<string>Language methods</string>
651
			<key>scope</key>
652
			<string>variable.language</string>
653
			<key>settings</key>
654
			<dict>
655
				<key>foreground</key>
656
				<string>#f7768e</string>
657
			</dict>
658
		</dict>
659
		<dict>
660
			<key>name</key>
661
			<string>Variable punctuation</string>
662
			<key>scope</key>
663
			<string>variable.other punctuation.definition.variable</string>
664
			<key>settings</key>
665
			<dict>
666
				<key>foreground</key>
667
				<string>#c0caf5</string>
668
			</dict>
669
		</dict>
670
		<dict>
671
			<key>name</key>
672
			<string>Keyword this with Punctuation, ES7 Bind Operator</string>
673
			<key>scope</key>
674
			<string>source.js constant.other.object.key.js string.unquoted.label.js, variable.language.this punctuation.definition.variable, keyword.other.this</string>
675
			<key>settings</key>
676
			<dict>
677
				<key>foreground</key>
678
				<string>#f7768e</string>
679
			</dict>
680
		</dict>
681
		<dict>
682
			<key>name</key>
683
			<string>HTML Attributes</string>
684
			<key>scope</key>
685
			<string>entity.other.attribute-name, text.html.basic entity.other.attribute-name.html, text.html.basic entity.other.attribute-name</string>
686
			<key>settings</key>
687
			<dict>
688
				<key>foreground</key>
689
				<string>#bb9af7</string>
690
			</dict>
691
		</dict>
692
		<dict>
693
			<key>name</key>
694
			<string>HTML Character Entity</string>
695
			<key>scope</key>
696
			<string>text.html constant.character.entity</string>
697
			<key>settings</key>
698
			<dict>
699
				<key>foreground</key>
700
				<string>#0DB9D7</string>
701
			</dict>
702
		</dict>
703
		<dict>
704
			<key>name</key>
705
			<string>Vue (Vetur / deprecated) Template attributes</string>
706
			<key>scope</key>
707
			<string>entity.other.attribute-name.id.html, meta.directive.vue entity.other.attribute-name.html</string>
708
			<key>settings</key>
709
			<dict>
710
				<key>foreground</key>
711
				<string>#bb9af7</string>
712
			</dict>
713
		</dict>
714
		<dict>
715
			<key>name</key>
716
			<string>CSS ID&apos;s</string>
717
			<key>scope</key>
718
			<string>source.sass keyword.control</string>
719
			<key>settings</key>
720
			<dict>
721
				<key>foreground</key>
722
				<string>#7aa2f7</string>
723
			</dict>
724
		</dict>
725
		<dict>
726
			<key>name</key>
727
			<string>CSS psuedo selectors</string>
728
			<key>scope</key>
729
			<string>entity.other.attribute-name.pseudo-class, entity.other.attribute-name.pseudo-element, entity.other.attribute-name.placeholder, meta.property-list meta.property-value</string>
730
			<key>settings</key>
731
			<dict>
732
				<key>foreground</key>
733
				<string>#bb9af7</string>
734
			</dict>
735
		</dict>
736
		<dict>
737
			<key>name</key>
738
			<string>Inserted</string>
739
			<key>scope</key>
740
			<string>markup.inserted</string>
741
			<key>settings</key>
742
			<dict>
743
				<key>foreground</key>
744
				<string>#449dab</string>
745
			</dict>
746
		</dict>
747
		<dict>
748
			<key>name</key>
749
			<string>Deleted</string>
750
			<key>scope</key>
751
			<string>markup.deleted</string>
752
			<key>settings</key>
753
			<dict>
754
				<key>foreground</key>
755
				<string>#914c54</string>
756
			</dict>
757
		</dict>
758
		<dict>
759
			<key>name</key>
760
			<string>Changed</string>
761
			<key>scope</key>
762
			<string>markup.changed</string>
763
			<key>settings</key>
764
			<dict>
765
				<key>foreground</key>
766
				<string>#6183bb</string>
767
			</dict>
768
		</dict>
769
		<dict>
770
			<key>name</key>
771
			<string>Regular Expressions</string>
772
			<key>scope</key>
773
			<string>string.regexp</string>
774
			<key>settings</key>
775
			<dict>
776
				<key>foreground</key>
777
				<string>#b4f9f8</string>
778
			</dict>
779
		</dict>
780
		<dict>
781
			<key>name</key>
782
			<string>Regular Expressions - Punctuation</string>
783
			<key>scope</key>
784
			<string>punctuation.definition.group</string>
785
			<key>settings</key>
786
			<dict>
787
				<key>foreground</key>
788
				<string>#f7768e</string>
789
			</dict>
790
		</dict>
791
		<dict>
792
			<key>name</key>
793
			<string>Regular Expressions - Character Class</string>
794
			<key>scope</key>
795
			<string>constant.other.character-class.regexp</string>
796
			<key>settings</key>
797
			<dict>
798
				<key>foreground</key>
799
				<string>#bb9af7</string>
800
			</dict>
801
		</dict>
802
		<dict>
803
			<key>name</key>
804
			<string>Regular Expressions - Character Class Set</string>
805
			<key>scope</key>
806
			<string>constant.other.character-class.set.regexp, punctuation.definition.character-class.regexp</string>
807
			<key>settings</key>
808
			<dict>
809
				<key>foreground</key>
810
				<string>#e0af68</string>
811
			</dict>
812
		</dict>
813
		<dict>
814
			<key>name</key>
815
			<string>Regular Expressions - Quantifier</string>
816
			<key>scope</key>
817
			<string>keyword.operator.quantifier.regexp</string>
818
			<key>settings</key>
819
			<dict>
820
				<key>foreground</key>
821
				<string>#89ddff</string>
822
			</dict>
823
		</dict>
824
		<dict>
825
			<key>name</key>
826
			<string>Regular Expressions - Backslash</string>
827
			<key>scope</key>
828
			<string>constant.character.escape.backslash</string>
829
			<key>settings</key>
830
			<dict>
831
				<key>foreground</key>
832
				<string>#c0caf5</string>
833
			</dict>
834
		</dict>
835
		<dict>
836
			<key>name</key>
837
			<string>Escape Characters</string>
838
			<key>scope</key>
839
			<string>constant.character.escape</string>
840
			<key>settings</key>
841
			<dict>
842
				<key>foreground</key>
843
				<string>#89ddff</string>
844
			</dict>
845
		</dict>
846
		<dict>
847
			<key>name</key>
848
			<string>Decorators</string>
849
			<key>scope</key>
850
			<string>tag.decorator.js entity.name.tag.js, tag.decorator.js punctuation.definition.tag.js</string>
851
			<key>settings</key>
852
			<dict>
853
				<key>foreground</key>
854
				<string>#7aa2f7</string>
855
			</dict>
856
		</dict>
857
		<dict>
858
			<key>name</key>
859
			<string>CSS Units</string>
860
			<key>scope</key>
861
			<string>keyword.other.unit</string>
862
			<key>settings</key>
863
			<dict>
864
				<key>foreground</key>
865
				<string>#f7768e</string>
866
			</dict>
867
		</dict>
868
		<dict>
869
			<key>name</key>
870
			<string>JSON Key - Level 0</string>
871
			<key>scope</key>
872
			<string>source.json meta.structure.dictionary.json support.type.property-name.json</string>
873
			<key>settings</key>
874
			<dict>
875
				<key>foreground</key>
876
				<string>#7aa2f7</string>
877
			</dict>
878
		</dict>
879
		<dict>
880
			<key>name</key>
881
			<string>JSON Key - Level 1</string>
882
			<key>scope</key>
883
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
884
			<key>settings</key>
885
			<dict>
886
				<key>foreground</key>
887
				<string>#0db9d7</string>
888
			</dict>
889
		</dict>
890
		<dict>
891
			<key>name</key>
892
			<string>JSON Key - Level 2</string>
893
			<key>scope</key>
894
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
895
			<key>settings</key>
896
			<dict>
897
				<key>foreground</key>
898
				<string>#7dcfff</string>
899
			</dict>
900
		</dict>
901
		<dict>
902
			<key>name</key>
903
			<string>JSON Key - Level 3</string>
904
			<key>scope</key>
905
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
906
			<key>settings</key>
907
			<dict>
908
				<key>foreground</key>
909
				<string>#bb9af7</string>
910
			</dict>
911
		</dict>
912
		<dict>
913
			<key>name</key>
914
			<string>JSON Key - Level 4</string>
915
			<key>scope</key>
916
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
917
			<key>settings</key>
918
			<dict>
919
				<key>foreground</key>
920
				<string>#e0af68</string>
921
			</dict>
922
		</dict>
923
		<dict>
924
			<key>name</key>
925
			<string>JSON Key - Level 5</string>
926
			<key>scope</key>
927
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
928
			<key>settings</key>
929
			<dict>
930
				<key>foreground</key>
931
				<string>#0db9d7</string>
932
			</dict>
933
		</dict>
934
		<dict>
935
			<key>name</key>
936
			<string>JSON Key - Level 6</string>
937
			<key>scope</key>
938
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
939
			<key>settings</key>
940
			<dict>
941
				<key>foreground</key>
942
				<string>#73daca</string>
943
			</dict>
944
		</dict>
945
		<dict>
946
			<key>name</key>
947
			<string>JSON Key - Level 7</string>
948
			<key>scope</key>
949
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
950
			<key>settings</key>
951
			<dict>
952
				<key>foreground</key>
953
				<string>#f7768e</string>
954
			</dict>
955
		</dict>
956
		<dict>
957
			<key>name</key>
958
			<string>JSON Key - Level 8</string>
959
			<key>scope</key>
960
			<string>source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json</string>
961
			<key>settings</key>
962
			<dict>
963
				<key>foreground</key>
964
				<string>#9ece6a</string>
965
			</dict>
966
		</dict>
967
		<dict>
968
			<key>name</key>
969
			<string>Plain Punctuation</string>
970
			<key>scope</key>
971
			<string>punctuation.definition.list_item.markdown</string>
972
			<key>settings</key>
973
			<dict>
974
				<key>foreground</key>
975
				<string>#9abdf5</string>
976
			</dict>
977
		</dict>
978
		<dict>
979
			<key>name</key>
980
			<string>Block Punctuation</string>
981
			<key>scope</key>
982
			<string>meta.block, meta.brace, punctuation.definition.block, punctuation.definition.use, punctuation.definition.class, punctuation.definition.begin.bracket, punctuation.definition.end.bracket, punctuation.definition.switch-expression.begin.bracket, punctuation.definition.switch-expression.end.bracket, punctuation.definition.section.switch-block.begin.bracket, punctuation.definition.section.switch-block.end.bracket, punctuation.definition.group.shell, punctuation.definition.parameters, punctuation.definition.arguments, punctuation.definition.dictionary, punctuation.definition.array, punctuation.section</string>
983
			<key>settings</key>
984
			<dict>
985
				<key>foreground</key>
986
				<string>#9abdf5</string>
987
			</dict>
988
		</dict>
989
		<dict>
990
			<key>name</key>
991
			<string>Markdown - Plain</string>
992
			<key>scope</key>
993
			<string>meta.embedded.block</string>
994
			<key>settings</key>
995
			<dict>
996
				<key>foreground</key>
997
				<string>#c0caf5</string>
998
			</dict>
999
		</dict>
1000
		<dict>
1001
			<key>name</key>
1002
			<string>HTML text</string>
1003
			<key>scope</key>
1004
			<string>meta.tag JSXNested, meta.jsx.children, text.html, text.log</string>
1005
			<key>settings</key>
1006
			<dict>
1007
				<key>foreground</key>
1008
				<string>#9aa5ce</string>
1009
			</dict>
1010
		</dict>
1011
		<dict>
1012
			<key>name</key>
1013
			<string>Markdown - Markup Raw Inline</string>
1014
			<key>scope</key>
1015
			<string>text.html.markdown markup.inline.raw.markdown</string>
1016
			<key>settings</key>
1017
			<dict>
1018
				<key>foreground</key>
1019
				<string>#bb9af7</string>
1020
			</dict>
1021
		</dict>
1022
		<dict>
1023
			<key>name</key>
1024
			<string>Markdown - Markup Raw Inline Punctuation</string>
1025
			<key>scope</key>
1026
			<string>text.html.markdown markup.inline.raw.markdown punctuation.definition.raw.markdown</string>
1027
			<key>settings</key>
1028
			<dict>
1029
				<key>foreground</key>
1030
				<string>#4E5579</string>
1031
			</dict>
1032
		</dict>
1033
		<dict>
1034
			<key>name</key>
1035
			<string>Markdown - Heading 1</string>
1036
			<key>scope</key>
1037
			<string>heading.1.markdown entity.name, heading.1.markdown punctuation.definition.heading.markdown</string>
1038
			<key>settings</key>
1039
			<dict>
1040
				<key>fontStyle</key>
1041
				<string>bold</string>
1042
				<key>foreground</key>
1043
				<string>#89ddff</string>
1044
			</dict>
1045
		</dict>
1046
		<dict>
1047
			<key>name</key>
1048
			<string>Markdown - Heading 2</string>
1049
			<key>scope</key>
1050
			<string>heading.2.markdown entity.name, heading.2.markdown punctuation.definition.heading.markdown</string>
1051
			<key>settings</key>
1052
			<dict>
1053
				<key>fontStyle</key>
1054
				<string>bold</string>
1055
				<key>foreground</key>
1056
				<string>#61bdf2</string>
1057
			</dict>
1058
		</dict>
1059
		<dict>
1060
			<key>name</key>
1061
			<string>Markdown - Heading 3</string>
1062
			<key>scope</key>
1063
			<string>heading.3.markdown entity.name, heading.3.markdown punctuation.definition.heading.markdown</string>
1064
			<key>settings</key>
1065
			<dict>
1066
				<key>fontStyle</key>
1067
				<string>bold</string>
1068
				<key>foreground</key>
1069
				<string>#7aa2f7</string>
1070
			</dict>
1071
		</dict>
1072
		<dict>
1073
			<key>name</key>
1074
			<string>Markdown - Heading 4</string>
1075
			<key>scope</key>
1076
			<string>heading.4.markdown entity.name, heading.4.markdown punctuation.definition.heading.markdown</string>
1077
			<key>settings</key>
1078
			<dict>
1079
				<key>fontStyle</key>
1080
				<string>bold</string>
1081
				<key>foreground</key>
1082
				<string>#6d91de</string>
1083
			</dict>
1084
		</dict>
1085
		<dict>
1086
			<key>name</key>
1087
			<string>Markdown - Heading 5</string>
1088
			<key>scope</key>
1089
			<string>heading.5.markdown entity.name, heading.5.markdown punctuation.definition.heading.markdown</string>
1090
			<key>settings</key>
1091
			<dict>
1092
				<key>fontStyle</key>
1093
				<string>bold</string>
1094
				<key>foreground</key>
1095
				<string>#9aa5ce</string>
1096
			</dict>
1097
		</dict>
1098
		<dict>
1099
			<key>name</key>
1100
			<string>Markdown - Heading 6</string>
1101
			<key>scope</key>
1102
			<string>heading.6.markdown entity.name, heading.6.markdown punctuation.definition.heading.markdown</string>
1103
			<key>settings</key>
1104
			<dict>
1105
				<key>fontStyle</key>
1106
				<string>bold</string>
1107
				<key>foreground</key>
1108
				<string>#747ca1</string>
1109
			</dict>
1110
		</dict>
1111
		<dict>
1112
			<key>name</key>
1113
			<string>Markup - Italic</string>
1114
			<key>scope</key>
1115
			<string>markup.italic, markup.italic punctuation</string>
1116
			<key>settings</key>
1117
			<dict>
1118
				<key>foreground</key>
1119
				<string>#c0caf5</string>
1120
				<key>fontStyle</key>
1121
				<string>italic</string>
1122
			</dict>
1123
		</dict>
1124
		<dict>
1125
			<key>name</key>
1126
			<string>Markup - Bold</string>
1127
			<key>scope</key>
1128
			<string>markup.bold, markup.bold punctuation</string>
1129
			<key>settings</key>
1130
			<dict>
1131
				<key>foreground</key>
1132
				<string>#c0caf5</string>
1133
				<key>fontStyle</key>
1134
				<string>bold</string>
1135
			</dict>
1136
		</dict>
1137
		<dict>
1138
			<key>name</key>
1139
			<string>Markup - Bold-Italic</string>
1140
			<key>scope</key>
1141
			<string>markup.bold markup.italic, markup.bold markup.italic punctuation</string>
1142
			<key>settings</key>
1143
			<dict>
1144
				<key>fontStyle</key>
1145
				<string>bold italic</string>
1146
				<key>foreground</key>
1147
				<string>#c0caf5</string>
1148
			</dict>
1149
		</dict>
1150
		<dict>
1151
			<key>name</key>
1152
			<string>Markup - Underline</string>
1153
			<key>scope</key>
1154
			<string>markup.underline, markup.underline punctuation</string>
1155
			<key>settings</key>
1156
			<dict>
1157
				<key>fontStyle</key>
1158
				<string>underline</string>
1159
			</dict>
1160
		</dict>
1161
		<dict>
1162
			<key>name</key>
1163
			<string>Markdown - Blockquote</string>
1164
			<key>scope</key>
1165
			<string>markup.quote punctuation.definition.blockquote.markdown</string>
1166
			<key>settings</key>
1167
			<dict>
1168
				<key>foreground</key>
1169
				<string>#4e5579</string>
1170
			</dict>
1171
		</dict>
1172
		<dict>
1173
			<key>name</key>
1174
			<string>Markup - Quote</string>
1175
			<key>scope</key>
1176
			<string>markup.quote</string>
1177
			<key>settings</key>
1178
			<dict>
1179
				<key>fontStyle</key>
1180
				<string>italic</string>
1181
			</dict>
1182
		</dict>
1183
		<dict>
1184
			<key>name</key>
1185
			<string>Markdown - Link</string>
1186
			<key>scope</key>
1187
			<string>string.other.link, markup.underline.link, constant.other.reference.link.markdown, string.other.link.description.title.markdown</string>
1188
			<key>settings</key>
1189
			<dict>
1190
				<key>foreground</key>
1191
				<string>#73daca</string>
1192
			</dict>
1193
		</dict>
1194
		<dict>
1195
			<key>name</key>
1196
			<string>Markdown - Fenced Code Block</string>
1197
			<key>scope</key>
1198
			<string>markup.fenced_code.block.markdown, markup.inline.raw.string.markdown, variable.language.fenced.markdown</string>
1199
			<key>settings</key>
1200
			<dict>
1201
				<key>foreground</key>
1202
				<string>#89ddff</string>
1203
			</dict>
1204
		</dict>
1205
		<dict>
1206
			<key>name</key>
1207
			<string>Markdown - Separator</string>
1208
			<key>scope</key>
1209
			<string>meta.separator</string>
1210
			<key>settings</key>
1211
			<dict>
1212
				<key>fontStyle</key>
1213
				<string>bold</string>
1214
				<key>foreground</key>
1215
				<string>#51597d</string>
1216
			</dict>
1217
		</dict>
1218
		<dict>
1219
			<key>name</key>
1220
			<string>Markup - Table</string>
1221
			<key>scope</key>
1222
			<string>markup.table</string>
1223
			<key>settings</key>
1224
			<dict>
1225
				<key>foreground</key>
1226
				<string>#c0cefc</string>
1227
			</dict>
1228
		</dict>
1229
		<dict>
1230
			<key>name</key>
1231
			<string>Token - Info</string>
1232
			<key>scope</key>
1233
			<string>token.info-token</string>
1234
			<key>settings</key>
1235
			<dict>
1236
				<key>foreground</key>
1237
				<string>#0db9d7</string>
1238
			</dict>
1239
		</dict>
1240
		<dict>
1241
			<key>name</key>
1242
			<string>Token - Warn</string>
1243
			<key>scope</key>
1244
			<string>token.warn-token</string>
1245
			<key>settings</key>
1246
			<dict>
1247
				<key>foreground</key>
1248
				<string>#ffdb69</string>
1249
			</dict>
1250
		</dict>
1251
		<dict>
1252
			<key>name</key>
1253
			<string>Token - Error</string>
1254
			<key>scope</key>
1255
			<string>token.error-token</string>
1256
			<key>settings</key>
1257
			<dict>
1258
				<key>foreground</key>
1259
				<string>#db4b4b</string>
1260
			</dict>
1261
		</dict>
1262
		<dict>
1263
			<key>name</key>
1264
			<string>Token - Debug</string>
1265
			<key>scope</key>
1266
			<string>token.debug-token</string>
1267
			<key>settings</key>
1268
			<dict>
1269
				<key>foreground</key>
1270
				<string>#b267e6</string>
1271
			</dict>
1272
		</dict>
1273
		<dict>
1274
			<key>name</key>
1275
			<string>Apache Tag</string>
1276
			<key>scope</key>
1277
			<string>entity.tag.apacheconf</string>
1278
			<key>settings</key>
1279
			<dict>
1280
				<key>foreground</key>
1281
				<string>#f7768e</string>
1282
			</dict>
1283
		</dict>
1284
		<dict>
1285
			<key>name</key>
1286
			<string>Preprocessor</string>
1287
			<key>scope</key>
1288
			<string>meta.preprocessor</string>
1289
			<key>settings</key>
1290
			<dict>
1291
				<key>foreground</key>
1292
				<string>#73daca</string>
1293
			</dict>
1294
		</dict>
1295
		<dict>
1296
			<key>name</key>
1297
			<string>ENV value</string>
1298
			<key>scope</key>
1299
			<string>source.env</string>
1300
			<key>settings</key>
1301
			<dict>
1302
				<key>foreground</key>
1303
				<string>#7aa2f7</string>
1304
			</dict>
1305
		</dict>
1306
	</array>
1307
	<key>uuid</key>
1308
	<string></string>
1309
	<key>license</key>
1310
	<string>Apache-2.0</string>
1311
</dict>
1312
</plist>
\ No newline at end of file
crates/coder-lite/src/markdown/buffers.rs added +373

@@ -0,0 +1,373 @@

1
//! Reusable buffers and internal data types for markdown parsing and rendering.
2
//!
3
//! This module contains all the intermediate data structures used by
4
//! MarkdownHighlighter during parsing and rendering.
5
6
use std::ops::Range;
7
8
use anstyle::Style as AnsiStyle;
9
use ratatui::text::{Line, Span};
10
use syntect::highlighting::Style as SyntectStyle;
11
12
/// A range of text with optional styling.
13
#[derive(Debug, Clone)]
14
pub struct Highlight {
15
    pub style: Option<AnsiStyle>,
16
    pub range: Range<usize>,
17
}
18
19
/// Syntax-highlighted code block replacement.
20
///
21
/// Stores the raw highlighted spans per line (intermediate representation).
22
/// This allows rendering to either ANSI strings or ratatui Lines on demand.
23
#[derive(Debug, Clone)]
24
pub struct Replace {
25
    /// Raw highlighted spans per line: Vec<(style, text)>.
26
    /// Each inner Vec represents one line of the code block.
27
    pub highlighted: Vec<Vec<(SyntectStyle, String)>>,
28
    /// Source byte range this replaces.
29
    pub range: Range<usize>,
30
}
31
32
/// Internal representation of a hyperlink target discovered during parsing.
33
///
34
/// Populated in the `Tag::Link` / `Tag::Image` arm of `MarkdownParser::on_start`.
35
/// Consumed during rendering to produce public `HyperlinkTarget`s in the output.
36
#[derive(Debug, Clone)]
37
pub struct LinkTarget {
38
    /// Source byte range of the *link text* (not the full `[text](url)` span).
39
    pub source_range: Range<usize>,
40
    /// Destination URL.
41
    pub url: String,
42
    /// Monotonically increasing identifier assigned during parsing.
43
    pub id: u32,
44
}
45
46
/// Parse-time record of a closed fenced code block.
47
///
48
/// Populated in the `Tag::CodeBlock` arm of `MarkdownParser`; consumed during
49
/// rendering (see `output::build_code_block_spans`) to produce the public
50
/// [`crate::markdown::CodeBlockSpan`] once the output line range is known. Only **closed**
51
/// fences are recorded — an unterminated trailing fence yields no entry.
52
#[derive(Debug, Clone)]
53
pub struct CodeBlockMeta {
54
    /// Fence info string (e.g. `"mermaid"`), verbatim from pulldown-cmark.
55
    pub info: String,
56
    /// De-prefixed body content (container markers stripped, CRLF normalized) —
57
    /// pulldown's merged body text, i.e. the clean code/diagram source.
58
    pub body: String,
59
    /// Source byte range of the fence body (delimiter lines excluded).
60
    pub body_source_range: Range<usize>,
61
}
62
63
/// Text transformation for substituting characters (e.g., bullets).
64
#[derive(Debug, Clone)]
65
pub struct Transform {
66
    /// Source byte range to transform.
67
    pub(crate) range: Range<usize>,
68
    /// Replacement text.
69
    pub(crate) to: String,
70
    /// Apply this transform even in raw (non-pretty) mode.
71
    ///
72
    /// Invariant: `to.len() == range.end - range.start` and the
73
    /// substitution must stay valid UTF-8 at the same byte offsets.
74
    /// `render_ansi` substitutes force transforms in place into a byte
75
    /// buffer; violating the invariant panics at `copy_from_slice` or
76
    /// `String::from_utf8` before any bytes escape the renderer.
77
    pub(crate) force: bool,
78
}
79
80
/// A styled segment within a table cell.
81
#[derive(Debug, Clone)]
82
pub struct CellSpan {
83
    pub text: String,
84
    pub bold: bool,
85
    pub italic: bool,
86
    pub code: bool,
87
    /// Hyperlink (url, id) when this span is inside a `[label](url)` link
88
    /// or autolink inside a table cell. `None` for plain text.
89
    pub link: Option<(String, u32)>,
90
}
91
92
impl CellSpan {
93
    pub fn new(
94
        text: String,
95
        bold: bool,
96
        italic: bool,
97
        code: bool,
98
        link: Option<(String, u32)>,
99
    ) -> Self {
100
        Self {
101
            text,
102
            bold,
103
            italic,
104
            code,
105
            link,
106
        }
107
    }
108
}
109
110
/// A table cell with styled content.
111
#[derive(Debug, Clone, Default)]
112
pub struct StyledCell {
113
    pub spans: Vec<CellSpan>,
114
}
115
116
impl StyledCell {
117
    pub fn new() -> Self {
118
        Self { spans: Vec::new() }
119
    }
120
121
    /// Get plain text content (for width calculation).
122
    pub fn plain_text(&self) -> String {
123
        self.spans.iter().map(|s| s.text.as_str()).collect()
124
    }
125
126
    /// Clear the cell content.
127
    pub fn clear(&mut self) {
128
        self.spans.clear();
129
    }
130
}
131
132
/// State for buffering table content during parsing.
133
#[derive(Debug, Clone)]
134
pub struct TableState {
135
    /// Column alignments from the table header.
136
    pub alignments: Vec<pulldown_cmark::Alignment>,
137
    /// Header row cells.
138
    pub header: Vec<StyledCell>,
139
    /// Body rows (each row is a Vec of styled cells).
140
    pub rows: Vec<Vec<StyledCell>>,
141
    /// Current row being built.
142
    pub current_row: Vec<StyledCell>,
143
    /// Current cell content being accumulated.
144
    pub current_cell: StyledCell,
145
    /// Current style state for the cell.
146
    pub cell_bold: bool,
147
    pub cell_italic: bool,
148
    pub cell_code: bool,
149
    /// Current link state: `Some((url, id))` while inside a `Tag::Link` /
150
    /// `Tag::Image` inside a table cell.  Text events captured while this
151
    /// is set produce link-tagged `CellSpan`s so the table renderer can
152
    /// apply link styling and emit `HyperlinkTarget`s.
153
    pub cell_link: Option<(String, u32)>,
154
    /// Whether we're in the header section.
155
    pub in_header: bool,
156
    /// Source byte range of the entire table.
157
    pub range: Range<usize>,
158
}
159
160
impl TableState {
161
    pub fn new(alignments: Vec<pulldown_cmark::Alignment>, start: usize) -> Self {
162
        Self {
163
            alignments,
164
            header: Vec::new(),
165
            rows: Vec::new(),
166
            current_row: Vec::new(),
167
            current_cell: StyledCell::new(),
168
            cell_bold: false,
169
            cell_italic: false,
170
            cell_code: false,
171
            cell_link: None,
172
            in_header: false,
173
            range: start..start,
174
        }
175
    }
176
177
    /// Push text with current styling to the cell.
178
    pub fn push_text(&mut self, text: &str) {
179
        self.current_cell.spans.push(CellSpan::new(
180
            text.to_string(),
181
            self.cell_bold,
182
            self.cell_italic,
183
            self.cell_code,
184
            self.cell_link.clone(),
185
        ));
186
    }
187
}
188
189
/// One hyperlink target inside a formatted table.
190
///
191
/// Coordinates are local to the table's `styled_lines`:
192
/// `line_offset` indexes into `TableReplace::styled_lines`; the renderer
193
/// adds the current absolute line count to produce a public
194
/// `HyperlinkTarget`.
195
#[derive(Debug, Clone)]
196
pub struct TableHyperlink {
197
    /// Index within `TableReplace::styled_lines`.
198
    pub line_offset: usize,
199
    /// Column range (display cells) on that line.
200
    pub column_range: Range<usize>,
201
    /// Destination URL.
202
    pub url: String,
203
    /// Stable identifier shared with the paragraph link path.
204
    pub id: u32,
205
}
206
207
/// Formatted table replacement for pretty mode rendering.
208
#[derive(Debug, Clone)]
209
pub struct TableReplace {
210
    /// Formatted table lines (plain strings for ANSI rendering).
211
    pub lines: Vec<String>,
212
    /// Styled table lines for ratatui rendering.
213
    pub styled_lines: Vec<Line<'static>>,
214
    /// Source byte range this replaces.
215
    pub range: Range<usize>,
216
    /// Per-rendered-line source offset from the table start.
217
    ///
218
    /// Maps each entry in `styled_lines` to the source line offset
219
    /// within the table (0 = header, 1 = separator, 2+ = body rows).
220
    /// Used by the renderer to produce correct `line_source_map` entries
221
    /// instead of the naive `table_start + line_idx` which overshoots
222
    /// when the rendered table has more lines than the source (borders,
223
    /// separators, wrapped cells).
224
    pub line_source_offsets: Vec<usize>,
225
    /// Hyperlinks for `[label](url)` / autolinks inside table cells.
226
    ///
227
    /// The paragraph link path (`LinkTarget` -> `chunk_link_offsets`)
228
    /// cannot project links onto a rendered table because the table
229
    /// replace consumes the entire source range — no text chunk's
230
    /// rendering walks over the link text.  The parser instead emits
231
    /// `TableHyperlink`s during table formatting with positions in
232
    /// table-local coordinates; the renderer translates them to absolute
233
    /// `HyperlinkTarget`s.
234
    pub hyperlinks: Vec<TableHyperlink>,
235
}
236
237
/// Rendered Mermaid diagram replacement for pretty mode rendering.
238
#[derive(Debug, Clone)]
239
pub struct MermaidReplace {
240
    /// Plain lines for ANSI rendering.
241
    pub lines: Vec<String>,
242
    /// Styled lines for ratatui rendering.
243
    pub styled_lines: Vec<Line<'static>>,
244
    /// Source byte range this replaces.
245
    pub range: Range<usize>,
246
}
247
248
/// Calculate the display width of a string (accounting for Unicode).
249
pub fn unicode_display_width(s: &str) -> usize {
250
    use unicode_width::UnicodeWidthStr;
251
    s.width()
252
}
253
254
/// Polyfill for `str::floor_char_boundary` (stable in Rust 1.91+).
255
///
256
/// Snaps `index` down to the nearest UTF-8 char boundary in `s`.  Indices
257
/// past the end of `s` are clamped to `s.len()`.  Replace with the std
258
/// method once the workspace toolchain is bumped to 1.91+.
259
pub(crate) fn floor_char_boundary(s: &str, index: usize) -> usize {
260
    let mut i = index.min(s.len());
261
    while i > 0 && !s.is_char_boundary(i) {
262
        i -= 1;
263
    }
264
    i
265
}
266
267
/// Polyfill for `str::ceil_char_boundary` (stable in Rust 1.91+).
268
///
269
/// Snaps `index` up to the nearest UTF-8 char boundary in `s`.  Indices
270
/// past the end of `s` are clamped to `s.len()`.  Replace with the std
271
/// method once the workspace toolchain is bumped to 1.91+.
272
pub(crate) fn ceil_char_boundary(s: &str, index: usize) -> usize {
273
    let mut i = index.min(s.len());
274
    while i < s.len() && !s.is_char_boundary(i) {
275
        i += 1;
276
    }
277
    i
278
}
279
280
/// Event kind for the render loop.
281
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
282
#[repr(u8)]
283
pub enum RenderEventKind {
284
    Highlight = 0,
285
    Replace = 1,
286
    Table = 2,
287
    Mermaid = 3,
288
}
289
290
/// Render event: marks where a highlight/replace/table starts or ends.
291
/// Derives Ord for sorting by (pos, kind, index, is_end).
292
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
293
pub struct RenderEvent {
294
    pub pos: usize,
295
    pub kind: RenderEventKind,
296
    pub index: usize,
297
    pub is_end: bool,
298
}
299
300
/// Reusable buffers for markdown highlighting and rendering.
301
///
302
/// All vectors are cleared (keeping capacity) between renders, eliminating
303
/// allocation overhead in the streaming hot path.
304
///
305
/// # Buffer Categories
306
///
307
/// **Parse output buffers** - populated during `run()`, read-only during `render()`:
308
/// - `highlights`: Style ranges for inline formatting
309
/// - `replaces`: Syntax-highlighted code blocks
310
/// - `transforms`: Character substitutions (e.g., bullets)
311
/// - `untagged_code_ranges`: Code blocks without language tags
312
/// - `table_replaces`: Formatted table replacements
313
///
314
/// **Render scratch buffers** - temporary storage during `render()`:
315
/// - `render_events`: Sorted event queue for the render loop
316
/// - `current_spans`: Building current line's spans
317
/// - `active_highlights`: Stack of active highlight indices
318
pub struct MarkdownBuffers {
319
    // Parse output buffers (written by run(), read by render())
320
    pub highlights: Vec<Highlight>,
321
    pub replaces: Vec<Replace>,
322
    pub transforms: Vec<Transform>,
323
    pub untagged_code_ranges: Vec<Range<usize>>,
324
    pub table_replaces: Vec<TableReplace>,
325
    pub mermaid_replaces: Vec<MermaidReplace>,
326
    pub link_targets: Vec<LinkTarget>,
327
    /// Closed fenced code blocks, in document order (see [`CodeBlockMeta`]).
328
    pub code_blocks: Vec<CodeBlockMeta>,
329
330
    // Render scratch buffers (used only during render())
331
    pub render_events: Vec<RenderEvent>,
332
    pub current_spans: Vec<Span<'static>>,
333
    pub active_highlights: Vec<usize>,
334
}
335
336
impl MarkdownBuffers {
337
    pub fn new() -> Self {
338
        Self {
339
            highlights: Vec::new(),
340
            replaces: Vec::new(),
341
            transforms: Vec::new(),
342
            untagged_code_ranges: Vec::new(),
343
            table_replaces: Vec::new(),
344
            mermaid_replaces: Vec::new(),
345
            link_targets: Vec::new(),
346
            code_blocks: Vec::new(),
347
            render_events: Vec::new(),
348
            current_spans: Vec::new(),
349
            active_highlights: Vec::new(),
350
        }
351
    }
352
353
    /// Clear all buffers, keeping allocated capacity.
354
    pub fn clear(&mut self) {
355
        self.highlights.clear();
356
        self.replaces.clear();
357
        self.transforms.clear();
358
        self.untagged_code_ranges.clear();
359
        self.table_replaces.clear();
360
        self.mermaid_replaces.clear();
361
        self.link_targets.clear();
362
        self.code_blocks.clear();
363
        self.render_events.clear();
364
        self.current_spans.clear();
365
        self.active_highlights.clear();
366
    }
367
}
368
369
impl Default for MarkdownBuffers {
370
    fn default() -> Self {
371
        Self::new()
372
    }
373
}
crates/coder-lite/src/markdown/checkpoint.rs added +58

@@ -0,0 +1,58 @@

1
//! Checkpoint types for incremental markdown rendering.
2
//!
3
//! This module defines types for identifying stable boundaries in markdown text
4
//! where rendered output can be "frozen" and cached. Content before a checkpoint
5
//! will not change regardless of what text is appended after it.
6
//!
7
//! # Design
8
//!
9
//! Checkpoints are only created at **top-level** (depth=0) block boundaries. Blocks
10
//! nested inside lists, blockquotes, or tables cannot be checkpoints because the
11
//! outer container might continue.
12
//!
13
//! # Example
14
//!
15
//! ```text
16
//! # Heading          <- Checkpoint after this (heading at depth=0)
17
//!
18
//! Paragraph text.    <- Checkpoint after blank line (paragraph at depth=0)
19
//!
20
//! - List item        <- NO checkpoint (inside list)
21
//!   ```code```       <- NO checkpoint (code block inside list)
22
//! - Another item
23
//!                    <- Checkpoint here (list closed at depth=0)
24
//! ```
25
26
/// A position in the source text where rendered content can be frozen.
27
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
pub struct Checkpoint {
29
    /// Byte offset in source text (exclusive end of frozen region).
30
    /// Content in `text[..source_bytes]` can be cached.
31
    pub source_bytes: usize,
32
    /// Number of output lines that correspond to this checkpoint.
33
    /// Lines `0..output_lines` can be frozen.
34
    pub output_lines: usize,
35
    /// What kind of block ended at this checkpoint.
36
    pub kind: CheckpointKind,
37
}
38
39
/// The type of markdown block that created a checkpoint.
40
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41
pub enum CheckpointKind {
42
    /// A heading (any level: h1-h6)
43
    Heading,
44
    /// A paragraph followed by a blank line
45
    Paragraph,
46
    /// A fenced or indented code block
47
    CodeBlock,
48
    /// A blockquote that closed at top level
49
    BlockQuote,
50
    /// A list (ordered or unordered) that closed at top level
51
    List,
52
    /// A thematic break (horizontal rule: ---, ***, ___)
53
    ThematicBreak,
54
    /// A table that closed at top level
55
    Table,
56
    /// A raw HTML block
57
    HtmlBlock,
58
}
crates/coder-lite/src/markdown/colors.rs added +451

@@ -0,0 +1,451 @@

1
//! Terminal color support detection and color conversion utilities.
2
//!
3
//! This module provides functionality to detect the terminal's color capabilities
4
//! and downgrade RGB colors to the appropriate level when needed.
5
6
use std::sync::OnceLock;
7
use std::sync::atomic::{AtomicU8, Ordering};
8
9
use anstyle::{Ansi256Color, AnsiColor, Color, RgbColor};
10
11
/// The level of color support detected for the terminal.
12
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
13
pub enum ColorLevel {
14
    /// No color support (monochrome terminals)
15
    None,
16
    /// Basic 16-color ANSI support (colors 0-15)
17
    Basic,
18
    /// 256-color support (colors 0-255)
19
    Ansi256,
20
    /// 24-bit truecolor RGB support (16 million colors)
21
    #[default]
22
    TrueColor,
23
}
24
25
impl ColorLevel {
26
    /// Returns true if at least basic color is supported.
27
    pub fn has_color(self) -> bool {
28
        self >= Self::Basic
29
    }
30
31
    /// Returns true if 256-color mode is supported.
32
    pub fn has_256(self) -> bool {
33
        self >= Self::Ansi256
34
    }
35
36
    /// Returns true if 24-bit truecolor is supported.
37
    pub fn has_truecolor(self) -> bool {
38
        self >= Self::TrueColor
39
    }
40
}
41
42
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
43
44
/// Detect the terminal's color support level.
45
///
46
/// This uses the `supports-color` crate which checks:
47
/// - `COLORTERM` environment variable (for truecolor detection)
48
/// - `TERM` environment variable
49
/// - Terminal-specific environment variables (like `ITERM_SESSION_ID`)
50
/// - Whether stdout is a TTY
51
///
52
/// The result is cached after the first call.
53
pub fn detect_color_level() -> ColorLevel {
54
    *COLOR_LEVEL.get_or_init(|| {
55
        // Explicit opt-out via NO_COLOR takes priority.
56
        if std::env::var_os("NO_COLOR").is_some() {
57
            return ColorLevel::None;
58
        }
59
60
        let level = match supports_color::on(supports_color::Stream::Stdout) {
61
            // Not a TTY (tests, piped) — default to TrueColor.
62
            // The pager is a TUI app that always runs inside a terminal;
63
            // stdout may not be a TTY when the pager renders to stderr.
64
            None => ColorLevel::TrueColor,
65
            Some(level) => {
66
                if level.has_16m {
67
                    ColorLevel::TrueColor
68
                } else if level.has_256 {
69
                    ColorLevel::Ansi256
70
                } else if level.has_basic {
71
                    ColorLevel::Basic
72
                } else {
73
                    ColorLevel::None
74
                }
75
            }
76
        };
77
78
        // The `supports-color` crate relies on COLORTERM=truecolor, but
79
        // tmux/SSH/mosh often strip that variable.  When the crate reports
80
        // only 256-color support, upgrade to TrueColor if we can identify
81
        // a known truecolor-capable terminal via its env vars.
82
        if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
83
            return ColorLevel::TrueColor;
84
        }
85
86
        level
87
    })
88
}
89
90
/// Check whether the terminal emulator is known to support truecolor.
91
///
92
/// Used as a fallback when `COLORTERM` is missing (e.g. inside tmux or over
93
/// SSH).  Checks terminal-specific env vars that survive session forwarding
94
/// even when `COLORTERM` and `TERM_PROGRAM` are stripped.
95
fn terminal_supports_truecolor() -> bool {
96
    use std::env;
97
98
    // TERM_PROGRAM is the most reliable signal (set by the emulator itself).
99
    if let Ok(prog) = env::var("TERM_PROGRAM") {
100
        let norm: String = prog
101
            .trim()
102
            .chars()
103
            .filter(|c| !matches!(c, ' ' | '-' | '_' | '.'))
104
            .map(|c| c.to_ascii_lowercase())
105
            .collect();
106
        // Every modern terminal except Apple Terminal supports truecolor.
107
        if matches!(
108
            norm.as_str(),
109
            "iterm"
110
                | "iterm2"
111
                | "itermapp"
112
                | "ghostty"
113
                | "kitty"
114
                | "wezterm"
115
                | "alacritty"
116
                | "warp"
117
                | "warpterminal"
118
                | "vscode"
119
        ) {
120
            return true;
121
        }
122
    }
123
124
    // Terminal-specific env vars that often survive tmux/SSH.
125
    env::var("ITERM_SESSION_ID").is_ok()
126
        || env::var("ITERM_PROFILE").is_ok()
127
        || env::var("WEZTERM_VERSION").is_ok()
128
        || env::var("KITTY_WINDOW_ID").is_ok()
129
        || env::var("ALACRITTY_SOCKET").is_ok()
130
}
131
132
/// Process-wide upper bound on the effective color level, stored as the
133
/// `ColorLevel` declaration-order discriminant.
134
static COLOR_LEVEL_CAP: AtomicU8 = AtomicU8::new(ColorLevel::TrueColor as u8);
135
136
/// When set, RGB syntax colors are remapped with [`polarity_safe_syntax_ansi`]
137
/// instead of nearest-ANSI16. Used by pager minimal mode: the canvas is the
138
/// terminal's own bg, so night-theme pastels quantized to White vanish on
139
/// light profiles. See `xai-grok-pager-render` syntax docs.
140
static POLARITY_SAFE_SYNTAX: AtomicU8 = AtomicU8::new(0);
141
142
/// Set the process-wide upper bound on the effective color level. Pass
143
/// [`ColorLevel::TrueColor`] to remove the cap.
144
pub fn set_color_level_cap(cap: ColorLevel) {
145
    COLOR_LEVEL_CAP.store(cap as u8, Ordering::Relaxed);
146
}
147
148
/// Engage dual-polarity-safe syntax color remapping (minimal / terminal-native).
149
///
150
/// When enabled, [`adapt_color`] maps near-gray RGB to "no color" (inherit
151
/// terminal default fg) and chromatic RGB to base ANSI accents — never White.
152
pub fn set_polarity_safe_syntax(enabled: bool) {
153
    POLARITY_SAFE_SYNTAX.store(u8::from(enabled), Ordering::Relaxed);
154
}
155
156
/// Whether polarity-safe syntax remapping is active.
157
#[must_use]
158
pub fn polarity_safe_syntax() -> bool {
159
    POLARITY_SAFE_SYNTAX.load(Ordering::Relaxed) != 0
160
}
161
162
fn color_level_cap() -> ColorLevel {
163
    match COLOR_LEVEL_CAP.load(Ordering::Relaxed) {
164
        0 => ColorLevel::None,
165
        1 => ColorLevel::Basic,
166
        2 => ColorLevel::Ansi256,
167
        _ => ColorLevel::TrueColor,
168
    }
169
}
170
171
/// Get the current color level (detecting if not already done), bounded by
172
/// the process-wide cap (see [`set_color_level_cap`]).
173
pub fn get_color_level() -> ColorLevel {
174
    detect_color_level().min(color_level_cap())
175
}
176
177
/// Override the color level (useful for testing or user preference).
178
///
179
/// Returns `Err` if the color level was already set.
180
#[allow(dead_code)]
181
pub fn set_color_level(level: ColorLevel) -> Result<(), ColorLevel> {
182
    COLOR_LEVEL.set(level)
183
}
184
185
/// Convert an `anstyle::Color` to the appropriate level based on terminal support.
186
///
187
/// This will downgrade colors as needed:
188
/// - TrueColor terminals: pass through unchanged
189
/// - 256-color terminals: RGB colors are converted to closest ANSI 256 color
190
/// - Basic terminals: colors are converted to closest ANSI 16 color
191
/// - No color: returns None
192
///
193
/// When [`polarity_safe_syntax`] is enabled (minimal mode), RGB tokens take
194
/// the dual-polarity path instead of nearest-ANSI16.
195
pub fn adapt_color(color: Color) -> Option<Color> {
196
    if polarity_safe_syntax() {
197
        return adapt_color_polarity_safe(color);
198
    }
199
200
    let level = get_color_level();
201
202
    match level {
203
        ColorLevel::None => None,
204
        ColorLevel::TrueColor => Some(color),
205
        ColorLevel::Ansi256 => Some(match color {
206
            Color::Rgb(rgb) => Color::Ansi256(rgb_to_ansi256(rgb)),
207
            other => other,
208
        }),
209
        ColorLevel::Basic => Some(match color {
210
            Color::Rgb(rgb) => Color::Ansi(rgb_to_ansi16(rgb)),
211
            Color::Ansi256(idx) => Color::Ansi(ansi256_to_ansi16(idx)),
212
            Color::Ansi(ansi) => Color::Ansi(ansi),
213
        }),
214
    }
215
}
216
217
/// Polarity-safe remap for syntax tokens painted on a transparent canvas.
218
///
219
/// - Near-gray RGB → `None` (inherit terminal default fg)
220
/// - Chromatic RGB → base ANSI Red/Green/Yellow/Blue/Magenta/Cyan
221
/// - Existing ANSI → demote bright white / white body slots to `None`; keep accents
222
fn adapt_color_polarity_safe(color: Color) -> Option<Color> {
223
    match color {
224
        Color::Rgb(rgb) => polarity_safe_syntax_ansi(rgb.0, rgb.1, rgb.2).map(Color::Ansi),
225
        Color::Ansi256(idx) => {
226
            // Expand xterm index to an approximate RGB then re-map.
227
            let (r, g, b) = ansi256_to_rgb(idx.index());
228
            polarity_safe_syntax_ansi(r, g, b).map(Color::Ansi)
229
        }
230
        Color::Ansi(ansi) => match ansi {
231
            // Body-ish slots that flip polarity → inherit default fg.
232
            AnsiColor::Black
233
            | AnsiColor::White
234
            | AnsiColor::BrightBlack
235
            | AnsiColor::BrightWhite => None,
236
            // Demote bright accents to base (brights can wash out on light).
237
            AnsiColor::BrightRed => Some(Color::Ansi(AnsiColor::Red)),
238
            AnsiColor::BrightGreen => Some(Color::Ansi(AnsiColor::Green)),
239
            AnsiColor::BrightYellow => Some(Color::Ansi(AnsiColor::Yellow)),
240
            AnsiColor::BrightBlue => Some(Color::Ansi(AnsiColor::Blue)),
241
            AnsiColor::BrightMagenta => Some(Color::Ansi(AnsiColor::Magenta)),
242
            AnsiColor::BrightCyan => Some(Color::Ansi(AnsiColor::Cyan)),
243
            other => Some(Color::Ansi(other)),
244
        },
245
    }
246
}
247
248
/// Dual-polarity-safe ANSI mapping for syntax tokens (minimal mode).
249
///
250
/// Returns `None` for near-gray (caller inherits terminal default fg).
251
/// Chromatic hues map to base ANSI colors only — never White/Black.
252
pub fn polarity_safe_syntax_ansi(r: u8, g: u8, b: u8) -> Option<AnsiColor> {
253
    let max = r.max(g).max(b) as i32;
254
    let min = r.min(g).min(b) as i32;
255
    let chroma = max - min;
256
    if chroma < 40 {
257
        return None;
258
    }
259
    let (ri, gi, bi) = (r as i32, g as i32, b as i32);
260
    let h = if max == ri {
261
        let mut h = (gi - bi) * 60 / chroma;
262
        if h < 0 {
263
            h += 360;
264
        }
265
        h
266
    } else if max == gi {
267
        (bi - ri) * 60 / chroma + 120
268
    } else {
269
        (ri - gi) * 60 / chroma + 240
270
    };
271
    // Magenta starts at 255° so Tokyo Night purple (#bb9af7, ~261°) lands
272
    // Magenta rather than Blue; pure blues (~221°) stay Blue.
273
    Some(match h {
274
        0..30 | 330..=360 => AnsiColor::Red,
275
        30..90 => AnsiColor::Yellow,
276
        90..150 => AnsiColor::Green,
277
        150..210 => AnsiColor::Cyan,
278
        210..255 => AnsiColor::Blue,
279
        _ => AnsiColor::Magenta,
280
    })
281
}
282
283
/// Approximate RGB for an xterm 256-color index (cube + grayscale).
284
fn ansi256_to_rgb(idx: u8) -> (u8, u8, u8) {
285
    match idx {
286
        0 => (0, 0, 0),
287
        1 => (128, 0, 0),
288
        2 => (0, 128, 0),
289
        3 => (128, 128, 0),
290
        4 => (0, 0, 128),
291
        5 => (128, 0, 128),
292
        6 => (0, 128, 128),
293
        7 => (192, 192, 192),
294
        8 => (128, 128, 128),
295
        9 => (255, 0, 0),
296
        10 => (0, 255, 0),
297
        11 => (255, 255, 0),
298
        12 => (0, 0, 255),
299
        13 => (255, 0, 255),
300
        14 => (0, 255, 255),
301
        15 => (255, 255, 255),
302
        16..=231 => {
303
            let n = idx - 16;
304
            let r = n / 36;
305
            let g = (n / 6) % 6;
306
            let b = n % 6;
307
            let level = |c: u8| if c == 0 { 0 } else { 55 + 40 * c };
308
            (level(r), level(g), level(b))
309
        }
310
        232..=255 => {
311
            let v = 8 + (idx - 232) * 10;
312
            (v, v, v)
313
        }
314
    }
315
}
316
317
/// Convert an `anstyle::Style` to the appropriate color level.
318
pub fn adapt_style(style: anstyle::Style) -> anstyle::Style {
319
    let fg = style.get_fg_color().and_then(adapt_color);
320
    let bg = style.get_bg_color().and_then(adapt_color);
321
    let effects = style.get_effects();
322
323
    let mut new_style = anstyle::Style::new();
324
    if let Some(fg) = fg {
325
        new_style = new_style.fg_color(Some(fg));
326
    }
327
    if let Some(bg) = bg {
328
        new_style = new_style.bg_color(Some(bg));
329
    }
330
    new_style | effects
331
}
332
333
/// Convert an RGB color to the closest ANSI 256-color palette entry.
334
pub fn rgb_to_ansi256(rgb: RgbColor) -> Ansi256Color {
335
    anstyle_lossy::rgb_to_xterm(rgb)
336
}
337
338
/// Convert an RGB color to the closest basic ANSI 16-color.
339
pub fn rgb_to_ansi16(rgb: RgbColor) -> AnsiColor {
340
    anstyle_lossy::rgb_to_ansi(rgb, anstyle_lossy::palette::VGA)
341
}
342
343
/// Convert an ANSI 256-color to the closest basic ANSI 16-color.
344
pub fn ansi256_to_ansi16(idx: Ansi256Color) -> AnsiColor {
345
    anstyle_lossy::xterm_to_ansi(idx, anstyle_lossy::palette::VGA)
346
}
347
348
#[cfg(test)]
349
mod tests {
350
    use super::*;
351
352
    #[test]
353
    fn test_rgb_to_ansi256_grayscale() {
354
        // Pure black should map to near-black
355
        let result = rgb_to_ansi256(RgbColor(0, 0, 0));
356
        assert!(result.index() == 16 || result.index() >= 232);
357
358
        // Pure white should map to near-white
359
        let result = rgb_to_ansi256(RgbColor(255, 255, 255));
360
        assert!(result.index() == 231 || result.index() == 255);
361
362
        // Medium gray
363
        let result = rgb_to_ansi256(RgbColor(128, 128, 128));
364
        assert!(result.index() >= 232); // Should be in grayscale range
365
    }
366
367
    #[test]
368
    fn test_rgb_to_ansi256_colors() {
369
        // Pure red
370
        let result = rgb_to_ansi256(RgbColor(255, 0, 0));
371
        assert_eq!(result.index(), 196); // Bright red in the cube
372
373
        // Pure green
374
        let result = rgb_to_ansi256(RgbColor(0, 255, 0));
375
        assert_eq!(result.index(), 46); // Bright green in the cube
376
377
        // Pure blue
378
        let result = rgb_to_ansi256(RgbColor(0, 0, 255));
379
        assert_eq!(result.index(), 21); // Bright blue in the cube
380
    }
381
382
    #[test]
383
    fn test_rgb_to_ansi16() {
384
        // Test basic color mapping
385
        let red = rgb_to_ansi16(RgbColor(200, 0, 0));
386
        assert!(matches!(red, AnsiColor::Red | AnsiColor::BrightRed));
387
388
        let green = rgb_to_ansi16(RgbColor(0, 200, 0));
389
        assert!(matches!(green, AnsiColor::Green | AnsiColor::BrightGreen));
390
391
        let blue = rgb_to_ansi16(RgbColor(0, 0, 200));
392
        assert!(matches!(blue, AnsiColor::Blue | AnsiColor::BrightBlue));
393
394
        // White
395
        let white = rgb_to_ansi16(RgbColor(250, 250, 250));
396
        assert!(matches!(white, AnsiColor::White | AnsiColor::BrightWhite));
397
    }
398
399
    #[test]
400
    fn test_ansi256_to_ansi16_standard() {
401
        // First 16 colors should map directly
402
        assert_eq!(ansi256_to_ansi16(Ansi256Color(0)), AnsiColor::Black);
403
        assert_eq!(ansi256_to_ansi16(Ansi256Color(1)), AnsiColor::Red);
404
        assert_eq!(ansi256_to_ansi16(Ansi256Color(7)), AnsiColor::White);
405
        assert_eq!(ansi256_to_ansi16(Ansi256Color(15)), AnsiColor::BrightWhite);
406
    }
407
408
    #[test]
409
    fn test_color_level_ordering() {
410
        assert!(ColorLevel::None < ColorLevel::Basic);
411
        assert!(ColorLevel::Basic < ColorLevel::Ansi256);
412
        assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor);
413
    }
414
415
    #[test]
416
    fn polarity_safe_grays_inherit_default() {
417
        assert_eq!(polarity_safe_syntax_ansi(0xc8, 0xc8, 0xc8), None);
418
        assert_eq!(polarity_safe_syntax_ansi(0x6c, 0x6c, 0x6c), None);
419
    }
420
421
    #[test]
422
    fn polarity_safe_never_white() {
423
        for (r, g, b) in [
424
            (0xbb, 0x9a, 0xf7),
425
            (0x7d, 0xcf, 0xff),
426
            (0x7a, 0xa2, 0xf7),
427
            (0xff, 0x9e, 0x64),
428
            (0xf7, 0x76, 0x8e),
429
            (0xc8, 0xc8, 0xc8),
430
        ] {
431
            let mapped = polarity_safe_syntax_ansi(r, g, b);
432
            assert!(
433
                !matches!(
434
                    mapped,
435
                    Some(AnsiColor::White | AnsiColor::BrightWhite | AnsiColor::Black)
436
                ),
437
                "#{r:02x}{g:02x}{b:02x} -> {mapped:?}"
438
            );
439
        }
440
    }
441
442
    #[test]
443
    fn adapt_color_polarity_safe_flag_drops_gray_rgb() {
444
        set_polarity_safe_syntax(true);
445
        let out = adapt_color(Color::Rgb(RgbColor(0xc8, 0xc8, 0xc8)));
446
        assert_eq!(out, None, "gray body must inherit default fg");
447
        let magenta = adapt_color(Color::Rgb(RgbColor(0xbb, 0x9a, 0xf7)));
448
        assert_eq!(magenta, Some(Color::Ansi(AnsiColor::Magenta)));
449
        set_polarity_safe_syntax(false);
450
    }
451
}
crates/coder-lite/src/markdown/core.rs added +1070

@@ -0,0 +1,1070 @@

1
//! Headless markdown analysis sharing Grok Build's exact `pulldown-cmark` config.
2
//!
3
//! This crate is intentionally lean -- it depends only on `pulldown-cmark` -- so it
4
//! can be used without pulling in the terminal-rendering stack (syntect, ratatui,
5
//! two-face). [`parser_options`] is the single source of truth for the parser
6
//! feature set, shared with `xai-grok-markdown` so analysis matches what Grok
7
//! Build actually renders 1:1.
8
//!
9
//! After parsing, Grok applies [`offset_events`]: only `~~…~~` is strikethrough.
10
//! Single-tilde pairs (`~text~`) are demoted to literal `~` text so LLM output
11
//! like `~**10%**` is not struck (pulldown treats those pairs as strike; we do not).
12
13
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
14
use std::ops::Range;
15
16
/// The exact `pulldown-cmark` option set Grok Build uses to render markdown.
17
///
18
/// With `ENABLE_STRIKETHROUGH`, pulldown treats both `~~…~~` and single-`~` pairs as
19
/// strike. Callers must consume events via [`offset_events`] so only double-tilde
20
/// strikethrough is retained (LLM-friendly post-policy).
21
pub fn parser_options() -> Options {
22
    Options::ENABLE_GFM
23
        | Options::ENABLE_STRIKETHROUGH
24
        | Options::ENABLE_MATH
25
        | Options::ENABLE_TASKLISTS
26
        | Options::ENABLE_TABLES
27
}
28
29
/// Offset event stream from Grok's parser, with single-tilde strikethrough demoted.
30
///
31
/// Prefer this over `Parser::new_ext(...).into_offset_iter()` so analysis and
32
/// rendering agree on what counts as strikethrough.
33
pub fn offset_events(text: &str) -> impl Iterator<Item = (Event<'_>, Range<usize>)> + '_ {
34
    DoubleTildeOnlyStrike {
35
        text,
36
        events: Parser::new_ext(text, parser_options()).into_offset_iter(),
37
    }
38
}
39
40
/// Stackless filter: Start and End share the same byte span in pulldown, so both
41
/// are classified by whether that span opens with `~~`. Single-tilde frames emit
42
/// delimiter `Text` instead of strike tags (delimiters are not separate events).
43
struct DoubleTildeOnlyStrike<'a, I> {
44
    text: &'a str,
45
    events: I,
46
}
47
48
/// True when the strike span at `range.start` is the double-tilde form.
49
fn is_double_tilde_strike(text: &str, range: &Range<usize>) -> bool {
50
    text.get(range.start..).is_some_and(|s| s.starts_with("~~"))
51
}
52
53
/// Opening or closing delimiter byte as `Text`, with the matching source range.
54
fn strike_delim_text<'a>(
55
    text: &'a str,
56
    range: &Range<usize>,
57
    opening: bool,
58
) -> (Event<'a>, Range<usize>) {
59
    let delim = if opening {
60
        let end = range.start + 1;
61
        debug_assert!(text.is_char_boundary(end) && end <= text.len());
62
        (range.start..end, &text[range.start..end])
63
    } else {
64
        let start = range.end - 1;
65
        debug_assert!(text.is_char_boundary(start) && start < text.len());
66
        (start..range.end, &text[start..range.end])
67
    };
68
    (Event::Text(delim.1.into()), delim.0)
69
}
70
71
impl<'a, I> Iterator for DoubleTildeOnlyStrike<'a, I>
72
where
73
    I: Iterator<Item = (Event<'a>, Range<usize>)>,
74
{
75
    type Item = (Event<'a>, Range<usize>);
76
77
    fn next(&mut self) -> Option<Self::Item> {
78
        let (event, range) = self.events.next()?;
79
        match &event {
80
            Event::Start(Tag::Strikethrough) if !is_double_tilde_strike(self.text, &range) => {
81
                Some(strike_delim_text(self.text, &range, true))
82
            }
83
            Event::End(TagEnd::Strikethrough) if !is_double_tilde_strike(self.text, &range) => {
84
                Some(strike_delim_text(self.text, &range, false))
85
            }
86
            _ => Some((event, range)),
87
        }
88
    }
89
}
90
91
/// Counts of markdown elements found in a document.
92
///
93
/// Counting mirrors the `pulldown-cmark` event stream the renderer walks, so a few
94
/// overlaps are intentional and documented per-field below.
95
#[derive(Debug, Default, Clone, PartialEq, Eq)]
96
#[non_exhaustive]
97
pub struct MarkdownStats {
98
    pub h1: u32,
99
    pub h2: u32,
100
    pub h3: u32,
101
    pub h4: u32,
102
    pub h5: u32,
103
    pub h6: u32,
104
    pub tables: u32,
105
    pub fenced_code: u32,
106
    pub indented_code: u32,
107
    pub inline_code: u32,
108
    pub strong: u32,
109
    pub emphasis: u32,
110
    pub strikethrough: u32,
111
    /// All GFM link types: inline, reference, collapsed, shortcut-when-defined,
112
    /// angle-bracket autolink, and email autolink.
113
    pub links: u32,
114
    /// Markup inside an image's alt text is still counted (e.g. `![**x**](u)` bumps `strong`).
115
    pub images: u32,
116
    pub blockquotes: u32,
117
    pub thematic_breaks: u32,
118
    pub inline_math: u32,
119
    pub display_math: u32,
120
    /// Subset of `list_items`: a task-list item is also a list item.
121
    pub task_list_items: u32,
122
    /// Container lists are not counted (no `lists` field); nested list items are included.
123
    pub list_items: u32,
124
}
125
126
impl MarkdownStats {
127
    /// Total heading count across all levels, derived from `h1..=h6`.
128
    pub fn headings(&self) -> u32 {
129
        self.h1 + self.h2 + self.h3 + self.h4 + self.h5 + self.h6
130
    }
131
132
    /// Single source of truth for name->value mapping; the exhaustive destructure makes adding a field a compile error here, so downstream consumers cannot drift from the struct.
133
    pub fn as_pairs(&self) -> [(&'static str, u32); 22] {
134
        // Exhaustive (no `..`): adding a field to MarkdownStats fails to compile until it is mapped below.
135
        let Self {
136
            h1,
137
            h2,
138
            h3,
139
            h4,
140
            h5,
141
            h6,
142
            tables,
143
            fenced_code,
144
            indented_code,
145
            inline_code,
146
            strong,
147
            emphasis,
148
            strikethrough,
149
            links,
150
            images,
151
            blockquotes,
152
            thematic_breaks,
153
            inline_math,
154
            display_math,
155
            task_list_items,
156
            list_items,
157
        } = *self;
158
        [
159
            ("headings", self.headings()),
160
            ("h1", h1),
161
            ("h2", h2),
162
            ("h3", h3),
163
            ("h4", h4),
164
            ("h5", h5),
165
            ("h6", h6),
166
            ("tables", tables),
167
            ("fenced_code", fenced_code),
168
            ("indented_code", indented_code),
169
            ("inline_code", inline_code),
170
            ("strong", strong),
171
            ("emphasis", emphasis),
172
            ("strikethrough", strikethrough),
173
            ("links", links),
174
            ("images", images),
175
            ("blockquotes", blockquotes),
176
            ("thematic_breaks", thematic_breaks),
177
            ("inline_math", inline_math),
178
            ("display_math", display_math),
179
            ("task_list_items", task_list_items),
180
            ("list_items", list_items),
181
        ]
182
    }
183
}
184
185
/// A render-fidelity failure: the model emitted markdown that does not render as
186
/// the structure it clearly intended.
187
///
188
/// Distinct from [`MarkdownStats`] counts: a count answers "how many tables", an
189
/// issue answers "did a construct silently degrade". `pulldown-cmark` never errors
190
/// (CommonMark is total), so each issue is detected by comparing intent (the raw
191
/// syntax) against what actually parsed.
192
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193
pub enum StructuralIssue {
194
    /// A GFM table delimiter row (`|---|---|`) sits under a header line, but the
195
    /// table did not parse (e.g. the delimiter's column count != the header's), so
196
    /// the lines render as a paragraph -- the "made a table but it didn't show" bug.
197
    MalformedTable,
198
    /// A fenced code block runs to EOF without a closing fence, swallowing the rest of the message.
199
    UnterminatedCodeBlock,
200
}
201
202
impl StructuralIssue {
203
    /// Stable snake_case name for this issue (for logs, metrics, or FFI bindings).
204
    pub fn as_str(self) -> &'static str {
205
        match self {
206
            Self::MalformedTable => "malformed_table",
207
            Self::UnterminatedCodeBlock => "unterminated_code_block",
208
        }
209
    }
210
}
211
212
/// Element counts plus any structural issues from a single parse pass.
213
#[derive(Debug, Default, Clone, PartialEq, Eq)]
214
pub struct MarkdownAnalysis {
215
    pub stats: MarkdownStats,
216
    pub issues: Vec<StructuralIssue>,
217
}
218
219
/// Strip the container markers pulldown keeps on each source line (`>` for blockquotes, indent for lists).
220
fn strip_block_prefix(line: &str) -> &str {
221
    line.trim_start_matches(['>', ' ', '\t'])
222
}
223
224
/// Unterminated iff no line after the opener is a closing fence matching the opener's char and length.
225
///
226
/// Works off raw block source (the only thing carrying closure info), so a verbatim fence line in
227
/// content could mask a real EOF -- a rare, safe-direction (under-penalizing) miss we accept for simplicity.
228
fn fenced_block_is_unterminated(block_src: &str) -> bool {
229
    let mut lines = block_src.lines();
230
    let Some(open) = lines.next().map(strip_block_prefix) else {
231
        return false;
232
    };
233
    let Some(fence_char) = open.chars().next().filter(|c| matches!(c, '`' | '~')) else {
234
        return false;
235
    };
236
    let open_len = open.chars().take_while(|c| *c == fence_char).count();
237
    !lines.any(|line| {
238
        let close = strip_block_prefix(line).trim_end();
239
        // `open_len >= 3`, so `close.len() >= open_len` already implies non-empty.
240
        close.len() >= open_len && close.chars().all(|c| c == fence_char)
241
    })
242
}
243
244
/// A GFM table delimiter row: only `|`, `-`, `:`, and whitespace, with at least one
245
/// pipe and one dash. The pipe requirement rejects a bare `---` thematic break or a
246
/// setext `-----` underline; the dash requirement rejects a `|||`-only row.
247
fn is_table_delimiter_line(line: &str) -> bool {
248
    let line = line.trim();
249
    line.contains('|')
250
        && line.contains('-')
251
        && line
252
            .chars()
253
            .all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
254
}
255
256
/// A line that could be a table header: non-empty, containing a column pipe, and
257
/// not itself delimiter-shaped (a `|---|` row arming the next line would chain one
258
/// broken table into a duplicate flag per extra delimiter row).
259
fn line_looks_like_header(line: &str) -> bool {
260
    let line = line.trim();
261
    !line.is_empty() && line.contains('|') && !is_table_delimiter_line(line)
262
}
263
264
/// Flag delimiter rows the model intended as a table but that `pulldown-cmark` did
265
/// not parse as one (so they render as a paragraph -- the broken-table bug).
266
///
267
/// `parsed_spans` are the byte ranges of real tables and code blocks: a delimiter
268
/// line starting inside one is either part of a valid table or literal code text, so
269
/// it never signals a malformed table. Everything else is fair game -- a delimiter
270
/// row directly under a pipe-bearing header line is an intended-but-unparsed table.
271
fn detect_malformed_tables(
272
    text: &str,
273
    parsed_spans: &[Range<usize>],
274
    issues: &mut Vec<StructuralIssue>,
275
) {
276
    let in_parsed_span = |offset: usize| parsed_spans.iter().any(|span| span.contains(&offset));
277
278
    let mut prev_is_header = false;
279
    let mut offset = 0;
280
    // `split_inclusive` keeps the trailing `\n`, so summing lengths tracks byte offsets exactly.
281
    for line in text.split_inclusive('\n') {
282
        let start = offset;
283
        offset += line.len();
284
285
        // A `|---|` line inside a parsed table/code block is not an intended-but-broken table.
286
        let excluded = in_parsed_span(start);
287
        let content = strip_block_prefix(line.trim_end_matches(['\n', '\r']));
288
289
        if !excluded && prev_is_header && is_table_delimiter_line(content) {
290
            issues.push(StructuralIssue::MalformedTable);
291
        }
292
        // The delimiter's header must be the immediately-preceding, non-excluded pipe line.
293
        prev_is_header = !excluded && line_looks_like_header(content);
294
    }
295
}
296
297
/// Parse `text` with Grok Build's options; count elements and flag structural issues.
298
pub fn analyze(text: &str) -> MarkdownAnalysis {
299
    let mut stats = MarkdownStats::default();
300
    let mut issues = Vec::new();
301
    // Byte ranges of constructs where a `|---|`-shaped line is legitimately not an
302
    // intended table (real tables and code blocks); consumed by `detect_malformed_tables`.
303
    let mut parsed_spans: Vec<Range<usize>> = Vec::new();
304
305
    // u32 element counters can't overflow: model output is token-bounded, far below `u32::MAX`.
306
    // `offset_events` attaches byte ranges and demotes single-tilde strike so counts match render.
307
    for (event, range) in offset_events(text) {
308
        // Structural-issue bookkeeping, tracked alongside the element counting below.
309
        match &event {
310
            // A parsed table's span covers its delimiter row -- exclude it from malformed-table scanning.
311
            Event::Start(Tag::Table(_)) => parsed_spans.push(range.clone()),
312
            // The range spans the opening fence through the close (or to EOF when unterminated).
313
            Event::Start(Tag::CodeBlock(kind)) => {
314
                if matches!(kind, CodeBlockKind::Fenced(_))
315
                    && fenced_block_is_unterminated(&text[range.clone()])
316
                {
317
                    issues.push(StructuralIssue::UnterminatedCodeBlock);
318
                }
319
                parsed_spans.push(range.clone());
320
            }
321
            _ => {}
322
        }
323
324
        match event {
325
            Event::Start(Tag::Heading { level, .. }) => match level {
326
                HeadingLevel::H1 => stats.h1 += 1,
327
                HeadingLevel::H2 => stats.h2 += 1,
328
                HeadingLevel::H3 => stats.h3 += 1,
329
                HeadingLevel::H4 => stats.h4 += 1,
330
                HeadingLevel::H5 => stats.h5 += 1,
331
                HeadingLevel::H6 => stats.h6 += 1,
332
            },
333
            Event::Start(Tag::Table(_)) => stats.tables += 1,
334
            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => stats.fenced_code += 1,
335
            Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => stats.indented_code += 1,
336
            Event::Start(Tag::Strong) => stats.strong += 1,
337
            Event::Start(Tag::Emphasis) => stats.emphasis += 1,
338
            Event::Start(Tag::Strikethrough) => stats.strikethrough += 1,
339
            Event::Start(Tag::Link { .. }) => stats.links += 1,
340
            Event::Start(Tag::Image { .. }) => stats.images += 1,
341
            Event::Start(Tag::BlockQuote(_)) => stats.blockquotes += 1,
342
            Event::Start(Tag::Item) => stats.list_items += 1,
343
            Event::Code(_) => stats.inline_code += 1,
344
            Event::InlineMath(_) => stats.inline_math += 1,
345
            Event::DisplayMath(_) => stats.display_math += 1,
346
            Event::Rule => stats.thematic_breaks += 1,
347
            Event::TaskListMarker(_) => stats.task_list_items += 1,
348
            _ => {}
349
        }
350
    }
351
352
    // Second pass: with every real-table/code-block span known, flag delimiter rows
353
    // the model intended as tables but that did not parse as one.
354
    detect_malformed_tables(text, &parsed_spans, &mut issues);
355
356
    MarkdownAnalysis { stats, issues }
357
}
358
359
#[cfg(test)]
360
mod tests {
361
    use super::*;
362
363
    #[test]
364
    fn pipeless_single_dash_divider_is_a_table() {
365
        assert_eq!(analyze("a | b\n- | -\nc | d\n").stats.tables, 1);
366
    }
367
368
    #[test]
369
    fn outer_pipe_single_dash_divider_is_a_table() {
370
        assert_eq!(analyze("| a | b |\n| - | - |\n| c | d |\n").stats.tables, 1);
371
    }
372
373
    #[test]
374
    fn two_tables_in_one_doc() {
375
        let doc = "| a | b |\n| - | - |\n| c | d |\n\n| e | f |\n| - | - |\n| g | h |\n";
376
        assert_eq!(analyze(doc).stats.tables, 2);
377
    }
378
379
    #[test]
380
    fn setext_heading_is_h1() {
381
        let stats = analyze("Title\n=====\n\nbody\n").stats;
382
        assert_eq!(stats.headings(), 1);
383
        assert_eq!(stats.h1, 1);
384
    }
385
386
    #[test]
387
    fn atx_heading_levels_counted() {
388
        let stats = analyze("## h2\n\n### h3\n\n#### h4\n\n##### h5\n\n###### h6\n").stats;
389
        assert_eq!(stats.h2, 1);
390
        assert_eq!(stats.h3, 1);
391
        assert_eq!(stats.h4, 1);
392
        assert_eq!(stats.h5, 1);
393
        assert_eq!(stats.h6, 1);
394
        assert_eq!(stats.headings(), 5);
395
    }
396
397
    #[test]
398
    fn inline_math_is_detected() {
399
        assert_eq!(analyze("mass is $E=mc^2$ ok\n").stats.inline_math, 1);
400
    }
401
402
    #[test]
403
    fn display_math_is_detected() {
404
        assert_eq!(analyze("$$\\int x dx$$\n").stats.display_math, 1);
405
    }
406
407
    #[test]
408
    fn indented_backticks_stay_inside_fenced_block() {
409
        assert_eq!(analyze("```\ncode\n    ```\nafter\n").stats.fenced_code, 1);
410
    }
411
412
    #[test]
413
    fn fenced_code_with_info_string() {
414
        assert_eq!(analyze("```rust\nx\n```\n").stats.fenced_code, 1);
415
    }
416
417
    #[test]
418
    fn four_space_indent_is_indented_code() {
419
        let stats = analyze("    code\n").stats;
420
        assert_eq!(stats.indented_code, 1);
421
        assert_eq!(stats.fenced_code, 0);
422
    }
423
424
    #[test]
425
    fn thematic_break_counted() {
426
        assert_eq!(analyze("---\n").stats.thematic_breaks, 1);
427
    }
428
429
    #[test]
430
    fn image_is_not_counted_as_link() {
431
        let stats = analyze("![a](x.png)\n").stats;
432
        assert_eq!(stats.images, 1);
433
        assert_eq!(stats.links, 0);
434
    }
435
436
    #[test]
437
    fn task_list_items_are_subset_of_list_items() {
438
        let stats = analyze("- [ ] a\n- [x] b\n- c\n").stats;
439
        assert_eq!(stats.list_items, 3);
440
        assert_eq!(stats.task_list_items, 2);
441
    }
442
443
    #[test]
444
    fn autolink_and_reference_links_counted() {
445
        assert_eq!(analyze("<https://x.com>\n").stats.links, 1);
446
        assert_eq!(analyze("[a][b]\n\n[b]: https://x.com\n").stats.links, 1);
447
    }
448
449
    #[test]
450
    fn empty_input_is_default() {
451
        assert_eq!(analyze("").stats, MarkdownStats::default());
452
    }
453
454
    #[test]
455
    fn as_pairs_pins_every_pair() {
456
        // One golden doc exercising several element types pins every label, value, AND order
457
        // at once -- a mislabel like ("inline_math", display_math) would fail here.
458
        let doc = "# Title\n## Sub\n\nSome **bold**, *italic*, ~~strike~~, `code`, and a [link](https://x.com).\n\n| a | b |\n| - | - |\n| c | d |\n\n- [ ] todo\n- [x] done\n- plain\n";
459
        assert_eq!(
460
            analyze(doc).stats.as_pairs(),
461
            [
462
                ("headings", 2),
463
                ("h1", 1),
464
                ("h2", 1),
465
                ("h3", 0),
466
                ("h4", 0),
467
                ("h5", 0),
468
                ("h6", 0),
469
                ("tables", 1),
470
                ("fenced_code", 0),
471
                ("indented_code", 0),
472
                ("inline_code", 1),
473
                ("strong", 1),
474
                ("emphasis", 1),
475
                ("strikethrough", 1),
476
                ("links", 1),
477
                ("images", 0),
478
                ("blockquotes", 0),
479
                ("thematic_breaks", 0),
480
                ("inline_math", 0),
481
                ("display_math", 0),
482
                ("task_list_items", 2),
483
                ("list_items", 3),
484
            ]
485
        );
486
    }
487
488
    #[test]
489
    fn mixed_document_locks_counts() {
490
        let doc = "# Title\n\nSome **bold** and *italic* and ~~strike~~ and `code`.\n\n- one\n- two\n\n> quote\n\n[link](https://example.com)\n";
491
        let stats = analyze(doc).stats;
492
        assert_eq!(stats.headings(), 1);
493
        assert_eq!(stats.h1, 1);
494
        assert_eq!(stats.list_items, 2);
495
        assert_eq!(stats.strong, 1);
496
        assert_eq!(stats.emphasis, 1);
497
        assert_eq!(stats.strikethrough, 1);
498
        assert_eq!(stats.inline_code, 1);
499
        assert_eq!(stats.links, 1);
500
        assert_eq!(stats.blockquotes, 1);
501
    }
502
503
    fn strike_start_end_counts(text: &str) -> (usize, usize) {
504
        let mut starts = 0;
505
        let mut ends = 0;
506
        for (e, _) in offset_events(text) {
507
            match e {
508
                Event::Start(Tag::Strikethrough) => starts += 1,
509
                Event::End(TagEnd::Strikethrough) => ends += 1,
510
                _ => {}
511
            }
512
        }
513
        (starts, ends)
514
    }
515
516
    #[test]
517
    fn bill_single_tilde_percent_is_not_strikethrough() {
518
        // Trigger case: approx percentages must not strike; nested strong still applies.
519
        let doc = "- `n=1` only: ~**10%** (~**300**)";
520
        let stats = analyze(doc).stats;
521
        assert_eq!(stats.strikethrough, 0);
522
        assert_eq!(stats.strong, 2);
523
        assert_eq!(strike_start_end_counts(doc), (0, 0));
524
    }
525
526
    #[test]
527
    fn double_tilde_deleted_is_strikethrough() {
528
        assert_eq!(analyze("~~deleted~~").stats.strikethrough, 1);
529
        assert_eq!(strike_start_end_counts("~~deleted~~"), (1, 1));
530
    }
531
532
    #[test]
533
    fn single_tilde_pair_is_literal_not_strike() {
534
        let doc = "~single~";
535
        assert_eq!(analyze(doc).stats.strikethrough, 0);
536
        assert_eq!(strike_start_end_counts(doc), (0, 0));
537
        let texts: Vec<_> = offset_events(doc)
538
            .filter_map(|(e, _)| match e {
539
                Event::Text(t) => Some(t.into_string()),
540
                _ => None,
541
            })
542
            .collect();
543
        assert!(texts.iter().any(|t| t == "~"));
544
    }
545
546
    #[test]
547
    fn lone_tilde_percent_is_not_strike() {
548
        assert_eq!(analyze("lone ~10% is fine").stats.strikethrough, 0);
549
        assert_eq!(strike_start_end_counts("lone ~10% is fine"), (0, 0));
550
    }
551
552
    #[test]
553
    fn mixed_double_and_single_tilde_counts_one_strike() {
554
        let doc = "keep ~~this~~ but not ~that~";
555
        assert_eq!(analyze(doc).stats.strikethrough, 1);
556
        assert_eq!(strike_start_end_counts(doc), (1, 1));
557
    }
558
559
    #[test]
560
    fn nested_double_inside_single_tilde_is_balanced() {
561
        // Outer single-`~` demoted; inner `~~…~~` kept — Start/End must stay paired.
562
        let doc = "~start ~~double~~ end~";
563
        assert_eq!(analyze(doc).stats.strikethrough, 1);
564
        assert_eq!(strike_start_end_counts(doc), (1, 1));
565
        let texts: Vec<_> = offset_events(doc)
566
            .filter_map(|(e, _)| match e {
567
                Event::Text(t) => Some(t.into_string()),
568
                _ => None,
569
            })
570
            .collect();
571
        // Outer delimiters visible as literal text (not only via strike styling).
572
        assert!(texts.iter().filter(|t| t.as_str() == "~").count() >= 2);
573
    }
574
575
    #[test]
576
    fn well_formed_doc_has_no_issues() {
577
        // Heading, paragraph, then a table with a real body row.
578
        let doc = "# Title\n\nIntro paragraph.\n\n| a | b |\n| - | - |\n| c | d |\n";
579
        assert_eq!(analyze(doc).issues, Vec::<StructuralIssue>::new());
580
    }
581
582
    #[test]
583
    fn well_formed_table_is_not_malformed() {
584
        // A leading, body-bearing table parses cleanly: a count, never a render-fidelity failure.
585
        let analysis = analyze("| a | b |\n| - | - |\n| c | d |\n");
586
        assert_eq!(analysis.stats.tables, 1);
587
        assert!(!analysis.issues.contains(&StructuralIssue::MalformedTable));
588
    }
589
590
    #[test]
591
    fn chained_delimiter_rows_flag_one_malformed_table() {
592
        // One broken table with stacked delimiter-shaped rows must flag exactly once:
593
        // a delimiter row never doubles as the next row's "header". Column counts
594
        // differ on every adjacent pair so pulldown parses no table at all (two
595
        // equal-width delimiter rows would parse as a table themselves).
596
        let doc = "| a | b | c |\n|---|---|---|----|\n|---|---|---|---|----|\n| 1 | 2 | 3 |\n";
597
        let analysis = analyze(doc);
598
        assert_eq!(analysis.stats.tables, 0);
599
        assert_eq!(
600
            analysis
601
                .issues
602
                .iter()
603
                .filter(|i| **i == StructuralIssue::MalformedTable)
604
                .count(),
605
            1
606
        );
607
    }
608
609
    #[test]
610
    fn header_only_table_is_not_malformed() {
611
        // A header + delimiter (no body) still parses as a table, so it is not a malformed table.
612
        let analysis = analyze("| a | b |\n| - | - |\n");
613
        assert_eq!(analysis.stats.tables, 1);
614
        assert!(!analysis.issues.contains(&StructuralIssue::MalformedTable));
615
    }
616
617
    #[test]
618
    fn delimiter_column_mismatch_flags_malformed_table() {
619
        // Header has 2 columns, delimiter has 3: pulldown-cmark abandons the table.
620
        let analysis = analyze("| a | b |\n| - | - | - |\n| c | d | e |\n");
621
        assert_eq!(analysis.stats.tables, 0);
622
        assert!(analysis.issues.contains(&StructuralIssue::MalformedTable));
623
    }
624
625
    #[test]
626
    fn broken_table_extra_delimiter_column_flags_malformed_table() {
627
        // Wide synthetic table whose delimiter row has 12 columns but the header
628
        // has 11 (an extra `|---|`), so pulldown-cmark renders the lines as a
629
        // paragraph instead of a table.
630
        let doc = "\
631
| ColA | ColB | ColC | ColD | ColE | ColF | ColG | ColH | ColI | ColJ | ColK |
632
|---|---|---|---|---|---|---|---|---|---|---|------------------------------------|
633
| A001 | 2026-01-01 12:00 (REF-100001) | ITEM-2026-01-01-00-00-00-ABCDEF01 | 2026-01-01 12:00:00 | 1.00 | 0.0 | 20.0 | 10.0 | 50.00 | left | 1 |
634
| A002 | 2026-01-02 12:00 (REF-100002) | N/A (sample ends) | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
635
";
636
        let analysis = analyze(doc);
637
        assert_eq!(
638
            analysis.stats.tables, 0,
639
            "pulldown-cmark must not parse the broken table"
640
        );
641
        assert!(
642
            analysis.issues.contains(&StructuralIssue::MalformedTable),
643
            "the intended-but-unparsed table must be flagged"
644
        );
645
    }
646
647
    #[test]
648
    fn pipe_prose_is_not_a_malformed_table() {
649
        // A paragraph that merely contains pipes (no delimiter row) must not flag.
650
        let analysis = analyze("use `a | b` in the shell\nand `c | d` too\n");
651
        assert!(!analysis.issues.contains(&StructuralIssue::MalformedTable));
652
    }
653
654
    #[test]
655
    fn delimiter_inside_code_fence_is_not_a_malformed_table() {
656
        // A `|---|` line inside a fenced code block is literal content, not a table.
657
        let analysis = analyze("```\n| a | b |\n|---|---|---|\n```\n");
658
        assert!(!analysis.issues.contains(&StructuralIssue::MalformedTable));
659
    }
660
661
    #[test]
662
    fn setext_heading_dashes_are_not_a_malformed_table() {
663
        // `Title\n-----` is a setext H2: the underline has no pipe, so it is not a delimiter row.
664
        let analysis = analyze("Title\n-----\n");
665
        assert!(!analysis.issues.contains(&StructuralIssue::MalformedTable));
666
    }
667
668
    #[test]
669
    fn unterminated_fenced_block_flags_issue() {
670
        let analysis = analyze("```\ncode\n");
671
        assert!(
672
            analysis
673
                .issues
674
                .contains(&StructuralIssue::UnterminatedCodeBlock)
675
        );
676
    }
677
678
    #[test]
679
    fn closed_fenced_block_has_no_unterminated_issue() {
680
        let analysis = analyze("```\ncode\n```\n");
681
        assert!(
682
            !analysis
683
                .issues
684
                .contains(&StructuralIssue::UnterminatedCodeBlock)
685
        );
686
    }
687
688
    #[test]
689
    fn closed_fenced_block_with_lang_has_no_unterminated_issue() {
690
        let analysis = analyze("```rust\nx\n```\n");
691
        assert!(
692
            !analysis
693
                .issues
694
                .contains(&StructuralIssue::UnterminatedCodeBlock)
695
        );
696
    }
697
698
    #[test]
699
    fn tilde_fenced_block_closed_has_no_unterminated_issue() {
700
        let analysis = analyze("~~~\nx\n~~~\n");
701
        assert!(
702
            !analysis
703
                .issues
704
                .contains(&StructuralIssue::UnterminatedCodeBlock)
705
        );
706
    }
707
708
    #[test]
709
    fn blockquoted_closed_fence_has_no_unterminated_issue() {
710
        // The block range keeps the `>` prefixes; a properly-closed quoted fence must be clean.
711
        let analysis = analyze("> ```\n> code\n> ```\n");
712
        assert!(
713
            !analysis
714
                .issues
715
                .contains(&StructuralIssue::UnterminatedCodeBlock)
716
        );
717
    }
718
719
    #[test]
720
    fn blockquoted_unterminated_fence_flags_issue() {
721
        let analysis = analyze("> ```\n> code\n");
722
        assert!(
723
            analysis
724
                .issues
725
                .contains(&StructuralIssue::UnterminatedCodeBlock)
726
        );
727
    }
728
729
    #[test]
730
    fn longer_opener_not_closed_by_shorter_fence() {
731
        // A 5-backtick opener is not closed by a 3-backtick line (close must be >= opener length).
732
        let analysis = analyze("`````\ncode\n```\n");
733
        assert!(
734
            analysis
735
                .issues
736
                .contains(&StructuralIssue::UnterminatedCodeBlock)
737
        );
738
    }
739
740
    // (name, doc, parsed_tables) corpus of intended-but-broken tables, shared by the
741
    // detection test and the flagged-implies-not-parsed invariant below.
742
    const MALFORMED_TABLE_TRUE_POSITIVES: &[(&str, &str, u32)] = &[
743
        (
744
            "delimiter_wider_than_header",
745
            "| a | b |\n|---|---|---|\n| c | d | e |\n",
746
            0,
747
        ),
748
        (
749
            "delimiter_narrower_than_header",
750
            "| a | b | c |\n|---|---|\n| d | e | f |\n",
751
            0,
752
        ),
753
        (
754
            "broken_table_in_blockquote",
755
            "> | a | b |\n> |---|---|---|\n",
756
            0,
757
        ),
758
        (
759
            "broken_table_at_eof_no_newline",
760
            "| a | b |\n|---|---|---|",
761
            0,
762
        ),
763
    ];
764
765
    #[test]
766
    fn malformed_table_true_positives() {
767
        for (name, doc, _) in MALFORMED_TABLE_TRUE_POSITIVES {
768
            assert!(
769
                analyze(doc)
770
                    .issues
771
                    .contains(&StructuralIssue::MalformedTable),
772
                "missed malformed table in `{name}`: {doc:?}"
773
            );
774
        }
775
    }
776
777
    #[test]
778
    fn flagged_implies_table_not_parsed() {
779
        // Invariant: a flagged doc's broken table must not also be counted as parsed.
780
        for (name, doc, parsed_tables) in MALFORMED_TABLE_TRUE_POSITIVES {
781
            assert_eq!(
782
                analyze(doc).stats.tables,
783
                *parsed_tables,
784
                "broken table in `{name}` must not count as parsed: {doc:?}"
785
            );
786
        }
787
    }
788
789
    #[test]
790
    fn malformed_table_no_false_positives() {
791
        // (name, doc) docs that either parse as real tables or are legitimately not tables.
792
        let cases: &[(&str, &str)] = &[
793
            (
794
                "aligned_colon_delimiter",
795
                "| a | b | c |\n|:---|---:|:--:|\n| 1 | 2 | 3 |\n",
796
            ),
797
            (
798
                "table_in_blockquote",
799
                "> | a | b |\n> | - | - |\n> | c | d |\n",
800
            ),
801
            (
802
                "table_in_list_item",
803
                "- item\n\n  | a | b |\n  | - | - |\n  | c | d |\n",
804
            ),
805
            (
806
                "mismatched_delimiter_in_tilde_fence",
807
                "~~~\n| a | b |\n|---|---|---|\n~~~\n",
808
            ),
809
            ("crlf_table", "| a | b |\r\n| - | - |\r\n| c | d |\r\n"),
810
            (
811
                "multi_byte_unicode_header",
812
                "| ünïcødé | b |\n| - | - |\n| c | d |\n",
813
            ),
814
            ("table_at_eof_no_newline", "| a | b |\n| - | - |\n| c | d |"),
815
            (
816
                "two_valid_tables_with_prose_between",
817
                "| a | b |\n| - | - |\n| c | d |\n\nprose here\n\n| e | f |\n| - | - |\n| g | h |\n",
818
            ),
819
            // The `---` has no pipe, so it is a setext underline / break, not a delimiter row.
820
            ("pipe_prose_then_plain_dashes", "uses a | b pipe\n---\n"),
821
            (
822
                "mismatched_delimiter_in_indented_code_block",
823
                "    | a |\n    |---|---|\n",
824
            ),
825
        ];
826
        for (name, doc) in cases {
827
            assert!(
828
                !analyze(doc)
829
                    .issues
830
                    .contains(&StructuralIssue::MalformedTable),
831
                "false positive on `{name}`: {doc:?}"
832
            );
833
        }
834
    }
835
836
    #[test]
837
    fn legacy_mdx_rule_divergence() {
838
        // Legacy markdown-validator MDX_* rule -> our verdict. We only flag
839
        // render-fidelity failures (intended structure that pulldown did not parse),
840
        // never style opinions:
841
        //   FENCE_UNBALANCED          -> UnterminatedCodeBlock (fence swallows the rest of the doc).
842
        //   TABLE_COLUMN_MISMATCH     -> MalformedTable (header/delimiter arity mismatch un-parses it).
843
        //   TABLE_DIVIDER_INVALID     -> NOT flagged: pulldown rejects the table, but a divider with
844
        //                                non-`|-: ` chars also fails our delimiter predicate. Accepted
845
        //                                safe-direction miss (under-penalize, never over-penalize).
846
        //   TABLE_START               -> not flagged: a doc-leading table renders fine (style opinion).
847
        //   TABLE_MIN_ROWS            -> not flagged: GFM parses header+delimiter as a body-less table.
848
        //   TABLE_EMPTY_HEADER        -> not flagged: empty header cells still parse as a table.
849
        //   TABLE_MISSING_BLANK_AFTER -> not flagged: GFM swallows the trailing prose line as a row.
850
        //   TABLE_CELL_NEWLINE        -> not flagged: each physical line is its own row; still a table.
851
        //   TABLE_FENCE_IN_CELL       -> not flagged: backticks in a cell are inline content.
852
        //   TABLE_IN_CODEBLOCK        -> not flagged: a table inside a fence is literal code by design.
853
        let cases: &[(&str, &str, u32, bool, bool)] = &[
854
            // (legacy rule, scenario doc, parsed tables, malformed_table?, unterminated_code_block?)
855
            ("FENCE_UNBALANCED", "```\ncode\n", 0, false, true),
856
            (
857
                "TABLE_COLUMN_MISMATCH",
858
                "| a | b |\n|---|---|---|\n| c | d | e |\n",
859
                0,
860
                true,
861
                false,
862
            ),
863
            (
864
                "TABLE_DIVIDER_INVALID",
865
                "| a | b |\n| -- | xx |\n| c | d |\n",
866
                0,
867
                false,
868
                false,
869
            ),
870
            (
871
                "TABLE_START",
872
                "| a | b |\n| - | - |\n| c | d |\n\nprose\n",
873
                1,
874
                false,
875
                false,
876
            ),
877
            ("TABLE_MIN_ROWS", "| a | b |\n| - | - |\n", 1, false, false),
878
            (
879
                "TABLE_EMPTY_HEADER",
880
                "| | |\n|---|---|\n| c | d |\n",
881
                1,
882
                false,
883
                false,
884
            ),
885
            (
886
                "TABLE_MISSING_BLANK_AFTER",
887
                "| a | b |\n| - | - |\n| c | d |\nprose\n",
888
                1,
889
                false,
890
                false,
891
            ),
892
            (
893
                "TABLE_CELL_NEWLINE",
894
                "| a | b |\n| - | - |\n| c | d\ne |\n",
895
                1,
896
                false,
897
                false,
898
            ),
899
            (
900
                "TABLE_FENCE_IN_CELL",
901
                "| a | ``` |\n| - | - |\n| c | d |\n",
902
                1,
903
                false,
904
                false,
905
            ),
906
            (
907
                "TABLE_IN_CODEBLOCK",
908
                "```\n| a | b |\n| - | - |\n| c | d |\n```\n",
909
                0,
910
                false,
911
                false,
912
            ),
913
        ];
914
        for (rule, doc, tables, malformed, unterminated) in cases {
915
            let analysis = analyze(doc);
916
            assert_eq!(
917
                analysis.stats.tables, *tables,
918
                "MDX_{rule}: parsed tables in {doc:?}"
919
            );
920
            assert_eq!(
921
                analysis.issues.contains(&StructuralIssue::MalformedTable),
922
                *malformed,
923
                "MDX_{rule}: malformed_table in {doc:?}"
924
            );
925
            assert_eq!(
926
                analysis
927
                    .issues
928
                    .contains(&StructuralIssue::UnterminatedCodeBlock),
929
                *unterminated,
930
                "MDX_{rule}: unterminated_code_block in {doc:?}"
931
            );
932
        }
933
    }
934
935
    #[test]
936
    fn gfm_spec_derived_table_cases() {
937
        // Minimal docs re-derived from the GFM spec's table-recognition rules
938
        // (section 4.10 "Tables (extension)"); each comment cites the behavior.
939
        let cases: &[(&str, &str, u32, bool)] = &[
940
            // (name, doc, parsed tables, malformed_table?)
941
            // GFM: a header row + matching delimiter row form a table (ex. 198).
942
            (
943
                "arity_match_is_table",
944
                "| foo | bar |\n| --- | --- |\n| baz | bim |\n",
945
                1,
946
                false,
947
            ),
948
            // GFM: delimiter cells may carry alignment colons and skip outer pipes (ex. 199).
949
            (
950
                "alignment_colons_no_outer_pipes",
951
                "| abc | defghi |\n:-: | -----------:\n| bar | baz |\n",
952
                1,
953
                false,
954
            ),
955
            // GFM: `\|` escapes a pipe inside a cell instead of splitting it (ex. 200).
956
            (
957
                "escaped_pipe_in_cell",
958
                "| f\\|oo | bar |\n| --- | --- |\n| b\\|az | bim |\n",
959
                1,
960
                false,
961
            ),
962
            // GFM: header/delimiter cell-count mismatch -> no table is recognized (ex. 203).
963
            (
964
                "arity_mismatch_not_recognized",
965
                "| abc | def |\n| --- |\n| bar |\n",
966
                0,
967
                true,
968
            ),
969
            // GFM: body rows may have more/fewer cells; padded/truncated, still a table (ex. 204).
970
            (
971
                "ragged_body_rows_still_table",
972
                "| abc | def |\n| --- | --- |\n| bar |\n| bar | baz | boo |\n",
973
                1,
974
                false,
975
            ),
976
            // GFM: the table is broken at the first empty line (ex. 205); the
977
            // pipe line after the blank is plain prose, not a second table.
978
            (
979
                "blank_line_ends_table",
980
                "| abc | def |\n| --- | --- |\n\n| bar | baz |\n",
981
                1,
982
                false,
983
            ),
984
            // A blank line between header and delimiter prevents recognition, and the
985
            // delimiter no longer sits under a header line, so we do not flag either.
986
            (
987
                "blank_between_header_and_delimiter",
988
                "| a | b |\n\n| - | - |\n",
989
                0,
990
                false,
991
            ),
992
        ];
993
        for (name, doc, tables, malformed) in cases {
994
            let analysis = analyze(doc);
995
            assert_eq!(
996
                analysis.stats.tables, *tables,
997
                "gfm `{name}`: parsed tables in {doc:?}"
998
            );
999
            assert_eq!(
1000
                analysis.issues.contains(&StructuralIssue::MalformedTable),
1001
                *malformed,
1002
                "gfm `{name}`: malformed_table in {doc:?}"
1003
            );
1004
        }
1005
    }
1006
1007
    #[test]
1008
    fn valid_table_mutations_flag() {
1009
        // Corrupting ONLY the delimiter row of a valid table must both un-parse the
1010
        // table and raise MalformedTable -- the detector tracks pulldown exactly.
1011
        let bases = [
1012
            "| a | b |\n| - | - |\n| c | d |\n",
1013
            "| a | b | c |\n| - | - | - |\n| d | e | f |\n",
1014
            "| a | b | c | d | e |\n| - | - | - | - | - |\n| f | g | h | i | j |\n",
1015
            "> | a | b |\n> | - | - |\n> | c | d |\n",
1016
        ];
1017
        // Rebuild the doc with line 1 (the delimiter row) rewritten by `mutate`.
1018
        fn with_delimiter(base: &str, mutate: impl Fn(&str) -> String) -> String {
1019
            let lines: Vec<String> = base
1020
                .lines()
1021
                .enumerate()
1022
                .map(|(i, line)| {
1023
                    if i == 1 {
1024
                        mutate(line)
1025
                    } else {
1026
                        line.to_string()
1027
                    }
1028
                })
1029
                .collect();
1030
            lines.join("\n") + "\n"
1031
        }
1032
1033
        for base in bases {
1034
            let clean = analyze(base);
1035
            assert!(
1036
                !clean.issues.contains(&StructuralIssue::MalformedTable),
1037
                "base must be clean: {base:?}"
1038
            );
1039
            assert_eq!(clean.stats.tables, 1, "base parses as one table: {base:?}");
1040
1041
            let mutants = [
1042
                // Append one extra `---|` cell to the delimiter row (wider than header).
1043
                (
1044
                    "append_delimiter_cell",
1045
                    with_delimiter(base, |line| format!("{line}---|")),
1046
                ),
1047
                // Delete one ` - |` cell from the delimiter row (narrower than header).
1048
                (
1049
                    "drop_delimiter_cell",
1050
                    with_delimiter(base, |line| {
1051
                        let at = line.rfind(" - |").expect("delimiter has a ` - |` cell");
1052
                        format!("{}{}", &line[..at], &line[at + 4..])
1053
                    }),
1054
                ),
1055
            ];
1056
            for (mutation, mutant) in mutants {
1057
                let analysis = analyze(&mutant);
1058
                assert!(
1059
                    analysis.issues.contains(&StructuralIssue::MalformedTable),
1060
                    "{mutation} must flag MalformedTable: {mutant:?}"
1061
                );
1062
                assert_eq!(
1063
                    analysis.stats.tables,
1064
                    clean.stats.tables - 1,
1065
                    "{mutation} must un-parse the table: {mutant:?}"
1066
                );
1067
            }
1068
        }
1069
    }
1070
}
crates/coder-lite/src/markdown/latex/commands.rs added +532

@@ -0,0 +1,532 @@

1
//! Core renderer: sequences, commands, scripts, fractions, accents.
2
3
use std::fmt::Write as _;
4
5
use super::cursor::Cursor;
6
use super::environments::render_environment;
7
use super::math_box::MathBox;
8
use super::symbols::{
9
    map_mathbb, map_mathbf, map_mathcal, map_mathfrak, symbol, to_subscript, to_superscript,
10
};
11
use super::{MAX_DEPTH, Mode};
12
13
/// Render an atom's source to a flat (single-line) Unicode string.
14
///
15
/// Atoms are arguments to commands (fraction sides, script bodies, accent
16
/// targets); they always render flat — multi-row content inside them joins
17
/// with `; `.
18
pub(super) fn render_atom(atom: &str, depth: usize, mode: Mode) -> String {
19
    let mut cursor = Cursor::new(atom);
20
    let mut out = MathBox::new(true);
21
    render_sequence(&mut cursor, &mut out, depth + 1, mode, None);
22
    out.into_lines().concat()
23
}
24
25
/// Core renderer: walks `cursor`, appending Unicode to `out`.
26
///
27
/// `stop_at` optionally terminates the sequence at an unbalanced `}` (used
28
/// when rendering inside a group whose `{` was consumed by the caller).
29
pub(super) fn render_sequence(
30
    cursor: &mut Cursor<'_>,
31
    out: &mut MathBox,
32
    depth: usize,
33
    mode: Mode,
34
    stop_at: Option<char>,
35
) {
36
    while let Some(ch) = cursor.peek() {
37
        if Some(ch) == stop_at {
38
            cursor.bump();
39
            return;
40
        }
41
        match ch {
42
            '\\' => {
43
                cursor.bump();
44
                render_command(cursor, out, depth, mode);
45
            }
46
            '{' => {
47
                cursor.bump();
48
                if depth >= MAX_DEPTH {
49
                    // Too deep: render the group body flat, without recursing.
50
                    out.push_str(cursor.read_group_body());
51
                } else {
52
                    // Render the group body into the same box so environments
53
                    // inside groups keep their 2D layout.
54
                    let body = cursor.read_group_body();
55
                    let mut sub = Cursor::new(body);
56
                    render_sequence(&mut sub, out, depth + 1, mode, None);
57
                }
58
            }
59
            '}' => {
60
                // Unbalanced closing brace: drop it.
61
                cursor.bump();
62
            }
63
            '^' => {
64
                cursor.bump();
65
                render_script(cursor, out, depth, mode, Script::Super);
66
            }
67
            '_' => {
68
                cursor.bump();
69
                render_script(cursor, out, depth, mode, Script::Sub);
70
            }
71
            '~' => {
72
                cursor.bump();
73
                out.push(' ');
74
            }
75
            '&' => {
76
                // Alignment marker outside an environment: drop.
77
                cursor.bump();
78
            }
79
            '$' => {
80
                // Stray math delimiter inside math: drop.
81
                cursor.bump();
82
            }
83
            '-' if mode == Mode::Math => {
84
                cursor.bump();
85
                out.push('−');
86
            }
87
            '\'' if mode == Mode::Math => {
88
                cursor.bump();
89
                out.push('′');
90
            }
91
            c if c.is_whitespace() => {
92
                cursor.skip_ws();
93
                // TeX collapses whitespace runs (including newlines) to
94
                // nothing semantically; keep a single space for readability.
95
                if !out.at_line_start() && !out.ends_with_space() {
96
                    out.push(' ');
97
                }
98
            }
99
            c => {
100
                cursor.bump();
101
                out.push(c);
102
            }
103
        }
104
    }
105
}
106
107
/// Which script position is being rendered.
108
#[derive(Copy, Clone, PartialEq, Eq)]
109
enum Script {
110
    Super,
111
    Sub,
112
}
113
114
/// Render `^atom` / `_atom` using Unicode script chars when every char of
115
/// the rendered atom has a script form; otherwise `^x` / `^(...)` fallback.
116
///
117
/// Word-like atoms take the fallback even when fully mappable: labels such as
118
/// `p_{\text{torso}}` or `x_{max}` would otherwise become long modifier-letter
119
/// runs (`pₜₒᵣₛₒ`) that are hard to read and render with visible gaps in
120
/// terminal fonts lacking those glyphs. Index-like atoms (`x_{ij}`,
121
/// `T_{i+1}`, `n^{th}`) keep the compact Unicode form.
122
fn render_script(
123
    cursor: &mut Cursor<'_>,
124
    out: &mut MathBox,
125
    depth: usize,
126
    mode: Mode,
127
    kind: Script,
128
) {
129
    let Some(atom) = cursor.read_atom() else {
130
        out.push(match kind {
131
            Script::Super => '^',
132
            Script::Sub => '_',
133
        });
134
        return;
135
    };
136
    let rendered = render_atom(atom, depth, mode);
137
    let mapped: Option<String> = if script_atom_is_wordlike(atom, &rendered) {
138
        None
139
    } else {
140
        rendered
141
            .chars()
142
            .map(|c| match kind {
143
                Script::Super => to_superscript(c),
144
                Script::Sub => to_subscript(c),
145
            })
146
            .collect()
147
    };
148
    match mapped {
149
        Some(s) if !s.is_empty() => out.push_str(&s),
150
        _ => {
151
            out.push(match kind {
152
                Script::Super => '^',
153
                Script::Sub => '_',
154
            });
155
            if rendered.chars().count() > 1 {
156
                let _ = write!(out, "({rendered})");
157
            } else {
158
                out.push_str(&rendered);
159
            }
160
        }
161
    }
162
}
163
164
/// `true` if a script atom is a word-like label rather than indices.
165
///
166
/// Two signals, checked on the atom *source* and its rendered form:
167
///
168
/// - the source routes through a text-family command (`\text{…}`, `\mathrm{…}`,
169
///   `\operatorname{…}`, …): the author explicitly marked the content as a
170
///   word;
171
/// - the rendered form contains a run of 3+ ASCII letters: multi-letter runs
172
///   read as words (`max`, `torso`), while 1–2 letter runs are index
173
///   juxtapositions (`ij`, `th`) that stay compact.
174
fn script_atom_is_wordlike(atom: &str, rendered: &str) -> bool {
175
    // `\text` also catches `\textrm`/`\textbf`/`\textit`/`\textsf`/`\texttt`/
176
    // `\textnormal` by prefix; `\math…` variants and box commands likewise.
177
    const TEXT_MARKERS: [&str; 8] = [
178
        "\\text",
179
        "\\mathrm",
180
        "\\mathsf",
181
        "\\mathtt",
182
        "\\mathit",
183
        "\\operatorname",
184
        "\\mbox",
185
        "\\hbox",
186
    ];
187
    if TEXT_MARKERS.iter().any(|m| atom.contains(m)) {
188
        return true;
189
    }
190
    let mut run = 0usize;
191
    for c in rendered.chars() {
192
        if c.is_ascii_alphabetic() {
193
            run += 1;
194
            if run >= 3 {
195
                return true;
196
            }
197
        } else {
198
            run = 0;
199
        }
200
    }
201
    false
202
}
203
204
/// Render a `\command` whose backslash was already consumed.
205
fn render_command(cursor: &mut Cursor<'_>, out: &mut MathBox, depth: usize, mode: Mode) {
206
    let name = cursor.read_command_name();
207
    match name {
208
        // ── Structure ────────────────────────────────────────────────────
209
        "" => out.push('\\'),
210
        "\\" => out.push('\n'),
211
        "begin" => render_environment(cursor, out, depth, mode),
212
        "end" => {
213
            // Stray \end without matching \begin: drop its argument.
214
            let _ = take_brace_arg(cursor);
215
        }
216
        "left" | "right" => {
217
            // Keep the delimiter that follows; `.` means "no delimiter".
218
            cursor.skip_ws();
219
            match cursor.peek() {
220
                Some('.') => {
221
                    cursor.bump();
222
                }
223
                Some('\\') => {
224
                    cursor.bump();
225
                    render_command(cursor, out, depth, mode);
226
                }
227
                Some(c) => {
228
                    cursor.bump();
229
                    out.push(c);
230
                }
231
                None => {}
232
            }
233
        }
234
235
        // ── Fractions / binomials / roots ────────────────────────────────
236
        "frac" | "dfrac" | "tfrac" | "cfrac" => {
237
            let num = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
238
            let den = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
239
            match (num, den) {
240
                (Some(n), Some(d)) => out.push_str(&format_fraction(&n, &d)),
241
                (Some(n), None) => out.push_str(&n),
242
                _ => {}
243
            }
244
        }
245
        "binom" | "tbinom" | "dbinom" => {
246
            let n = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
247
            let k = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
248
            if let (Some(n), Some(k)) = (n, k) {
249
                let _ = write!(out, "C({n}, {k})");
250
            }
251
        }
252
        "sqrt" => {
253
            cursor.skip_ws();
254
            let index = if cursor.peek() == Some('[') {
255
                cursor.bump();
256
                let start = cursor.pos;
257
                while let Some(c) = cursor.peek() {
258
                    if c == ']' {
259
                        break;
260
                    }
261
                    cursor.bump();
262
                }
263
                let idx = &cursor.src[start..cursor.pos];
264
                cursor.bump(); // consume `]`
265
                Some(render_atom(idx, depth, mode))
266
            } else {
267
                None
268
            };
269
            let radical = match index.as_deref() {
270
                None | Some("2") => "√",
271
                Some("3") => "∛",
272
                Some("4") => "∜",
273
                Some(other) => {
274
                    // ⁿ√ style prefix for other indices.
275
                    let sup: Option<String> = other.chars().map(to_superscript).collect();
276
                    out.push_str(&sup.unwrap_or_else(|| format!("({other})")));
277
                    "√"
278
                }
279
            };
280
            out.push_str(radical);
281
            if let Some(arg) = cursor.read_atom() {
282
                let rendered = render_atom(arg, depth, mode);
283
                // Parenthesize any multi-char radicand: `√ab` would read as
284
                // `(√a)b`.
285
                if rendered.chars().count() > 1 {
286
                    let _ = write!(out, "({rendered})");
287
                } else {
288
                    out.push_str(&rendered);
289
                }
290
            }
291
        }
292
293
        // ── Boxes (frame dropped; content preserved) ─────────────────────
294
        "boxed" => {
295
            if let Some(arg) = take_brace_arg(cursor) {
296
                out.push_str(&render_atom(arg, depth, mode));
297
            }
298
        }
299
        "fbox" | "framebox" => {
300
            if let Some(arg) = take_brace_arg(cursor) {
301
                out.push_str(&render_atom(arg, depth, Mode::Text));
302
            }
303
        }
304
305
        // ── Text / alphabets ─────────────────────────────────────────────
306
        "text" | "textrm" | "textit" | "textbf" | "textsf" | "texttt" | "textnormal" | "mbox"
307
        | "hbox" => {
308
            if let Some(arg) = take_brace_arg(cursor) {
309
                out.push_str(&render_atom(arg, depth, Mode::Text));
310
            }
311
        }
312
        "mathrm" | "operatorname" | "mathit" | "mathsf" | "mathtt" | "mathnormal" => {
313
            if let Some(arg) = take_brace_arg(cursor) {
314
                out.push_str(&render_atom(arg, depth, Mode::Text));
315
            }
316
        }
317
        "mathbb" => render_mapped_alphabet(cursor, out, depth, mode, map_mathbb),
318
        "mathcal" | "mathscr" => render_mapped_alphabet(cursor, out, depth, mode, map_mathcal),
319
        "mathfrak" => render_mapped_alphabet(cursor, out, depth, mode, map_mathfrak),
320
        "mathbf" | "boldsymbol" | "bm" | "bold" => {
321
            render_mapped_alphabet(cursor, out, depth, mode, map_mathbf)
322
        }
323
324
        // ── Accents (combining marks) ────────────────────────────────────
325
        "hat" | "widehat" => render_accent(cursor, out, depth, mode, '\u{0302}'),
326
        "bar" | "overline" => render_accent(cursor, out, depth, mode, '\u{0304}'),
327
        "tilde" | "widetilde" => render_accent(cursor, out, depth, mode, '\u{0303}'),
328
        "vec" => render_accent(cursor, out, depth, mode, '\u{20D7}'),
329
        "dot" => render_accent(cursor, out, depth, mode, '\u{0307}'),
330
        "ddot" => render_accent(cursor, out, depth, mode, '\u{0308}'),
331
        "check" => render_accent(cursor, out, depth, mode, '\u{030C}'),
332
        "breve" => render_accent(cursor, out, depth, mode, '\u{0306}'),
333
        "acute" => render_accent(cursor, out, depth, mode, '\u{0301}'),
334
        "grave" => render_accent(cursor, out, depth, mode, '\u{0300}'),
335
        "mathring" => render_accent(cursor, out, depth, mode, '\u{030A}'),
336
        "underline" => render_accent(cursor, out, depth, mode, '\u{0332}'),
337
338
        // ── Negation ─────────────────────────────────────────────────────
339
        "not" => {
340
            if let Some(atom) = cursor.read_atom() {
341
                let rendered = render_atom(atom, depth, mode);
342
                match rendered.as_str() {
343
                    "∈" => out.push('∉'),
344
                    "=" => out.push('≠'),
345
                    "<" => out.push('≮'),
346
                    ">" => out.push('≯'),
347
                    "≡" => out.push('≢'),
348
                    "⊂" => out.push('⊄'),
349
                    "⊆" => out.push('⊈'),
350
                    "∃" => out.push('∄'),
351
                    other => {
352
                        out.push_str(other);
353
                        // Combining long solidus overlay on the last char.
354
                        if !other.is_empty() {
355
                            out.push('\u{0338}');
356
                        }
357
                    }
358
                }
359
            }
360
        }
361
362
        // ── Decorations rendered as base + script ────────────────────────
363
        "overset" | "stackrel" => {
364
            let over = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
365
            let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
366
            if let (Some(over), Some(base)) = (over, base) {
367
                out.push_str(&base);
368
                let sup: Option<String> = over.chars().map(to_superscript).collect();
369
                match sup {
370
                    Some(s) if !s.is_empty() => out.push_str(&s),
371
                    _ => {}
372
                }
373
            }
374
        }
375
        "underset" => {
376
            let under = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
377
            let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
378
            if let (Some(under), Some(base)) = (under, base) {
379
                out.push_str(&base);
380
                let sub: Option<String> = under.chars().map(to_subscript).collect();
381
                match sub {
382
                    Some(s) if !s.is_empty() => out.push_str(&s),
383
                    _ => {}
384
                }
385
            }
386
        }
387
388
        // ── Modular arithmetic ───────────────────────────────────────────
389
        "pmod" => {
390
            if let Some(arg) = take_brace_arg(cursor) {
391
                if !out.at_line_start() && !out.ends_with_space() {
392
                    out.push(' ');
393
                }
394
                let _ = write!(out, "(mod {})", render_atom(arg, depth, mode));
395
            }
396
        }
397
        "bmod" => {
398
            if !out.at_line_start() && !out.ends_with_space() {
399
                out.push(' ');
400
            }
401
            out.push_str("mod ");
402
        }
403
404
        // ── Spacing ──────────────────────────────────────────────────────
405
        "," | ";" | ":" | ">" | " " | "space" | "thinspace" | "medspace" | "thickspace"
406
        | "enspace" => {
407
            if !out.at_line_start() && !out.ends_with_space() {
408
                out.push(' ');
409
            }
410
        }
411
        "quad" => out.push_str("  "),
412
        "qquad" => out.push_str("    "),
413
        "!" | "negthinspace" | "negmedspace" | "negthickspace" => {}
414
415
        // ── No-ops (sizing/styling/structure hints) ──────────────────────
416
        "limits" | "nolimits" | "displaystyle" | "textstyle" | "scriptstyle"
417
        | "scriptscriptstyle" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "Bigl" | "biggl"
418
        | "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "bigm" | "Bigm" | "biggm" | "Biggm"
419
        | "mathstrut" | "strut" | "allowbreak" | "nonumber" | "notag" | "mathopen"
420
        | "mathclose" | "mathbin" | "mathrel" | "mathord" | "mathpunct" | "mathinner"
421
        | "mathop" | "ensuremath" | "label" | "tag" => {
422
            // \label/\tag carry non-visual arguments: drop them.
423
            if matches!(name, "label" | "tag") {
424
                let _ = take_brace_arg(cursor);
425
            }
426
        }
427
428
        // ── Symbol table ─────────────────────────────────────────────────
429
        _ => {
430
            if let Some(sym) = symbol(name) {
431
                out.push_str(sym);
432
            } else {
433
                // Unknown command: keep its name as plain text.
434
                out.push_str(name);
435
            }
436
        }
437
    }
438
}
439
440
/// Consume `{...}` (after optional whitespace) and return the body source.
441
pub(super) fn take_brace_arg<'a>(cursor: &mut Cursor<'a>) -> Option<&'a str> {
442
    cursor.skip_ws();
443
    if cursor.peek() == Some('{') {
444
        cursor.bump();
445
        Some(cursor.read_group_body())
446
    } else {
447
        None
448
    }
449
}
450
451
/// `true` if a fraction/root operand needs parentheses for readability.
452
fn needs_parens(s: &str) -> bool {
453
    s.chars().count() > 1 && s.contains([' ', '+', '−', '-', '=', '/'])
454
}
455
456
/// Format `num/den`, mapping common numeric fractions to vulgar fractions.
457
fn format_fraction(num: &str, den: &str) -> String {
458
    let vulgar = match (num, den) {
459
        ("1", "2") => Some('½'),
460
        ("1", "3") => Some('⅓'),
461
        ("2", "3") => Some('⅔'),
462
        ("1", "4") => Some('¼'),
463
        ("3", "4") => Some('¾'),
464
        ("1", "5") => Some('⅕'),
465
        ("2", "5") => Some('⅖'),
466
        ("3", "5") => Some('⅗'),
467
        ("4", "5") => Some('⅘'),
468
        ("1", "6") => Some('⅙'),
469
        ("5", "6") => Some('⅚'),
470
        ("1", "7") => Some('⅐'),
471
        ("1", "8") => Some('⅛'),
472
        ("3", "8") => Some('⅜'),
473
        ("5", "8") => Some('⅝'),
474
        ("7", "8") => Some('⅞'),
475
        ("1", "9") => Some('⅑'),
476
        ("1", "10") => Some('⅒'),
477
        _ => None,
478
    };
479
    if let Some(v) = vulgar {
480
        return v.to_string();
481
    }
482
    let n = if needs_parens(num) {
483
        format!("({num})")
484
    } else {
485
        num.to_string()
486
    };
487
    let d = if needs_parens(den) {
488
        format!("({den})")
489
    } else {
490
        den.to_string()
491
    };
492
    format!("{n}/{d}")
493
}
494
495
/// Render an alphabet-mapping command (`\mathbb{R}` etc.): map chars that
496
/// have a styled form, keep the rest as rendered.
497
fn render_mapped_alphabet(
498
    cursor: &mut Cursor<'_>,
499
    out: &mut MathBox,
500
    depth: usize,
501
    mode: Mode,
502
    map: fn(char) -> Option<char>,
503
) {
504
    let Some(atom) = cursor.read_atom() else {
505
        return;
506
    };
507
    let rendered = render_atom(atom, depth, mode);
508
    for c in rendered.chars() {
509
        out.push(map(c).unwrap_or(c));
510
    }
511
}
512
513
/// Render an accent command by appending a combining mark to each char of
514
/// the argument.
515
fn render_accent(
516
    cursor: &mut Cursor<'_>,
517
    out: &mut MathBox,
518
    depth: usize,
519
    mode: Mode,
520
    combining: char,
521
) {
522
    let Some(atom) = cursor.read_atom() else {
523
        return;
524
    };
525
    let rendered = render_atom(atom, depth, mode);
526
    for c in rendered.chars() {
527
        out.push(c);
528
        if !c.is_whitespace() {
529
            out.push(combining);
530
        }
531
    }
532
}
crates/coder-lite/src/markdown/latex/cursor.rs added +99

@@ -0,0 +1,99 @@

1
//! Byte cursor over TeX source.
2
3
/// Byte cursor over the TeX source.
4
pub(super) struct Cursor<'a> {
5
    pub(super) src: &'a str,
6
    pub(super) pos: usize,
7
}
8
9
impl<'a> Cursor<'a> {
10
    pub(super) fn new(src: &'a str) -> Self {
11
        Self { src, pos: 0 }
12
    }
13
14
    pub(super) fn peek(&self) -> Option<char> {
15
        self.src[self.pos..].chars().next()
16
    }
17
18
    pub(super) fn bump(&mut self) -> Option<char> {
19
        let ch = self.peek()?;
20
        self.pos += ch.len_utf8();
21
        Some(ch)
22
    }
23
24
    /// Consume `\command` (alphabetic name) or `\<single char>`; the leading
25
    /// backslash must already be consumed. Returns the command name.
26
    ///
27
    /// Unlike TeX we do NOT consume trailing whitespace: the caller's
28
    /// whitespace collapsing keeps `\to 0` rendering as `→ 0`.
29
    pub(super) fn read_command_name(&mut self) -> &'a str {
30
        let start = self.pos;
31
        match self.peek() {
32
            Some(c) if c.is_ascii_alphabetic() => {
33
                while matches!(self.peek(), Some(c) if c.is_ascii_alphabetic()) {
34
                    self.bump();
35
                }
36
                &self.src[start..self.pos]
37
            }
38
            Some(_) => {
39
                self.bump();
40
                &self.src[start..self.pos]
41
            }
42
            None => "",
43
        }
44
    }
45
46
    /// Skip whitespace (TeX collapses it; meaning comes from commands).
47
    pub(super) fn skip_ws(&mut self) {
48
        while matches!(self.peek(), Some(c) if c.is_whitespace()) {
49
            self.bump();
50
        }
51
    }
52
53
    /// Read a balanced `{...}` group body, assuming `{` was already consumed.
54
    /// Returns the inner source (without braces). Unbalanced input returns
55
    /// the remainder of the source.
56
    pub(super) fn read_group_body(&mut self) -> &'a str {
57
        let start = self.pos;
58
        let mut depth = 1usize;
59
        while let Some(ch) = self.bump() {
60
            match ch {
61
                '\\' => {
62
                    // Skip escaped char so `\{`/`\}` don't affect depth.
63
                    self.bump();
64
                }
65
                '{' => depth += 1,
66
                '}' => {
67
                    depth -= 1;
68
                    if depth == 0 {
69
                        return &self.src[start..self.pos - 1];
70
                    }
71
                }
72
                _ => {}
73
            }
74
        }
75
        &self.src[start..self.pos]
76
    }
77
78
    /// Read the next "atom": a `{...}` group body, a `\command` (returned
79
    /// with backslash), or a single char. Skips leading whitespace.
80
    pub(super) fn read_atom(&mut self) -> Option<&'a str> {
81
        self.skip_ws();
82
        let start = self.pos;
83
        match self.peek()? {
84
            '{' => {
85
                self.bump();
86
                Some(self.read_group_body())
87
            }
88
            '\\' => {
89
                self.bump();
90
                self.read_command_name();
91
                Some(&self.src[start..self.pos])
92
            }
93
            _ => {
94
                self.bump();
95
                Some(&self.src[start..self.pos])
96
            }
97
        }
98
    }
99
}
crates/coder-lite/src/markdown/latex/environments.rs added +325

@@ -0,0 +1,325 @@

1
//! `\\begin{env}...\\end{env}` environments: matrices, cases, alignments.
2
3
use crate::markdown::buffers::unicode_display_width;
4
5
use super::Mode;
6
use super::commands::{render_atom, take_brace_arg};
7
use super::cursor::Cursor;
8
use super::math_box::MathBox;
9
10
/// Render `\begin{env}...\end{env}`. The `\begin` name was already consumed.
11
pub(super) fn render_environment(
12
    cursor: &mut Cursor<'_>,
13
    out: &mut MathBox,
14
    depth: usize,
15
    mode: Mode,
16
) {
17
    let Some(env_name) = take_brace_arg(cursor) else {
18
        return;
19
    };
20
    let env_name = env_name.trim().trim_end_matches('*');
21
22
    // Capture body source until the matching `\end{name}`, tracking nesting
23
    // of same-named environments. Scans raw source from the cursor.
24
    let body_start = cursor.pos;
25
    let mut body_end = cursor.src.len();
26
    let mut resume = cursor.src.len();
27
    let mut nest = 0usize;
28
    let mut search = cursor.pos;
29
    while search < cursor.src.len() {
30
        let rest = &cursor.src[search..];
31
        let Some(rel) = rest.find('\\') else {
32
            break;
33
        };
34
        let bs_pos = search + rel;
35
        let after_bs = &cursor.src[bs_pos + 1..];
36
        let kw_len = if command_at(after_bs, "begin") {
37
            "begin".len()
38
        } else if command_at(after_bs, "end") {
39
            "end".len()
40
        } else {
41
            // Not begin/end: skip the backslash and the char after it (so
42
            // `\\` and `\{` never confuse the scan).
43
            let skip = after_bs.chars().next().map_or(0, char::len_utf8);
44
            search = bs_pos + 1 + skip.max(1);
45
            continue;
46
        };
47
        let is_begin = kw_len == "begin".len();
48
        let mut probe = Cursor {
49
            src: cursor.src,
50
            pos: bs_pos + 1 + kw_len,
51
        };
52
        let arg = take_brace_arg(&mut probe).map(|a| a.trim().trim_end_matches('*'));
53
        if arg == Some(env_name) {
54
            if is_begin {
55
                nest += 1;
56
            } else if nest == 0 {
57
                body_end = bs_pos;
58
                resume = probe.pos;
59
                break;
60
            } else {
61
                nest -= 1;
62
            }
63
        }
64
        search = probe.pos.max(bs_pos + 1 + kw_len);
65
    }
66
    cursor.pos = resume;
67
    let mut body = &cursor.src[body_start..body_end.min(cursor.src.len())];
68
69
    // Optional column spec for array environments: `\begin{array}{ll}`.
70
    if env_name == "array" || env_name == "alignat" {
71
        let mut probe = Cursor::new(body);
72
        probe.skip_ws();
73
        if probe.peek() == Some('{') {
74
            probe.bump();
75
            let _ = probe.read_group_body();
76
            body = &body[probe.pos..];
77
        }
78
    }
79
    let rows = env_rows_to_strings(body, env_name, out.flat, depth, mode);
80
    out.hcat_rows(rows);
81
}
82
83
/// `true` if `rest` starts with command word `word` NOT followed by another
84
/// ASCII letter (so `\endx` is not mistaken for `\end`).
85
fn command_at(rest: &str, word: &str) -> bool {
86
    rest.starts_with(word)
87
        && !rest[word.len()..]
88
            .chars()
89
            .next()
90
            .is_some_and(|c| c.is_ascii_alphabetic())
91
}
92
93
/// Split an environment body into rows (`\\`) and cells (`&`) at brace and
94
/// environment depth 0, render each cell, then lay the rows out according to
95
/// the environment. Returns one string per visual row; the caller attaches
96
/// them as a box. In `flat` mode, matrix/cases environments render as a
97
/// single row with `; ` between matrix rows.
98
fn env_rows_to_strings(
99
    body: &str,
100
    env_name: &str,
101
    flat: bool,
102
    depth: usize,
103
    mode: Mode,
104
) -> Vec<String> {
105
    let mut rows: Vec<Vec<String>> = Vec::new();
106
    let mut row: Vec<String> = Vec::new();
107
    let mut cell_start = 0usize;
108
    let mut brace_depth = 0usize;
109
    let mut env_depth = 0usize;
110
    let bytes = body.as_bytes();
111
    let mut i = 0usize;
112
    while i < bytes.len() {
113
        match bytes[i] {
114
            b'\\' => {
115
                if bytes.get(i + 1) == Some(&b'\\') {
116
                    if brace_depth == 0 && env_depth == 0 {
117
                        row.push(body[cell_start..i].to_string());
118
                        rows.push(std::mem::take(&mut row));
119
                        i += 2;
120
                        cell_start = i;
121
                        continue;
122
                    }
123
                    i += 2;
124
                    continue;
125
                }
126
                let rest = &body[i + 1..];
127
                if command_at(rest, "begin") {
128
                    env_depth += 1;
129
                } else if command_at(rest, "end") {
130
                    env_depth = env_depth.saturating_sub(1);
131
                }
132
                // Skip the backslash plus the char after it so escaped
133
                // delimiters (`\&`, `\{`, `\}`) never affect depth/splits.
134
                let skip = rest.chars().next().map_or(0, char::len_utf8);
135
                i += 1 + skip.max(1);
136
                continue;
137
            }
138
            b'{' => brace_depth += 1,
139
            b'}' => brace_depth = brace_depth.saturating_sub(1),
140
            b'&' if brace_depth == 0 && env_depth == 0 => {
141
                row.push(body[cell_start..i].to_string());
142
                cell_start = i + 1;
143
            }
144
            _ => {}
145
        }
146
        i += 1;
147
    }
148
    row.push(body[cell_start.min(bytes.len())..].to_string());
149
    rows.push(row);
150
151
    // Render each cell, drop fully-empty rows.
152
    let mut rendered_rows: Vec<Vec<String>> = rows
153
        .into_iter()
154
        .map(|cells| {
155
            cells
156
                .into_iter()
157
                .map(|c| render_atom(c.trim(), depth, mode).trim().to_string())
158
                .collect::<Vec<_>>()
159
        })
160
        .collect();
161
    rendered_rows.retain(|cells| cells.iter().any(|c| !c.is_empty()));
162
    if rendered_rows.is_empty() {
163
        return Vec::new();
164
    }
165
166
    let is_matrix = matches!(
167
        env_name,
168
        "matrix"
169
            | "pmatrix"
170
            | "bmatrix"
171
            | "Bmatrix"
172
            | "vmatrix"
173
            | "Vmatrix"
174
            | "smallmatrix"
175
            | "array"
176
    );
177
    let n_rows = rendered_rows.len();
178
179
    if is_matrix {
180
        // Flat (inline) mode: one row, single delimiter pair, rows joined
181
        // with `; ` — `(1  2; 3  4)`.
182
        if flat {
183
            let inner = rendered_rows
184
                .iter()
185
                .map(|cells| cells.join("  "))
186
                .collect::<Vec<_>>()
187
                .join("; ");
188
            // Single-row delimiter pair; plain `matrix` has none (' ').
189
            let (l, r) = matrix_delims(env_name, 0, 1);
190
            let mut s = String::new();
191
            if l != ' ' {
192
                s.push(l);
193
            }
194
            s.push_str(&inner);
195
            if r != ' ' {
196
                s.push(r);
197
            }
198
            return vec![s];
199
        }
200
        // Pad columns to equal width so rows align.
201
        let n_cols = rendered_rows.iter().map(Vec::len).max().unwrap_or(0);
202
        let mut widths = vec![0usize; n_cols];
203
        for cells in &rendered_rows {
204
            for (i, cell) in cells.iter().enumerate() {
205
                widths[i] = widths[i].max(unicode_display_width(cell));
206
            }
207
        }
208
        rendered_rows
209
            .iter()
210
            .enumerate()
211
            .map(|(row_idx, cells)| {
212
                let mut content = String::new();
213
                for (i, cell) in cells.iter().enumerate() {
214
                    if i > 0 {
215
                        content.push_str("  ");
216
                    }
217
                    content.push_str(cell);
218
                    if i + 1 < cells.len() {
219
                        let pad = widths[i].saturating_sub(unicode_display_width(cell));
220
                        content.push_str(&" ".repeat(pad));
221
                    }
222
                }
223
                let (l, r) = matrix_delims(env_name, row_idx, n_rows);
224
                format!("{l}{content}{r}")
225
            })
226
            .collect()
227
    } else if env_name == "cases" {
228
        if flat {
229
            let inner = rendered_rows
230
                .iter()
231
                .map(|cells| cells.join("  "))
232
                .collect::<Vec<_>>()
233
                .join("; ");
234
            return vec![format!("{{{inner}}}")];
235
        }
236
        rendered_rows
237
            .iter()
238
            .enumerate()
239
            .map(|(row_idx, cells)| {
240
                let brace = cases_brace(row_idx, n_rows);
241
                format!("{brace} {}", cells.join("  "))
242
            })
243
            .collect()
244
    } else {
245
        // aligned/align/gather/split/equation/…: `&` is an invisible
246
        // alignment marker; rejoin cells with a single space. One string per
247
        // row; the caller's box attachment (or flat `; ` join) handles the
248
        // rest.
249
        rendered_rows
250
            .iter()
251
            .map(|cells| {
252
                let mut s = cells
253
                    .iter()
254
                    .filter(|c| !c.is_empty())
255
                    .cloned()
256
                    .collect::<Vec<_>>()
257
                    .join(" ");
258
                // Collapse any double spaces introduced around markers.
259
                while s.contains("  ") {
260
                    s = s.replace("  ", " ");
261
                }
262
                s
263
            })
264
            .collect()
265
    }
266
}
267
268
/// Per-row delimiters for matrix-family environments.
269
fn matrix_delims(env: &str, row: usize, n_rows: usize) -> (char, char) {
270
    let single = n_rows == 1;
271
    let first = row == 0;
272
    let last = row + 1 == n_rows;
273
    match env {
274
        "pmatrix" => {
275
            if single {
276
                ('(', ')')
277
            } else if first {
278
                ('⎛', '⎞')
279
            } else if last {
280
                ('⎝', '⎠')
281
            } else {
282
                ('⎜', '⎟')
283
            }
284
        }
285
        "bmatrix" | "array" => {
286
            if single {
287
                ('[', ']')
288
            } else if first {
289
                ('⎡', '⎤')
290
            } else if last {
291
                ('⎣', '⎦')
292
            } else {
293
                ('⎢', '⎥')
294
            }
295
        }
296
        "Bmatrix" => {
297
            if single {
298
                ('{', '}')
299
            } else if first {
300
                ('⎧', '⎫')
301
            } else if last {
302
                ('⎩', '⎭')
303
            } else {
304
                ('⎨', '⎬')
305
            }
306
        }
307
        "vmatrix" | "Vmatrix" => ('│', '│'),
308
        _ => (' ', ' '),
309
    }
310
}
311
312
/// Left-brace column char for `cases` rows.
313
fn cases_brace(row: usize, n_rows: usize) -> char {
314
    if n_rows == 1 {
315
        '{'
316
    } else if row == 0 {
317
        '⎧'
318
    } else if row + 1 == n_rows {
319
        '⎩'
320
    } else if row == n_rows / 2 {
321
        '⎨'
322
    } else {
323
        '⎪'
324
    }
325
}
crates/coder-lite/src/markdown/latex/math_box.rs added +156

@@ -0,0 +1,156 @@

1
//! Two-dimensional math layout box.
2
3
use crate::markdown::buffers::unicode_display_width;
4
5
/// Two-dimensional text box with an anchor row where horizontal flow
6
/// attaches.
7
///
8
/// Multi-row content (matrix-family environments) extends above/below the
9
/// anchor row; subsequent output continues on the anchor row. This keeps a
10
/// prefix, a matrix, and a suffix aligned:
11
///
12
/// ```text
13
/// A = ⎛1  2⎞,   det(A) = −2
14
///     ⎝3  4⎠
15
/// ```
16
pub(super) struct MathBox {
17
    lines: Vec<String>,
18
    /// Row index that horizontal flow currently appends to.
19
    anchor: usize,
20
    /// First row belonging to the current visual line. Rows before `floor`
21
    /// are completed lines from earlier `\\` breaks and must never be
22
    /// touched by box attachment.
23
    floor: usize,
24
    /// Flat mode (inline math): vertical layout is impossible, so row breaks
25
    /// render as `; ` and environments render single-row.
26
    pub(super) flat: bool,
27
}
28
29
impl MathBox {
30
    pub(super) fn new(flat: bool) -> Self {
31
        Self {
32
            lines: vec![String::new()],
33
            anchor: 0,
34
            floor: 0,
35
            flat,
36
        }
37
    }
38
39
    fn cur(&mut self) -> &mut String {
40
        &mut self.lines[self.anchor]
41
    }
42
43
    /// `true` when nothing has been emitted on the current flow row yet.
44
    pub(super) fn at_line_start(&self) -> bool {
45
        self.lines[self.anchor].is_empty()
46
    }
47
48
    pub(super) fn ends_with_space(&self) -> bool {
49
        self.lines[self.anchor].ends_with(' ')
50
    }
51
52
    pub(super) fn push(&mut self, c: char) {
53
        if c == '\n' {
54
            self.vbreak();
55
        } else {
56
            self.cur().push(c);
57
        }
58
    }
59
60
    pub(super) fn push_str(&mut self, s: &str) {
61
        if s.contains('\n') {
62
            self.hcat_rows(s.split('\n').map(str::to_string).collect());
63
        } else {
64
            self.cur().push_str(s);
65
        }
66
    }
67
68
    /// End the current visual line; flow continues on a fresh row below all
69
    /// existing rows. Flat mode renders the break as `; `.
70
    fn vbreak(&mut self) {
71
        if self.flat {
72
            if !self.at_line_start() {
73
                let cur = self.cur();
74
                while cur.ends_with(' ') {
75
                    cur.pop();
76
                }
77
                cur.push_str("; ");
78
            }
79
        } else {
80
            self.lines.push(String::new());
81
            self.anchor = self.lines.len() - 1;
82
            self.floor = self.anchor;
83
        }
84
    }
85
86
    /// Attach `rows` as a box at the current flow position, anchored at the
87
    /// box's upper-middle row. All box rows start at the same column; flow
88
    /// resumes on the anchor row past the box's widest row.
89
    pub(super) fn hcat_rows(&mut self, rows: Vec<String>) {
90
        if rows.is_empty() {
91
            return;
92
        }
93
        if self.flat || rows.len() == 1 {
94
            for (i, row) in rows.iter().enumerate() {
95
                if i > 0 {
96
                    self.vbreak();
97
                }
98
                self.cur().push_str(row);
99
            }
100
            return;
101
        }
102
        let box_anchor = (rows.len() - 1) / 2;
103
        let attach_col = unicode_display_width(&self.lines[self.anchor]);
104
        let box_width = rows
105
            .iter()
106
            .map(|r| unicode_display_width(r))
107
            .max()
108
            .unwrap_or(0);
109
110
        // Ensure enough rows above the anchor within the current visual line.
111
        let have_above = self.anchor - self.floor;
112
        if box_anchor > have_above {
113
            let add = box_anchor - have_above;
114
            for _ in 0..add {
115
                self.lines.insert(self.floor, String::new());
116
            }
117
            self.anchor += add;
118
        }
119
        // Ensure enough rows below the anchor.
120
        let below = rows.len() - box_anchor - 1;
121
        let have_below = self.lines.len() - self.anchor - 1;
122
        if below > have_below {
123
            for _ in 0..(below - have_below) {
124
                self.lines.push(String::new());
125
            }
126
        }
127
        // Place the box rows, left-padded to the attach column.
128
        for (i, row) in rows.iter().enumerate() {
129
            let target = self.anchor - box_anchor + i;
130
            let line = &mut self.lines[target];
131
            let cur_w = unicode_display_width(line);
132
            if cur_w < attach_col {
133
                line.push_str(&" ".repeat(attach_col - cur_w));
134
            }
135
            line.push_str(row);
136
        }
137
        // Flow resumes past the box's widest row.
138
        let frontier = attach_col + box_width;
139
        let cur_w = unicode_display_width(&self.lines[self.anchor]);
140
        if cur_w < frontier {
141
            let pad = frontier - cur_w;
142
            self.lines[self.anchor].push_str(&" ".repeat(pad));
143
        }
144
    }
145
146
    pub(super) fn into_lines(self) -> Vec<String> {
147
        self.lines
148
    }
149
}
150
151
impl std::fmt::Write for MathBox {
152
    fn write_str(&mut self, s: &str) -> std::fmt::Result {
153
        self.push_str(s);
154
        Ok(())
155
    }
156
}
crates/coder-lite/src/markdown/latex/mod.rs added +96

@@ -0,0 +1,96 @@

1
//! Best-effort LaTeX math → Unicode plain-text conversion.
2
//!
3
//! Converts TeX math source (the content of `$...$`, `$$...$$`, `\(...\)`,
4
//! `\[...\]`) into a readable Unicode approximation for terminal display:
5
//!
6
//! - Greek letters and symbol commands (`\alpha` → `α`, `\le` → `≤`, …)
7
//! - Superscripts/subscripts via Unicode script characters (`x^2` → `x²`,
8
//!   `a_1` → `a₁`) with `^(...)`/`_(...)` fallback when a char has no
9
//!   Unicode script form
10
//! - Fractions (`\frac{1}{2}` → `½`, `\frac{a+b}{c}` → `(a+b)/c`)
11
//! - Roots (`\sqrt{x}` → `√x`, `\sqrt[3]{x}` → `∛x`)
12
//! - Alphabets (`\mathbb{R}` → `ℝ`, `\mathcal{L}` → `ℒ`, `\mathbf{v}` → `𝐯`)
13
//! - Accents via combining marks (`\hat{x}` → `x̂`, `\vec{v}` → `v⃗`)
14
//! - Environments (`aligned`, `cases`, `pmatrix`, …) → multi-line layout
15
//!
16
//! The converter is total: it never panics and always produces *some* output
17
//! (unknown commands degrade to their bare name). Callers decide whether to
18
//! use the conversion or fall back to raw TeX source.
19
20
mod commands;
21
mod cursor;
22
mod environments;
23
mod math_box;
24
mod symbols;
25
26
#[cfg(test)]
27
mod tests;
28
29
use commands::render_sequence;
30
use cursor::Cursor;
31
use math_box::MathBox;
32
33
/// Inputs larger than this are not converted (callers fall back to raw
34
/// display). Guards the streaming hot path: the tail is re-rendered on every
35
/// chunk, so conversion cost must stay trivially small.
36
pub(crate) const MAX_MATH_SOURCE_LEN: usize = 4096;
37
38
/// Hard cap on group-nesting recursion. Inputs deeper than this render their
39
/// remaining content flatly rather than recursing further.
40
const MAX_DEPTH: usize = 32;
41
42
/// Convert inline math to a single-line Unicode string.
43
///
44
/// Row separators (`\\`) collapse to `; ` and multi-row environments render
45
/// single-row, so inline math never introduces a line break mid-paragraph.
46
/// Returns `None` when the source is too large to convert (see
47
/// [`MAX_MATH_SOURCE_LEN`]).
48
pub(crate) fn latex_to_unicode_inline(src: &str) -> Option<String> {
49
    if src.len() > MAX_MATH_SOURCE_LEN {
50
        return None;
51
    }
52
    let lines = convert(src, true);
53
    let joined = lines
54
        .iter()
55
        .map(|l| l.trim())
56
        .filter(|l| !l.is_empty())
57
        .collect::<Vec<_>>()
58
        .join("; ");
59
    Some(joined)
60
}
61
62
/// Convert display math to one or more Unicode lines.
63
///
64
/// Lines come from `\\` row separators and multi-row environments, which lay
65
/// out as 2D boxes anchored to the surrounding flow (see [`MathBox`]).
66
/// Leading whitespace is structural (box alignment) and preserved; only line
67
/// ends are trimmed. Returns `None` when the source is too large to convert,
68
/// and an empty `Vec` when the math has no visible content (callers should
69
/// fall back in both cases).
70
pub(crate) fn latex_to_unicode_display(src: &str) -> Option<Vec<String>> {
71
    if src.len() > MAX_MATH_SOURCE_LEN {
72
        return None;
73
    }
74
    let lines: Vec<String> = convert(src, false)
75
        .into_iter()
76
        .map(|l| l.trim_end().to_string())
77
        .filter(|l| !l.is_empty())
78
        .collect();
79
    Some(lines)
80
}
81
82
/// Run the converter and return the output lines.
83
fn convert(src: &str, flat: bool) -> Vec<String> {
84
    let mut cursor = Cursor::new(src);
85
    let mut out = MathBox::new(flat);
86
    render_sequence(&mut cursor, &mut out, 0, Mode::Math, None);
87
    out.into_lines()
88
}
89
90
/// Rendering mode: math mode applies typographic substitutions (`-` → `−`,
91
/// `'` → `′`) that text fragments (`\text{...}`) must not receive.
92
#[derive(Copy, Clone, PartialEq, Eq)]
93
enum Mode {
94
    Math,
95
    Text,
96
}
crates/coder-lite/src/markdown/latex/symbols.rs added +412

@@ -0,0 +1,412 @@

1
//! Character and symbol mapping tables.
2
3
pub(super) fn to_superscript(c: char) -> Option<char> {
4
    Some(match c {
5
        '0' => '⁰',
6
        '1' => '¹',
7
        '2' => '²',
8
        '3' => '³',
9
        '4' => '⁴',
10
        '5' => '⁵',
11
        '6' => '⁶',
12
        '7' => '⁷',
13
        '8' => '⁸',
14
        '9' => '⁹',
15
        '+' => '⁺',
16
        '-' | '−' => '⁻',
17
        '=' => '⁼',
18
        '(' => '⁽',
19
        ')' => '⁾',
20
        'a' => 'ᵃ',
21
        'b' => 'ᵇ',
22
        'c' => 'ᶜ',
23
        'd' => 'ᵈ',
24
        'e' => 'ᵉ',
25
        'f' => 'ᶠ',
26
        'g' => 'ᵍ',
27
        'h' => 'ʰ',
28
        'i' => 'ⁱ',
29
        'j' => 'ʲ',
30
        'k' => 'ᵏ',
31
        'l' => 'ˡ',
32
        'm' => 'ᵐ',
33
        'n' => 'ⁿ',
34
        'o' => 'ᵒ',
35
        'p' => 'ᵖ',
36
        'r' => 'ʳ',
37
        's' => 'ˢ',
38
        't' => 'ᵗ',
39
        'u' => 'ᵘ',
40
        'v' => 'ᵛ',
41
        'w' => 'ʷ',
42
        'x' => 'ˣ',
43
        'y' => 'ʸ',
44
        'z' => 'ᶻ',
45
        'T' => 'ᵀ',
46
        '∗' | '*' => '*',
47
        '′' | '\'' => '′',
48
        ' ' => ' ',
49
        _ => return None,
50
    })
51
}
52
53
pub(super) fn to_subscript(c: char) -> Option<char> {
54
    Some(match c {
55
        '0' => '₀',
56
        '1' => '₁',
57
        '2' => '₂',
58
        '3' => '₃',
59
        '4' => '₄',
60
        '5' => '₅',
61
        '6' => '₆',
62
        '7' => '₇',
63
        '8' => '₈',
64
        '9' => '₉',
65
        '+' => '₊',
66
        '-' | '−' => '₋',
67
        '=' => '₌',
68
        '(' => '₍',
69
        ')' => '₎',
70
        'a' => 'ₐ',
71
        'e' => 'ₑ',
72
        'h' => 'ₕ',
73
        'i' => 'ᵢ',
74
        'j' => 'ⱼ',
75
        'k' => 'ₖ',
76
        'l' => 'ₗ',
77
        'm' => 'ₘ',
78
        'n' => 'ₙ',
79
        'o' => 'ₒ',
80
        'p' => 'ₚ',
81
        'r' => 'ᵣ',
82
        's' => 'ₛ',
83
        't' => 'ₜ',
84
        'u' => 'ᵤ',
85
        'v' => 'ᵥ',
86
        'x' => 'ₓ',
87
        ' ' => ' ',
88
        _ => return None,
89
    })
90
}
91
92
pub(super) fn map_mathbb(c: char) -> Option<char> {
93
    Some(match c {
94
        'C' => 'ℂ',
95
        'H' => 'ℍ',
96
        'N' => 'ℕ',
97
        'P' => 'ℙ',
98
        'Q' => 'ℚ',
99
        'R' => 'ℝ',
100
        'Z' => 'ℤ',
101
        'A'..='Z' => char::from_u32(0x1D538 + (c as u32 - 'A' as u32))?,
102
        'a'..='z' => char::from_u32(0x1D552 + (c as u32 - 'a' as u32))?,
103
        '0'..='9' => char::from_u32(0x1D7D8 + (c as u32 - '0' as u32))?,
104
        _ => return None,
105
    })
106
}
107
108
pub(super) fn map_mathcal(c: char) -> Option<char> {
109
    Some(match c {
110
        'B' => 'ℬ',
111
        'E' => 'ℰ',
112
        'F' => 'ℱ',
113
        'H' => 'ℋ',
114
        'I' => 'ℐ',
115
        'L' => 'ℒ',
116
        'M' => 'ℳ',
117
        'R' => 'ℛ',
118
        'e' => 'ℯ',
119
        'g' => 'ℊ',
120
        'o' => 'ℴ',
121
        'A'..='Z' => char::from_u32(0x1D49C + (c as u32 - 'A' as u32))?,
122
        'a'..='z' => char::from_u32(0x1D4B6 + (c as u32 - 'a' as u32))?,
123
        _ => return None,
124
    })
125
}
126
127
pub(super) fn map_mathfrak(c: char) -> Option<char> {
128
    Some(match c {
129
        'C' => 'ℭ',
130
        'H' => 'ℌ',
131
        'I' => 'ℑ',
132
        'R' => 'ℜ',
133
        'Z' => 'ℨ',
134
        'A'..='Z' => char::from_u32(0x1D504 + (c as u32 - 'A' as u32))?,
135
        'a'..='z' => char::from_u32(0x1D51E + (c as u32 - 'a' as u32))?,
136
        _ => return None,
137
    })
138
}
139
140
pub(super) fn map_mathbf(c: char) -> Option<char> {
141
    Some(match c {
142
        'A'..='Z' => char::from_u32(0x1D400 + (c as u32 - 'A' as u32))?,
143
        'a'..='z' => char::from_u32(0x1D41A + (c as u32 - 'a' as u32))?,
144
        '0'..='9' => char::from_u32(0x1D7CE + (c as u32 - '0' as u32))?,
145
        _ => return None,
146
    })
147
}
148
149
/// Symbol command table (commands with no arguments).
150
pub(super) fn symbol(name: &str) -> Option<&'static str> {
151
    Some(match name {
152
        // Greek lowercase
153
        "alpha" => "α",
154
        "beta" => "β",
155
        "gamma" => "γ",
156
        "delta" => "δ",
157
        "epsilon" => "ϵ",
158
        "varepsilon" => "ε",
159
        "zeta" => "ζ",
160
        "eta" => "η",
161
        "theta" => "θ",
162
        "vartheta" => "ϑ",
163
        "iota" => "ι",
164
        "kappa" => "κ",
165
        "lambda" => "λ",
166
        "mu" => "μ",
167
        "nu" => "ν",
168
        "xi" => "ξ",
169
        "omicron" => "ο",
170
        "pi" => "π",
171
        "varpi" => "ϖ",
172
        "rho" => "ρ",
173
        "varrho" => "ϱ",
174
        "sigma" => "σ",
175
        "varsigma" => "ς",
176
        "tau" => "τ",
177
        "upsilon" => "υ",
178
        "phi" => "ϕ",
179
        "varphi" => "φ",
180
        "chi" => "χ",
181
        "psi" => "ψ",
182
        "omega" => "ω",
183
        // Greek uppercase
184
        "Gamma" => "Γ",
185
        "Delta" => "Δ",
186
        "Theta" => "Θ",
187
        "Lambda" => "Λ",
188
        "Xi" => "Ξ",
189
        "Pi" => "Π",
190
        "Sigma" => "Σ",
191
        "Upsilon" => "Υ",
192
        "Phi" => "Φ",
193
        "Psi" => "Ψ",
194
        "Omega" => "Ω",
195
        // Big operators
196
        "sum" => "∑",
197
        "prod" => "∏",
198
        "coprod" => "∐",
199
        "int" => "∫",
200
        "iint" => "∬",
201
        "iiint" => "∭",
202
        "oint" => "∮",
203
        "bigcup" => "⋃",
204
        "bigcap" => "⋂",
205
        "bigvee" => "⋁",
206
        "bigwedge" => "⋀",
207
        "bigoplus" => "⨁",
208
        "bigotimes" => "⨂",
209
        "bigodot" => "⨀",
210
        "biguplus" => "⨄",
211
        // Named operators (render as plain words)
212
        "lim" => "lim",
213
        "limsup" => "lim sup",
214
        "liminf" => "lim inf",
215
        "sin" => "sin",
216
        "cos" => "cos",
217
        "tan" => "tan",
218
        "cot" => "cot",
219
        "sec" => "sec",
220
        "csc" => "csc",
221
        "arcsin" => "arcsin",
222
        "arccos" => "arccos",
223
        "arctan" => "arctan",
224
        "sinh" => "sinh",
225
        "cosh" => "cosh",
226
        "tanh" => "tanh",
227
        "coth" => "coth",
228
        "log" => "log",
229
        "ln" => "ln",
230
        "lg" => "lg",
231
        "exp" => "exp",
232
        "max" => "max",
233
        "min" => "min",
234
        "sup" => "sup",
235
        "inf" => "inf",
236
        "det" => "det",
237
        "dim" => "dim",
238
        "ker" => "ker",
239
        "deg" => "deg",
240
        "arg" => "arg",
241
        "gcd" => "gcd",
242
        "hom" => "hom",
243
        "Pr" => "Pr",
244
        // Binary operators
245
        "times" => "×",
246
        "cdot" => "⋅",
247
        "div" => "÷",
248
        "pm" => "±",
249
        "mp" => "∓",
250
        "ast" => "∗",
251
        "star" => "⋆",
252
        "circ" => "∘",
253
        "bullet" => "•",
254
        "oplus" => "⊕",
255
        "ominus" => "⊖",
256
        "otimes" => "⊗",
257
        "oslash" => "⊘",
258
        "odot" => "⊙",
259
        "wedge" | "land" => "∧",
260
        "vee" | "lor" => "∨",
261
        "cap" => "∩",
262
        "cup" => "∪",
263
        "setminus" => "∖",
264
        "smallsetminus" => "∖",
265
        "uplus" => "⊎",
266
        "sqcap" => "⊓",
267
        "sqcup" => "⊔",
268
        "triangleleft" => "◁",
269
        "triangleright" => "▷",
270
        "wr" => "≀",
271
        "diamond" => "⋄",
272
        "dagger" => "†",
273
        "ddagger" => "‡",
274
        "amalg" => "⨿",
275
        // Relations
276
        "le" | "leq" | "leqslant" => "≤",
277
        "ge" | "geq" | "geqslant" => "≥",
278
        "ne" | "neq" => "≠",
279
        "ll" => "≪",
280
        "gg" => "≫",
281
        "approx" => "≈",
282
        "sim" => "∼",
283
        "simeq" => "≃",
284
        "cong" => "≅",
285
        "equiv" => "≡",
286
        "doteq" => "≐",
287
        "propto" => "∝",
288
        "prec" => "≺",
289
        "succ" => "≻",
290
        "preceq" => "⪯",
291
        "succeq" => "⪰",
292
        "asymp" => "≍",
293
        "in" => "∈",
294
        "ni" | "owns" => "∋",
295
        "notin" => "∉",
296
        "subset" => "⊂",
297
        "supset" => "⊃",
298
        "subseteq" => "⊆",
299
        "supseteq" => "⊇",
300
        "subsetneq" => "⊊",
301
        "supsetneq" => "⊋",
302
        "sqsubseteq" => "⊑",
303
        "sqsupseteq" => "⊒",
304
        "vdash" => "⊢",
305
        "dashv" => "⊣",
306
        "models" | "vDash" => "⊨",
307
        "perp" => "⊥",
308
        "parallel" => "∥",
309
        "nparallel" => "∦",
310
        "mid" => "∣",
311
        "nmid" => "∤",
312
        "smile" => "⌣",
313
        "frown" => "⌢",
314
        "bowtie" => "⋈",
315
        // Arrows
316
        "to" | "rightarrow" => "→",
317
        "leftarrow" | "gets" => "←",
318
        "leftrightarrow" => "↔",
319
        "Rightarrow" => "⇒",
320
        "Leftarrow" => "⇐",
321
        "Leftrightarrow" => "⇔",
322
        "implies" => "⟹",
323
        "impliedby" => "⟸",
324
        "iff" => "⟺",
325
        "longrightarrow" => "⟶",
326
        "longleftarrow" => "⟵",
327
        "longmapsto" => "⟼",
328
        "mapsto" => "↦",
329
        "uparrow" => "↑",
330
        "downarrow" => "↓",
331
        "updownarrow" => "↕",
332
        "Uparrow" => "⇑",
333
        "Downarrow" => "⇓",
334
        "nearrow" => "↗",
335
        "searrow" => "↘",
336
        "swarrow" => "↙",
337
        "nwarrow" => "↖",
338
        "hookrightarrow" => "↪",
339
        "hookleftarrow" => "↩",
340
        "rightharpoonup" => "⇀",
341
        "leftharpoonup" => "↼",
342
        "rightleftharpoons" => "⇌",
343
        // Logic / sets / misc letters
344
        "forall" => "∀",
345
        "exists" => "∃",
346
        "nexists" => "∄",
347
        "neg" | "lnot" => "¬",
348
        "emptyset" | "varnothing" => "∅",
349
        "infty" => "∞",
350
        "nabla" => "∇",
351
        "partial" => "∂",
352
        "hbar" => "ℏ",
353
        "ell" => "ℓ",
354
        "Re" => "ℜ",
355
        "Im" => "ℑ",
356
        "aleph" => "ℵ",
357
        "beth" => "ℶ",
358
        "wp" => "℘",
359
        "imath" => "ı",
360
        "jmath" => "ȷ",
361
        "top" => "⊤",
362
        "bot" => "⊥",
363
        "angle" => "∠",
364
        "measuredangle" => "∡",
365
        "triangle" => "△",
366
        "square" | "Box" => "□",
367
        "blacksquare" => "■",
368
        "diamondsuit" => "♦",
369
        "heartsuit" => "♥",
370
        "clubsuit" => "♣",
371
        "spadesuit" => "♠",
372
        "flat" => "♭",
373
        "natural" => "♮",
374
        "sharp" => "♯",
375
        "checkmark" => "✓",
376
        "degree" => "°",
377
        "prime" => "′",
378
        "dprime" => "″",
379
        "therefore" => "∴",
380
        "because" => "∵",
381
        "dots" | "ldots" | "dotsc" | "dotso" | "dotsb" | "dotsm" => "…",
382
        "cdots" => "⋯",
383
        "vdots" => "⋮",
384
        "ddots" => "⋱",
385
        "surd" => "√",
386
        "AA" => "Å",
387
        // Delimiters
388
        "langle" => "⟨",
389
        "rangle" => "⟩",
390
        "lceil" => "⌈",
391
        "rceil" => "⌉",
392
        "lfloor" => "⌊",
393
        "rfloor" => "⌋",
394
        "lbrace" => "{",
395
        "rbrace" => "}",
396
        "lbrack" => "[",
397
        "rbrack" => "]",
398
        "vert" => "|",
399
        "Vert" | "|" => "‖",
400
        "backslash" => "\\",
401
        "setbslash" => "∖",
402
        // Escaped literals
403
        "{" => "{",
404
        "}" => "}",
405
        "%" => "%",
406
        "$" => "$",
407
        "&" => "&",
408
        "#" => "#",
409
        "_" => "_",
410
        _ => return None,
411
    })
412
}
crates/coder-lite/src/markdown/latex/tests.rs added +370

@@ -0,0 +1,370 @@

1
use super::*;
2
3
fn inline(src: &str) -> String {
4
    latex_to_unicode_inline(src).expect("within size limit")
5
}
6
7
fn display(src: &str) -> Vec<String> {
8
    latex_to_unicode_display(src).expect("within size limit")
9
}
10
11
#[test]
12
fn plain_expression_passes_through() {
13
    assert_eq!(inline("E = mc"), "E = mc");
14
}
15
16
#[test]
17
fn superscripts_map_to_unicode() {
18
    assert_eq!(inline("E = mc^2"), "E = mc²");
19
    assert_eq!(inline("x^{10}"), "x¹⁰");
20
    assert_eq!(inline("e^{-x}"), "e⁻ˣ");
21
    assert_eq!(inline("x^T"), "xᵀ");
22
}
23
24
#[test]
25
fn subscripts_map_to_unicode() {
26
    assert_eq!(inline("a_1 + a_2"), "a₁ + a₂");
27
    assert_eq!(inline("x_{ij}"), "xᵢⱼ");
28
}
29
30
#[test]
31
fn script_fallback_uses_parens() {
32
    // φ has no superscript form → fall back to ^(...)
33
    assert_eq!(inline("x^{\\alpha\\beta}"), "x^(αβ)");
34
    assert_eq!(inline("x^\\alpha"), "x^α");
35
    // Single unmappable subscript char.
36
    assert_eq!(inline("a_q"), "a_q");
37
}
38
39
#[test]
40
fn wordlike_scripts_fall_back_to_parens() {
41
    // Text-family commands mark the atom as a word → no modifier-letter runs
42
    // (`pₜₒᵣₛₒ` is unreadable and gappy in many terminal fonts).
43
    assert_eq!(inline("p_{\\text{torso}}"), "p_(torso)");
44
    assert_eq!(inline("z_{\\mathrm{draft}}"), "z_(draft)");
45
    assert_eq!(inline("x^{\\text{opt}}"), "x^(opt)");
46
    // 3+ letter runs read as words even without \text.
47
    assert_eq!(inline("x_{max}"), "x_(max)");
48
    assert_eq!(inline("z_{torso}"), "z_(torso)");
49
}
50
51
#[test]
52
fn indexlike_scripts_keep_unicode_forms() {
53
    // 1–2 letter runs are index juxtapositions, not words.
54
    assert_eq!(inline("x_{ij}"), "xᵢⱼ");
55
    assert_eq!(inline("T_{i+1}"), "Tᵢ₊₁");
56
    assert_eq!(inline("n^{th}"), "nᵗʰ");
57
    assert_eq!(inline("\\sum_{i=0}^{2} \\gamma^{i}"), "∑ᵢ₌₀² γⁱ");
58
}
59
60
#[test]
61
fn boxed_renders_content_without_frame() {
62
    assert_eq!(inline("\\boxed{x = 1}"), "x = 1");
63
    assert_eq!(inline("\\boxed{\\mathcal{L}}"), "ℒ");
64
    assert_eq!(inline("\\fbox{done}"), "done");
65
    // Math typography applies inside \boxed (math mode) …
66
    assert_eq!(inline("\\boxed{a - b}"), "a − b");
67
    // … but not inside \fbox (text mode).
68
    assert_eq!(inline("\\fbox{a-b}"), "a-b");
69
}
70
71
#[test]
72
fn mtp_loss_equation_converts_fully() {
73
    // A complex real-world equation: every command must
74
    // convert — no literal command names in the output.
75
    let src = "\\boxed{\n\\mathcal{L}_{\\text{MTP}}\n=\n\\sum_{i=0}^{2}\n\\gamma^{i}\\,\n\\mathbb{E}_{\\text{positions, mask}}\n\\Big[\n\\mathrm{KL}\\big(\n  \\mathrm{softmax}(z_{\\text{torso}}^{(s_i)})\n  \\;\\big\\|\\;\n  \\mathrm{softmax}(z_{\\text{draft}}^{(i)})\n\\big)\n\\Big]\n}";
76
    let joined = inline(src);
77
    assert!(joined.contains("ℒ_(MTP)"), "got: {joined}");
78
    assert!(joined.contains("∑ᵢ₌₀²"), "got: {joined}");
79
    assert!(joined.contains("𝔼_(positions, mask)"), "got: {joined}");
80
    assert!(joined.contains("softmax(z_(torso)"), "got: {joined}");
81
    assert!(joined.contains("‖"), "got: {joined}");
82
    assert!(!joined.contains("boxed"), "got: {joined}");
83
    assert!(!joined.contains('\\'), "got: {joined}");
84
}
85
86
#[test]
87
fn greek_letters() {
88
    assert_eq!(inline("\\alpha + \\beta = \\Gamma"), "α + β = Γ");
89
    assert_eq!(inline("\\varepsilon \\varphi"), "ε φ");
90
}
91
92
#[test]
93
fn relations_and_operators() {
94
    assert_eq!(inline("a \\le b \\ne c \\times d"), "a ≤ b ≠ c × d");
95
    assert_eq!(inline("x \\in A \\cup B"), "x ∈ A ∪ B");
96
    assert_eq!(inline("p \\implies q"), "p ⟹ q");
97
    assert_eq!(inline("f: A \\to B"), "f: A → B");
98
}
99
100
#[test]
101
fn vulgar_and_general_fractions() {
102
    assert_eq!(inline("\\frac{1}{2}"), "½");
103
    assert_eq!(inline("\\frac{3}{4}"), "¾");
104
    assert_eq!(inline("\\frac{dy}{dx}"), "dy/dx");
105
    assert_eq!(inline("\\frac{a+b}{c}"), "(a+b)/c");
106
    assert_eq!(inline("\\frac{x}{y - z}"), "x/(y − z)");
107
}
108
109
#[test]
110
fn roots() {
111
    assert_eq!(inline("\\sqrt{x}"), "√x");
112
    assert_eq!(inline("\\sqrt{a + b}"), "√(a + b)");
113
    assert_eq!(inline("\\sqrt[3]{x}"), "∛x");
114
    assert_eq!(inline("\\sqrt[4]{x}"), "∜x");
115
    assert_eq!(inline("\\sqrt[n]{x}"), "ⁿ√x");
116
}
117
118
#[test]
119
fn text_commands_pass_content_through() {
120
    assert_eq!(inline("\\text{if } x > 0"), "if x > 0");
121
    assert_eq!(inline("\\mathrm{d}x"), "dx");
122
    assert_eq!(inline("\\operatorname{softmax}(z)"), "softmax(z)");
123
    // Text mode must not map `-` to minus.
124
    assert_eq!(inline("\\text{x-ray}"), "x-ray");
125
}
126
127
#[test]
128
fn alphabets() {
129
    assert_eq!(inline("\\mathbb{R}^n"), "ℝⁿ");
130
    assert_eq!(inline("\\mathbb{N} \\mathbb{Z} \\mathbb{Q}"), "ℕ ℤ ℚ");
131
    assert_eq!(inline("\\mathcal{L}"), "ℒ");
132
    assert_eq!(inline("\\mathcal{O}(n)"), "𝒪(n)");
133
    assert_eq!(inline("\\mathfrak{g}"), "𝔤");
134
    assert_eq!(inline("\\mathbf{v}"), "𝐯");
135
}
136
137
#[test]
138
fn accents_use_combining_marks() {
139
    assert_eq!(inline("\\hat{x}"), "x\u{0302}");
140
    assert_eq!(inline("\\bar{y}"), "y\u{0304}");
141
    assert_eq!(inline("\\vec{v}"), "v\u{20D7}");
142
    assert_eq!(inline("\\dot{q}"), "q\u{0307}");
143
    assert_eq!(inline("\\tilde\\theta"), "θ\u{0303}");
144
}
145
146
#[test]
147
fn left_right_and_spacing() {
148
    assert_eq!(inline("\\left( \\frac{1}{2} \\right)"), "( ½ )".to_string());
149
    assert_eq!(inline("\\left. x \\right|_0^1"), "x |₀¹");
150
    assert_eq!(inline("\\int f(x)\\,dx"), "∫ f(x) dx");
151
    assert_eq!(inline("a\\!b"), "ab");
152
    assert_eq!(inline("a \\quad b"), "a   b");
153
}
154
155
#[test]
156
fn named_function_operators() {
157
    assert_eq!(inline("\\sin(x) + \\cos(y)"), "sin(x) + cos(y)");
158
    assert_eq!(inline("\\lim_{x \\to 0} f(x)"), "lim_(x → 0) f(x)");
159
    assert_eq!(inline("\\log n"), "log n");
160
}
161
162
#[test]
163
fn integrals_and_sums_with_bounds() {
164
    assert_eq!(inline("\\int_0^\\infty e^{-x} dx"), "∫₀^∞ e⁻ˣ dx");
165
    assert_eq!(inline("\\sum_{i=1}^{n} a_i"), "∑ᵢ₌₁ⁿ aᵢ");
166
}
167
168
#[test]
169
fn minus_and_prime_typography() {
170
    assert_eq!(inline("a - b"), "a − b");
171
    assert_eq!(inline("f'(x)"), "f′(x)");
172
}
173
174
#[test]
175
fn not_negates_known_relations() {
176
    assert_eq!(inline("a \\not= b"), "a ≠ b");
177
    assert_eq!(inline("x \\not\\in S"), "x ∉ S");
178
    assert_eq!(inline("a \\not\\sim b"), "a ∼\u{0338} b");
179
}
180
181
#[test]
182
fn binomials_and_mod() {
183
    assert_eq!(inline("\\binom{n}{k}"), "C(n, k)");
184
    assert_eq!(inline("a \\equiv b \\pmod{m}"), "a ≡ b (mod m)");
185
    assert_eq!(inline("a \\bmod b"), "a mod b");
186
}
187
188
#[test]
189
fn row_breaks_join_inline_and_split_display() {
190
    assert_eq!(inline("a \\\\ b"), "a; b");
191
    assert_eq!(display("a \\\\ b"), vec!["a", "b"]);
192
}
193
194
#[test]
195
fn aligned_environment_strips_markers() {
196
    let lines = display("\\begin{aligned} x &= y + 1 \\\\ y &= 2 \\end{aligned}");
197
    assert_eq!(lines, vec!["x = y + 1", "y = 2"]);
198
}
199
200
#[test]
201
fn cases_environment_renders_brace_column() {
202
    let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & \\text{otherwise} \\end{cases}");
203
    assert_eq!(lines.len(), 2);
204
    assert!(lines[0].starts_with("f(x) = ⎧ x"), "got {lines:?}");
205
    assert!(lines[1].trim_start().starts_with("⎩ 0"), "got {lines:?}");
206
}
207
208
#[test]
209
fn pmatrix_pads_columns() {
210
    let lines = display("\\begin{pmatrix} 1 & 22 \\\\ 333 & 4 \\end{pmatrix}");
211
    assert_eq!(lines, vec!["⎛1    22⎞", "⎝333  4⎠"]);
212
}
213
214
#[test]
215
fn bmatrix_single_row_uses_flat_brackets() {
216
    assert_eq!(
217
        display("\\begin{bmatrix} a & b \\end{bmatrix}"),
218
        vec!["[a  b]"]
219
    );
220
}
221
222
#[test]
223
fn vmatrix_uses_bars() {
224
    let lines = display("\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}");
225
    assert_eq!(lines, vec!["│a  b│", "│c  d│"]);
226
}
227
228
#[test]
229
fn matrix_with_prefix_aligns_as_box() {
230
    // The prefix must stay on the anchor row with the matrix body
231
    // aligned beneath — not glued to the first row only.
232
    let lines = display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}");
233
    assert_eq!(lines, vec!["A = ⎛1  2⎞", "    ⎝3  4⎠"]);
234
}
235
236
#[test]
237
fn matrix_with_prefix_and_suffix_flows_on_anchor_row() {
238
    let lines =
239
        display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}, \\quad \\det(A) = -2");
240
    assert_eq!(lines, vec!["A = ⎛1  2⎞,   det(A) = −2", "    ⎝3  4⎠"]);
241
}
242
243
#[test]
244
fn three_row_matrix_anchors_on_middle_row() {
245
    let lines = display("v = \\begin{pmatrix} 1 \\\\ 2 \\\\ 3 \\end{pmatrix} x");
246
    assert_eq!(lines, vec!["    ⎛1⎞", "v = ⎜2⎟ x", "    ⎝3⎠"]);
247
}
248
249
#[test]
250
fn cases_with_prefix_aligns_as_box() {
251
    let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}");
252
    assert_eq!(lines, vec!["f(x) = ⎧ x  x > 0", "       ⎩ 0  e"]);
253
}
254
255
#[test]
256
fn inline_matrix_renders_flat() {
257
    assert_eq!(
258
        inline("\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}"),
259
        "(1  2; 3  4)"
260
    );
261
    assert_eq!(inline("\\begin{bmatrix} a \\\\ b \\end{bmatrix}"), "[a; b]");
262
}
263
264
#[test]
265
fn inline_cases_renders_flat() {
266
    assert_eq!(
267
        inline("\\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}"),
268
        "{x  x > 0; 0  e}"
269
    );
270
}
271
272
#[test]
273
fn two_matrices_on_one_line_share_rows() {
274
    let lines = display(
275
        "\\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix} + \\begin{pmatrix} 3 \\\\ 4 \\end{pmatrix}",
276
    );
277
    assert_eq!(lines, vec!["⎛1⎞ + ⎛3⎞", "⎝2⎠   ⎝4⎠"]);
278
}
279
280
#[test]
281
fn row_break_then_matrix_does_not_disturb_previous_line() {
282
    let lines = display("a \\\\ B = \\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix}");
283
    assert_eq!(lines, vec!["a", "B = ⎛1⎞", "    ⎝2⎠"]);
284
}
285
286
#[test]
287
fn unknown_environment_renders_rows() {
288
    let lines = display("\\begin{foo} a \\\\ b \\end{foo}");
289
    assert_eq!(lines, vec!["a", "b"]);
290
}
291
292
#[test]
293
fn nested_environment_resolves_matching_end() {
294
    let lines = display(
295
        "\\begin{aligned} A &= \\begin{pmatrix} 1 \\end{pmatrix} \\\\ B &= 2 \\end{aligned}",
296
    );
297
    assert_eq!(lines, vec!["A = (1)", "B = 2"]);
298
}
299
300
#[test]
301
fn unknown_commands_keep_their_name() {
302
    assert_eq!(inline("\\foobar x"), "foobar x");
303
}
304
305
#[test]
306
fn overset_and_stackrel() {
307
    assert_eq!(inline("a \\overset{!}{=} b"), "a = b");
308
    assert_eq!(inline("a \\overset{n}{=} b"), "a =ⁿ b");
309
}
310
311
#[test]
312
fn malformed_input_does_not_panic() {
313
    for src in [
314
        "",
315
        "{",
316
        "}",
317
        "\\",
318
        "\\frac{a}",
319
        "\\frac",
320
        "\\sqrt[",
321
        "\\begin{aligned} x",
322
        "\\begin",
323
        "\\end{x}",
324
        "^",
325
        "_",
326
        "^{",
327
        "a^",
328
        "{{{{{{",
329
        "\\left",
330
        "\\not",
331
        "$$$",
332
        "\\\\\\",
333
        "&&&&",
334
    ] {
335
        let _ = latex_to_unicode_inline(src);
336
        let _ = latex_to_unicode_display(src);
337
    }
338
}
339
340
#[test]
341
fn deeply_nested_input_is_bounded() {
342
    let mut src = String::new();
343
    for _ in 0..200 {
344
        src.push('{');
345
    }
346
    src.push('x');
347
    for _ in 0..200 {
348
        src.push('}');
349
    }
350
    let _ = latex_to_unicode_inline(&src);
351
}
352
353
#[test]
354
fn oversized_input_is_rejected() {
355
    let big = "x".repeat(MAX_MATH_SOURCE_LEN + 1);
356
    assert!(latex_to_unicode_inline(&big).is_none());
357
    assert!(latex_to_unicode_display(&big).is_none());
358
}
359
360
#[test]
361
fn whitespace_only_display_is_empty() {
362
    assert!(display("  \n  ").is_empty());
363
}
364
365
#[test]
366
fn escaped_literals() {
367
    assert_eq!(inline("100\\%"), "100%");
368
    assert_eq!(inline("\\{a, b\\}"), "{a, b}");
369
    assert_eq!(inline("\\$5"), "$5");
370
}
crates/coder-lite/src/markdown/latex_delimiters.rs added +1305

@@ -0,0 +1,1305 @@

1
//! Streaming normalization of LaTeX math delimiters into the canonical
2
//! `$...$` / `$$...$$` forms that `pulldown-cmark`'s math extension understands.
3
//!
4
//! Models overwhelmingly emit the backslash delimiter forms (`\(...\)`,
5
//! `\[...\]`) and sometimes `\begin{equation}...\end{equation}`. `pulldown-cmark`
6
//! only recognizes the `$` forms, so historically the backslash forms were
7
//! handled by bespoke post-parse source scanners — which were disabled inside
8
//! table cells (a bug). By rewriting every delimiter form into the
9
//! canonical `$`/`$$` form *before* parsing, the existing
10
//! `Event::InlineMath`/`Event::DisplayMath` handlers (which already convert math
11
//! in both prose and table cells) handle everything uniformly.
12
//!
13
//! # Transform set (applied only outside code, respecting escapes)
14
//!
15
//! | Input | Output |
16
//! |-------|--------|
17
//! | `\( … \)` | `$…$` (whitespace just inside the delimiters trimmed) |
18
//! | `\)` (unmatched) | `$` |
19
//! | `\[ … \]` / `$$ … $$` / `\begin{equation} … \end{equation}` | `$$…$$`, interior newlines joined |
20
//! | `\[` / `\]` (unmatched) | `$$` |
21
//! | `\begin{equation[*]}` / `\end{equation[*]}` (unmatched) | `$$` |
22
//!
23
//! Inline `\( … \)` is converted span-at-once: the matching unescaped `\)` is
24
//! located and the ASCII whitespace immediately inside the delimiters is
25
//! trimmed, so the emitted `$…$` has no space right after the opening `$` or
26
//! before the closing `$`. pulldown-cmark's dollar-math flanking rule rejects
27
//! `$ … $` (whitespace next to a delimiter) and would otherwise leave a padded
28
//! span as raw `$ … $` text. Interior newlines join to spaces (TeX treats them
29
//! as spaces) so a span wrapped across source lines cannot be re-parsed as
30
//! block structure.
31
//!
32
//! # Display spans are joined onto one line
33
//!
34
//! Every display-math opener — `\[`, `\begin{equation[*]}`, or a bare `$$` —
35
//! is resolved span-at-once: the matching close (`\]`, `\end{equation[*]}`, or
36
//! `$$`, whichever comes first) is located and the span is emitted as
37
//! `$$…$$` with each interior line trimmed and joined by a single space.
38
//! CommonMark gives *block* constructs priority over inline math, so a
39
//! multi-line `$$…$$` whose interior contains a line that looks like a block
40
//! start — a setext underline (`=`/`-` alone on a line), a `#`
41
//! heading, or a `-` list item — would otherwise be split into
42
//! heading/list/paragraph blocks and never reach the math parser. TeX treats
43
//! interior newlines as spaces, so joining is semantics-preserving (`\\` row
44
//! separators are untouched and still produce multi-line output downstream).
45
//!
46
//! The close-scan is bounded: it gives up (emitting the opener alone, exactly
47
//! the old behavior) past [`MAX_MATH_SOURCE_LEN`] look-ahead, at a blank line
48
//! (a paragraph break — two stray `$$` in prose must not fuse across
49
//! paragraphs), or at a line starting with `>` (blockquoted math carries `>`
50
//! markers that must not become span content; pulldown already handles the
51
//! quoted multi-line span after marker stripping).
52
//!
53
//! Bare single `$` is left untouched (so the pass is **idempotent**). Escaped
54
//! openers (`\\(`, `\\[`, `\$`) are left literal via backslash-pair consumption,
55
//! matching the old scanner's even/odd parity rule. Content inside inline code
56
//! spans and fenced code blocks is left verbatim (so LaTeX-in-backticks stays
57
//! raw, preserving prior behavior). Inner LaTeX environments such as
58
//! `\begin{aligned}` / `\begin{pmatrix}` are *not* touched — they live inside the
59
//! `$$...$$` span and are rendered by the LaTeX→Unicode converter.
60
//!
61
//! # Streaming
62
//!
63
//! [`LatexDelimiterNormalizer`] is fed chunks in order and is **chunk-split
64
//! invariant**: feeding the same total text produces the same output regardless
65
//! of where the chunk boundaries fall. It achieves this by holding back only a
66
//! bounded ambiguous suffix (a trailing `\`/`\begin{…` partial, a trailing
67
//! backtick/tilde run whose length isn't yet known, or an unclosed inline `\(`
68
//! whose `\)` has not arrived — bounded by the math size cap so an open that
69
//! never closes cannot stall the stream) until the next chunk, and by flushing
70
//! that suffix on [`finish`](LatexDelimiterNormalizer::finish).
71
//!
72
//! # Known divergences from CommonMark (bounded, documented)
73
//!
74
//! - 4-space *indented* code blocks are not treated as code (math inside them
75
//!   would convert). Rare in model output.
76
//! - Inline code spans are treated as single-line: an unterminated `` ` `` reverts
77
//!   to normal at the newline. This only changes behavior next to a stray,
78
//!   unmatched backtick.
79
//!
80
//! Streaming-vs-one-shot equivalence is pinned by an exhaustive byte-split test.
81
82
use crate::markdown::latex::MAX_MATH_SOURCE_LEN;
83
84
const ENV_BEGIN: &str = "\\begin{equation}";
85
const ENV_BEGIN_STARRED: &str = "\\begin{equation*}";
86
const ENV_END: &str = "\\end{equation}";
87
const ENV_END_STARRED: &str = "\\end{equation*}";
88
89
/// Longest environment token we special-case (`\begin{equation*}` = 17 bytes).
90
/// Bounds how many trailing bytes a `push` may hold back for a `\begin`/`\end`.
91
const ENV_TOKENS: [&str; 4] = [ENV_BEGIN, ENV_BEGIN_STARRED, ENV_END, ENV_END_STARRED];
92
93
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94
enum State {
95
    Normal,
96
    /// Inside an inline code span opened by a run of `run` backticks.
97
    InlineCode {
98
        run: usize,
99
    },
100
    /// Inside a fenced code block opened by `len` copies of `ch` (`` ` `` or `~`).
101
    Fenced {
102
        ch: u8,
103
        len: usize,
104
    },
105
}
106
107
/// Streaming, code-aware, escape-aware LaTeX delimiter normalizer.
108
///
109
/// Feed chunks via [`push`](Self::push) and call [`finish`](Self::finish) at end
110
/// of stream. For a complete string in hand, use [`normalize_latex_delimiters`].
111
#[derive(Debug, Clone)]
112
pub struct LatexDelimiterNormalizer {
113
    state: State,
114
    /// True when the next byte begins a new line (start of input counts).
115
    at_line_start: bool,
116
    /// Raw bytes held back from a previous `push` because they may be the prefix
117
    /// of a construct that needs more input to classify.
118
    pending: String,
119
}
120
121
impl Default for LatexDelimiterNormalizer {
122
    fn default() -> Self {
123
        Self::new()
124
    }
125
}
126
127
impl LatexDelimiterNormalizer {
128
    pub fn new() -> Self {
129
        Self {
130
            state: State::Normal,
131
            at_line_start: true,
132
            pending: String::new(),
133
        }
134
    }
135
136
    /// Reset to the initial state, dropping any held-back bytes.
137
    pub fn reset(&mut self) {
138
        self.state = State::Normal;
139
        self.at_line_start = true;
140
        self.pending.clear();
141
    }
142
143
    /// Push a raw chunk; returns the finalized normalized prefix.
144
    ///
145
    /// A bounded ambiguous suffix may be held back and emitted by a later
146
    /// `push` or by [`finish`](Self::finish).
147
    pub fn push(&mut self, chunk: &str) -> String {
148
        if chunk.is_empty() {
149
            return String::new();
150
        }
151
        let mut buf = std::mem::take(&mut self.pending);
152
        buf.push_str(chunk);
153
        let (out, consumed) = self.process(&buf, false);
154
        self.pending = buf[consumed..].to_string();
155
        out
156
    }
157
158
    /// Flush any held-back bytes as literal (end of stream).
159
    pub fn finish(&mut self) -> String {
160
        let buf = std::mem::take(&mut self.pending);
161
        if buf.is_empty() {
162
            return String::new();
163
        }
164
        let (out, consumed) = self.process(&buf, true);
165
        debug_assert_eq!(consumed, buf.len(), "final flush must consume all input");
166
        out
167
    }
168
169
    /// Process `buf` from the start, advancing internal state. Returns the
170
    /// emitted text and the number of bytes consumed; bytes `[consumed..]` are
171
    /// the held-back ambiguous suffix (always empty when `final_flush`).
172
    fn process(&mut self, buf: &str, final_flush: bool) -> (String, usize) {
173
        let bytes = buf.as_bytes();
174
        let n = bytes.len();
175
        let mut out = String::with_capacity(n + 8);
176
        let mut i = 0;
177
        while i < n {
178
            match self.state {
179
                State::Normal => {
180
                    if self.at_line_start {
181
                        match scan_fence_open(bytes, i, final_flush) {
182
                            FenceScan::NeedMore => break,
183
                            FenceScan::Match { ch, len, end } => {
184
                                out.push_str(&buf[i..end]);
185
                                i = end;
186
                                self.state = State::Fenced { ch, len };
187
                                self.at_line_start = false;
188
                                continue;
189
                            }
190
                            FenceScan::No => {}
191
                        }
192
                    }
193
                    match bytes[i] {
194
                        b'\n' => {
195
                            out.push('\n');
196
                            i += 1;
197
                            self.at_line_start = true;
198
                        }
199
                        b'`' => {
200
                            let run = count_run(bytes, i, b'`');
201
                            if i + run == n && !final_flush {
202
                                break; // run may extend; hold it back
203
                            }
204
                            out.push_str(&buf[i..i + run]);
205
                            i += run;
206
                            self.state = State::InlineCode { run };
207
                            self.at_line_start = false;
208
                        }
209
                        b'\\' => {
210
                            // Every non-`break` arm below advances past a delimiter
211
                            // mid-line, so `at_line_start` is cleared once here; the
212
                            // `break`s (hold-backs) skip it and preserve it for
213
                            // the retry, making that invariant structural.
214
                            match classify_backslash(buf, i, final_flush) {
215
                                Bs::NeedMore => break,
216
                                Bs::InlineOpen => match find_inline_close(bytes, i, final_flush) {
217
                                    InlineClose::Found { close } => {
218
                                        // Trim pulldown's flanking whitespace so `$…$` is
219
                                        // accepted; the custom set (vs `char::is_ascii_whitespace`)
220
                                        // exists only to add vertical tab (0x0B).
221
                                        let inner = buf[i + 2..close]
222
                                            .trim_matches(|c: char| matches!(c, ' ' | '\t'..='\r'));
223
                                        if inner.is_empty() {
224
                                            // Empty after trim: a lone `$` keeps the old
225
                                            // position-for-position output (`$<ws>$`, or
226
                                            // `$$` when the interior is truly empty).
227
                                            out.push('$');
228
                                            i += 2;
229
                                        } else {
230
                                            // Join interior newlines: a `$…$` wrapped
231
                                            // across source lines would otherwise be
232
                                            // vulnerable to block re-parsing (setext
233
                                            // underlines, list markers).
234
                                            out.push('$');
235
                                            push_joined_lines(&mut out, inner);
236
                                            out.push('$');
237
                                            i = close + 2;
238
                                        }
239
                                    }
240
                                    // Too far, or unclosed at EOF: lone `$`, no trim.
241
                                    InlineClose::Unmatched => {
242
                                        out.push('$');
243
                                        i += 2;
244
                                    }
245
                                    // Hold back from `\(` until the `\)` arrives.
246
                                    InlineClose::NeedMore => break,
247
                                },
248
                                Bs::DisplayOpen { len } => {
249
                                    match find_display_close(buf, i + len, final_flush) {
250
                                        DisplayClose::Found { close, close_len } => {
251
                                            emit_display_span(&mut out, &buf[i + len..close]);
252
                                            i = close + close_len;
253
                                        }
254
                                        // No close in reach: emit the canonical opener
255
                                        // alone (old position-for-position behavior)
256
                                        // and process the interior normally.
257
                                        DisplayClose::Unmatched => {
258
                                            out.push_str("$$");
259
                                            i += len;
260
                                        }
261
                                        // Hold back from the opener until the close
262
                                        // (or an abort condition) arrives.
263
                                        DisplayClose::NeedMore => break,
264
                                    }
265
                                }
266
                                Bs::Convert { to, len } => {
267
                                    out.push_str(to);
268
                                    i += len;
269
                                }
270
                                Bs::Literal { len } => {
271
                                    out.push_str(&buf[i..i + len]);
272
                                    i += len;
273
                                }
274
                            }
275
                            self.at_line_start = false;
276
                        }
277
                        b'$' => {
278
                            let run = count_run(bytes, i, b'$');
279
                            if run == 1 && i + 1 == n && !final_flush {
280
                                break; // may become `$$`; hold it back
281
                            }
282
                            if run >= 2 {
283
                                // A display opener is exactly two `$`; any further
284
                                // `$`s are span content for the close-scan. Consuming
285
                                // two (not the whole run) keeps emitted spans fixed
286
                                // points: output like `$` + `$$…$$` re-tokenizes to
287
                                // the same bytes on a second pass (idempotency).
288
                                match find_display_close(buf, i + 2, final_flush) {
289
                                    DisplayClose::Found { close, close_len } => {
290
                                        emit_display_span(&mut out, &buf[i + 2..close]);
291
                                        i = close + close_len;
292
                                    }
293
                                    // No close in reach: `$$` stays literal (pulldown
294
                                    // decides), interior is processed normally.
295
                                    DisplayClose::Unmatched => {
296
                                        out.push_str("$$");
297
                                        i += 2;
298
                                    }
299
                                    DisplayClose::NeedMore => break,
300
                                }
301
                            } else {
302
                                // Single `$` (inline math / currency) passes through
303
                                // verbatim; pulldown handles it.
304
                                out.push('$');
305
                                i += 1;
306
                            }
307
                            self.at_line_start = false;
308
                        }
309
                        _ => {
310
                            // Copy a run of ordinary bytes up to the next
311
                            // interesting ASCII byte. Multibyte UTF-8 bytes
312
                            // (>= 0x80) never equal the ASCII delimiters, so
313
                            // they are copied whole and slices stay valid.
314
                            let start = i;
315
                            while i < n && !matches!(bytes[i], b'\n' | b'`' | b'\\' | b'$') {
316
                                i += 1;
317
                            }
318
                            out.push_str(&buf[start..i]);
319
                            self.at_line_start = false;
320
                        }
321
                    }
322
                }
323
                State::InlineCode { run } => {
324
                    // Single-line span: copy verbatim until a matching-length
325
                    // backtick run closes it, the line ends (unterminated → revert
326
                    // to Normal so later math still converts), or EOF.
327
                    let start = i;
328
                    let mut handled = false;
329
                    while i < n {
330
                        match bytes[i] {
331
                            b'\n' => {
332
                                i += 1;
333
                                out.push_str(&buf[start..i]);
334
                                self.state = State::Normal;
335
                                self.at_line_start = true;
336
                                handled = true;
337
                                break;
338
                            }
339
                            b'`' => {
340
                                let r = count_run(bytes, i, b'`');
341
                                if i + r == n && !final_flush {
342
                                    out.push_str(&buf[start..i]);
343
                                    return (out, i); // hold back the trailing run
344
                                }
345
                                if r == run {
346
                                    i += r;
347
                                    out.push_str(&buf[start..i]);
348
                                    self.state = State::Normal;
349
                                    self.at_line_start = false;
350
                                    handled = true;
351
                                    break;
352
                                }
353
                                i += r; // non-matching run is literal content
354
                            }
355
                            _ => i += 1,
356
                        }
357
                    }
358
                    if !handled {
359
                        out.push_str(&buf[start..i]); // EOF inside code
360
                    }
361
                }
362
                State::Fenced { ch, len } => {
363
                    if self.at_line_start {
364
                        match scan_fence_close(bytes, i, ch, len, final_flush) {
365
                            FenceScan::NeedMore => break,
366
                            FenceScan::Match { end, .. } => {
367
                                out.push_str(&buf[i..end]);
368
                                i = end;
369
                                self.state = State::Normal;
370
                                self.at_line_start = false;
371
                                continue;
372
                            }
373
                            FenceScan::No => {}
374
                        }
375
                    }
376
                    // Copy the rest of this line verbatim (fenced content).
377
                    let start = i;
378
                    while i < n && bytes[i] != b'\n' {
379
                        i += 1;
380
                    }
381
                    if i < n {
382
                        i += 1; // include the newline
383
                        self.at_line_start = true;
384
                    } else {
385
                        self.at_line_start = false;
386
                    }
387
                    out.push_str(&buf[start..i]);
388
                }
389
            }
390
        }
391
        (out, i)
392
    }
393
}
394
395
/// One-shot normalization == `push(s)` + `finish()`. Used by batch render
396
/// entries and tests.
397
pub fn normalize_latex_delimiters(s: &str) -> String {
398
    let mut nz = LatexDelimiterNormalizer::new();
399
    let mut out = nz.push(s);
400
    out.push_str(&nz.finish());
401
    out
402
}
403
404
fn count_run(bytes: &[u8], start: usize, ch: u8) -> usize {
405
    let mut j = start;
406
    while j < bytes.len() && bytes[j] == ch {
407
        j += 1;
408
    }
409
    j - start
410
}
411
412
/// Result of scanning for a fence open/close marker at a line start.
413
enum FenceScan {
414
    /// Marker found; `end` is the index just past the run of fence chars.
415
    Match { ch: u8, len: usize, end: usize },
416
    /// Definitely not a fence marker here.
417
    No,
418
    /// Not enough input to decide; caller should hold back from the line start.
419
    NeedMore,
420
}
421
422
/// Scan for an opening fence (`` ``` `` / `~~~`, length >= 3) at line start,
423
/// allowing up to 3 leading spaces. An info string may follow the run.
424
fn scan_fence_open(bytes: &[u8], i: usize, final_flush: bool) -> FenceScan {
425
    let n = bytes.len();
426
    let mut j = i;
427
    let mut spaces = 0;
428
    while j < n && bytes[j] == b' ' && spaces < 4 {
429
        spaces += 1;
430
        j += 1;
431
    }
432
    if spaces >= 4 {
433
        return FenceScan::No; // indented; not treated as a fence opener
434
    }
435
    if j == n {
436
        return if final_flush {
437
            FenceScan::No
438
        } else {
439
            FenceScan::NeedMore // ≤3 spaces then EOF: a fence may still start
440
        };
441
    }
442
    let ch = bytes[j];
443
    if ch != b'`' && ch != b'~' {
444
        return FenceScan::No;
445
    }
446
    let run = count_run(bytes, j, ch);
447
    if j + run == n && !final_flush {
448
        return FenceScan::NeedMore; // run may extend
449
    }
450
    if run < 3 {
451
        return FenceScan::No; // inline code / stray tildes, not a fence
452
    }
453
    FenceScan::Match {
454
        ch,
455
        len: run,
456
        end: j + run,
457
    }
458
}
459
460
/// Scan for a closing fence at line start: up to 3 spaces, a run of `ch` with
461
/// length >= `len`, then only whitespace to end of line.
462
fn scan_fence_close(bytes: &[u8], i: usize, ch: u8, len: usize, final_flush: bool) -> FenceScan {
463
    let n = bytes.len();
464
    let mut j = i;
465
    let mut spaces = 0;
466
    while j < n && bytes[j] == b' ' && spaces < 4 {
467
        spaces += 1;
468
        j += 1;
469
    }
470
    if spaces >= 4 {
471
        return FenceScan::No;
472
    }
473
    if j == n {
474
        return if final_flush {
475
            FenceScan::No
476
        } else {
477
            FenceScan::NeedMore
478
        };
479
    }
480
    if bytes[j] != ch {
481
        return FenceScan::No;
482
    }
483
    let run = count_run(bytes, j, ch);
484
    if j + run == n && !final_flush {
485
        return FenceScan::NeedMore; // run may still grow to >= len
486
    }
487
    if run < len {
488
        return FenceScan::No;
489
    }
490
    // A close line carries no info string: only trailing whitespace allowed.
491
    let mut k = j + run;
492
    while k < n && matches!(bytes[k], b' ' | b'\t') {
493
        k += 1;
494
    }
495
    if k == n {
496
        return if final_flush {
497
            FenceScan::Match {
498
                ch,
499
                len: run,
500
                end: j + run,
501
            }
502
        } else {
503
            FenceScan::NeedMore
504
        };
505
    }
506
    if bytes[k] == b'\n' {
507
        FenceScan::Match {
508
            ch,
509
            len: run,
510
            end: j + run,
511
        }
512
    } else {
513
        FenceScan::No // non-whitespace after the run → info string → content
514
    }
515
}
516
517
/// Classification of a backslash sequence starting at `i` (where `bytes[i]` is
518
/// `\`).
519
enum Bs {
520
    /// Replace `buf[i..i+len]` with `to`.
521
    Convert { to: &'static str, len: usize },
522
    /// An inline math open `\(`: the caller locates the matching `\)` and emits
523
    /// a whitespace-trimmed `$…$` span (see [`find_inline_close`]).
524
    InlineOpen,
525
    /// A display math open (`\[` or `\begin{equation[*]}`, `len` bytes): the
526
    /// caller locates the matching close and emits a line-joined `$$…$$` span
527
    /// (see [`find_display_close`]).
528
    DisplayOpen { len: usize },
529
    /// Emit `buf[i..i+len]` verbatim (consumes the sequence so escape parity
530
    /// holds; e.g. `\\` is consumed as a pair).
531
    Literal { len: usize },
532
    /// Not enough input to classify; hold back from `i`.
533
    NeedMore,
534
}
535
536
fn classify_backslash(buf: &str, i: usize, final_flush: bool) -> Bs {
537
    let bytes = buf.as_bytes();
538
    let n = bytes.len();
539
    debug_assert_eq!(bytes[i], b'\\');
540
    if i + 1 >= n {
541
        return if final_flush {
542
            Bs::Literal { len: 1 }
543
        } else {
544
            Bs::NeedMore
545
        };
546
    }
547
    match bytes[i + 1] {
548
        // Escaped backslash: emit the pair so a following `(`/`[` is not read as
549
        // a delimiter (this is the even/odd parity rule, applied incrementally).
550
        b'\\' => Bs::Literal { len: 2 },
551
        // Inline open: the caller scans for the matching `\)` to emit a
552
        // whitespace-trimmed `$…$`. A lone `\)` (unmatched close) still maps to
553
        // `$` position-for-position.
554
        b'(' => Bs::InlineOpen,
555
        b')' => Bs::Convert { to: "$", len: 2 },
556
        // Display open: the caller scans for the matching close to emit a
557
        // line-joined `$$…$$`. A lone `\]` (unmatched close) still maps to
558
        // `$$` position-for-position.
559
        b'[' => Bs::DisplayOpen { len: 2 },
560
        b']' => Bs::Convert { to: "$$", len: 2 },
561
        b'b' | b'e' => match match_env(buf, i, final_flush) {
562
            // `\begin{equation[*]}` opens a display span; a stray
563
            // `\end{equation[*]}` still maps to `$$` position-for-position.
564
            EnvScan::Convert(len) => {
565
                if bytes[i + 1] == b'b' {
566
                    Bs::DisplayOpen { len }
567
                } else {
568
                    Bs::Convert { to: "$$", len }
569
                }
570
            }
571
            EnvScan::NeedMore => Bs::NeedMore,
572
            // Not one of our envs (e.g. `\begin{aligned}`): emit just the `\` and
573
            // let the rest be copied as ordinary text (verbatim).
574
            EnvScan::No => Bs::Literal { len: 1 },
575
        },
576
        // `\$`, `\x`, etc: emit the `\`, process the next char normally.
577
        _ => Bs::Literal { len: 1 },
578
    }
579
}
580
581
/// Outcome of scanning an inline `\(` span for its matching close.
582
enum InlineClose {
583
    /// Unescaped `\)` found; `close` is the byte index of its backslash.
584
    Found { close: usize },
585
    /// No usable close: either none within the look-ahead cap, or the open is
586
    /// still unclosed at end of stream. The caller emits a lone `$` (no trim),
587
    /// reproducing the old position-for-position behavior.
588
    Unmatched,
589
    /// Buffer ends within the cap without a close and more input may still
590
    /// arrive; the caller holds back from the open until the `\)` shows up.
591
    NeedMore,
592
}
593
594
/// Scan for the unescaped `\)` closing an inline `\(` at `open`
595
/// (`bytes[open..open + 2] == b"\\("`). The inner length is bounded by
596
/// [`MAX_MATH_SOURCE_LEN`] (the converter's own input cap) so an unclosed `\(`
597
/// cannot stall the stream; the bound is a distance relative to `open`, so the
598
/// Found/Unmatched decision is the same whether the input arrives whole or
599
/// split. Mirrors [`classify_backslash`]'s `final_flush`: at end of stream an
600
/// unfound close resolves to `Unmatched` instead of `NeedMore`.
601
///
602
/// Backslash parity matches [`classify_backslash`]: `\\` is an escaped pair
603
/// (its following byte is literal), a lone `\)` is the close, and any other
604
/// `\x` consumes both bytes as span content.
605
fn find_inline_close(bytes: &[u8], open: usize, final_flush: bool) -> InlineClose {
606
    debug_assert!(
607
        bytes.get(open) == Some(&b'\\') && bytes.get(open + 1) == Some(&b'('),
608
        "find_inline_close must start at a `\\(`"
609
    );
610
    let n = bytes.len();
611
    let mut k = open + 2;
612
    while k < n {
613
        // A close at `k` would give inner `buf[open + 2..k]`; stop once that
614
        // would exceed what `latex_to_unicode_inline` accepts.
615
        if k - (open + 2) > MAX_MATH_SOURCE_LEN {
616
            return InlineClose::Unmatched;
617
        }
618
        if bytes[k] == b'\\' {
619
            match bytes.get(k + 1) {
620
                None => break, // trailing `\`: need the next byte to classify
621
                Some(b')') => return InlineClose::Found { close: k },
622
                Some(_) => k += 2, // `\\` pair or `\x` escape: skip both bytes
623
            }
624
        } else {
625
            k += 1;
626
        }
627
    }
628
    // End of buffer within the cap (or a trailing `\`): unclosed at EOF emits a
629
    // lone `$`; otherwise hold back for more input.
630
    if final_flush {
631
        InlineClose::Unmatched
632
    } else {
633
        InlineClose::NeedMore
634
    }
635
}
636
637
/// Outcome of scanning a display span (opened by `\[`, `$$`, or
638
/// `\begin{equation[*]}`) for its close.
639
enum DisplayClose {
640
    /// Close token found; `close` is its byte index, `close_len` its length.
641
    Found { close: usize, close_len: usize },
642
    /// No usable close: past the look-ahead cap, aborted at a blank line or a
643
    /// blockquote marker, or unclosed at end of stream. The caller emits the
644
    /// canonical `$$` opener alone (the old position-for-position behavior).
645
    Unmatched,
646
    /// Buffer ends without a decision and more input may still arrive; the
647
    /// caller holds back from the opener.
648
    NeedMore,
649
}
650
651
/// Scan for the token closing a display span whose content starts at
652
/// `content_start`. Any display close token counts — `\]`, `$$`, or
653
/// `\end{equation[*]}` — matching the pre-existing behavior where mismatched
654
/// opener/close pairs (e.g. `\[ … $$`) still formed a span because every
655
/// delimiter normalized to `$$` independently.
656
///
657
/// The scan is bounded by [`MAX_MATH_SOURCE_LEN`] relative to `content_start`
658
/// (so the Found/Unmatched decision is split-invariant) and aborts — leaving
659
/// the source for normal processing — at:
660
///
661
/// - a blank line: a paragraph break means the opener was almost certainly not
662
///   math (e.g. `$$` used as prose), and two stray `$$` must not fuse across
663
///   paragraphs;
664
/// - a line starting with `>`: blockquoted display math carries `>` markers
665
///   that would otherwise be joined into the span as literal content
666
///   (pulldown handles the quoted multi-line span itself after stripping the
667
///   markers).
668
///
669
/// Backslash parity matches [`find_inline_close`]: `\\` and other `\x` pairs
670
/// are span content, consumed two bytes at a time.
671
fn find_display_close(buf: &str, content_start: usize, final_flush: bool) -> DisplayClose {
672
    let bytes = buf.as_bytes();
673
    let n = bytes.len();
674
    let mut k = content_start;
675
    while k < n {
676
        if k - content_start > MAX_MATH_SOURCE_LEN {
677
            return DisplayClose::Unmatched;
678
        }
679
        match bytes[k] {
680
            b'\\' => match bytes.get(k + 1) {
681
                None => break, // trailing `\`: need the next byte to classify
682
                Some(b']') => {
683
                    return DisplayClose::Found {
684
                        close: k,
685
                        close_len: 2,
686
                    };
687
                }
688
                Some(b'e') => {
689
                    // `\end{equation}` / `\end{equation*}` closes the span.
690
                    let rest = &buf[k..];
691
                    let mut matched = None;
692
                    let mut could_extend = false;
693
                    for tok in [ENV_END, ENV_END_STARRED] {
694
                        if rest.len() >= tok.len() {
695
                            if rest.starts_with(tok) {
696
                                matched =
697
                                    Some(matched.map_or(tok.len(), |m: usize| m.max(tok.len())));
698
                            }
699
                        } else if tok.starts_with(rest) {
700
                            could_extend = true;
701
                        }
702
                    }
703
                    if let Some(close_len) = matched {
704
                        return DisplayClose::Found {
705
                            close: k,
706
                            close_len,
707
                        };
708
                    }
709
                    if could_extend && !final_flush {
710
                        return DisplayClose::NeedMore;
711
                    }
712
                    k += 2; // `\e…` of something else: span content
713
                }
714
                Some(_) => k += 2, // `\\` pair or `\x` escape: span content
715
            },
716
            b'$' => {
717
                let run = count_run(bytes, k, b'$');
718
                if run >= 2 {
719
                    return DisplayClose::Found {
720
                        close: k,
721
                        close_len: 2,
722
                    };
723
                }
724
                if k + run == n && !final_flush {
725
                    return DisplayClose::NeedMore; // lone `$` at EOB may extend
726
                }
727
                k += run;
728
            }
729
            b'\n' => {
730
                // Look at the next line's start: blank line or `>` marker
731
                // aborts the span (see doc comment).
732
                let mut j = k + 1;
733
                while j < n && matches!(bytes[j], b' ' | b'\t') {
734
                    j += 1;
735
                }
736
                if j == n {
737
                    break; // need the next line's first byte to decide
738
                }
739
                if matches!(bytes[j], b'\n' | b'>') {
740
                    return DisplayClose::Unmatched;
741
                }
742
                k += 1;
743
            }
744
            _ => k += 1,
745
        }
746
    }
747
    if final_flush {
748
        DisplayClose::Unmatched
749
    } else {
750
        DisplayClose::NeedMore
751
    }
752
}
753
754
/// Emit `interior` as a canonical `$$…$$` span, joining interior lines.
755
///
756
/// Single-line interiors are emitted verbatim (bare `$$…$$` input passes
757
/// through byte-for-byte, keeping the pass idempotent). Multi-line interiors
758
/// have each line trimmed and joined with a single space so CommonMark block
759
/// parsing (setext underlines, list items, headings) cannot split the span;
760
/// TeX treats the newlines as spaces, so rendering is unchanged.
761
fn emit_display_span(out: &mut String, interior: &str) {
762
    out.push_str("$$");
763
    push_joined_lines(out, interior);
764
    out.push_str("$$");
765
}
766
767
/// Push `text` onto `out`; if it spans multiple lines, trim each line and
768
/// join the non-empty ones with single spaces (single-line text is verbatim).
769
fn push_joined_lines(out: &mut String, text: &str) {
770
    if !text.contains('\n') {
771
        out.push_str(text);
772
        return;
773
    }
774
    let mut first = true;
775
    for line in text.lines() {
776
        let trimmed = line.trim_matches([' ', '\t', '\r']);
777
        if trimmed.is_empty() {
778
            continue;
779
        }
780
        if !first {
781
            out.push(' ');
782
        }
783
        out.push_str(trimmed);
784
        first = false;
785
    }
786
}
787
788
enum EnvScan {
789
    Convert(usize),
790
    No,
791
    NeedMore,
792
}
793
794
/// Match `\begin{equation}` / `\end{equation}` (and starred variants) at `i`.
795
fn match_env(buf: &str, i: usize, final_flush: bool) -> EnvScan {
796
    let rest = &buf[i..];
797
    let mut best: Option<usize> = None;
798
    let mut could_extend = false;
799
    for tok in ENV_TOKENS {
800
        if rest.len() >= tok.len() {
801
            if rest.starts_with(tok) {
802
                best = Some(best.map_or(tok.len(), |b: usize| b.max(tok.len())));
803
            }
804
        } else if tok.starts_with(rest) {
805
            could_extend = true;
806
        }
807
    }
808
    if let Some(len) = best {
809
        // `\begin{equation}` is not a prefix of `\begin{equation*}` (char 16 is
810
        // `}` vs `*`), so the longest full match is unambiguous.
811
        return EnvScan::Convert(len);
812
    }
813
    if could_extend && !final_flush {
814
        return EnvScan::NeedMore;
815
    }
816
    EnvScan::No
817
}
818
819
#[cfg(test)]
820
mod tests {
821
    use super::*;
822
    use pretty_assertions::assert_eq;
823
824
    fn norm(s: &str) -> String {
825
        normalize_latex_delimiters(s)
826
    }
827
828
    // ── Basic conversions ────────────────────────────────────────────────
829
830
    #[test]
831
    fn inline_paren_converts() {
832
        assert_eq!(norm("\\(x^2\\)"), "$x^2$");
833
        assert_eq!(norm("a \\(x\\) b"), "a $x$ b");
834
    }
835
836
    // ── Inline `\( … \)` boundary-whitespace trimming (the regression) ────
837
838
    #[test]
839
    fn normalize_inline_paren_trims_boundary_ws() {
840
        // Padding on both flanks is stripped so pulldown's dollar-math flanking
841
        // rule accepts the emitted `$…$`.
842
        assert_eq!(norm("a \\( x+y \\) b"), "a $x+y$ b");
843
        // One-sided padding.
844
        assert_eq!(norm("\\(x \\)"), "$x$");
845
        assert_eq!(norm("\\( x\\)"), "$x$");
846
        // Multiple spaces / tabs collapse away at the boundaries only.
847
        assert_eq!(norm("\\(   x+y   \\)"), "$x+y$");
848
        assert_eq!(norm("\\(\tx\t\\)"), "$x$");
849
        // VT (0x0B) is the one flanking-whitespace char `char::is_ascii_whitespace`
850
        // omits, so this pins the custom trim predicate against a regression to std.
851
        assert_eq!(norm("\\(\u{0b}x\u{0b}\\)"), "$x$");
852
        // Interior whitespace and inner escaped braces are preserved.
853
        assert_eq!(norm("\\( a + b \\)"), "$a + b$");
854
        assert_eq!(norm("\\( \\{x\\} \\)"), "$\\{x\\}$");
855
    }
856
857
    #[test]
858
    fn normalize_inline_paren_trim_leaves_escapes_and_dollars_alone() {
859
        // Escaped `\\(`/`\\)` is a literal backslash + paren, not a math span.
860
        assert_eq!(norm("\\\\( x \\\\)"), "\\\\( x \\\\)");
861
        // Only the backslash forms are ours: a space-padded bare `$ x $` is NOT
862
        // trimmed (currency untouched-ness is covered by `currency_not_misconverted`).
863
        assert_eq!(norm("$ x $"), "$ x $");
864
    }
865
866
    #[test]
867
    fn normalize_inline_paren_empty_span_degrades_position_for_position() {
868
        // A whitespace-only span keeps its interior between two lone `$` (the
869
        // old position-for-position form) rather than trimming to a `$$` opener.
870
        assert_eq!(norm("\\( \\)"), "$ $");
871
        assert_eq!(norm("\\(   \\)"), "$   $");
872
        // A truly-empty `\(\)` has no interior to separate the `$`, so it still
873
        // collapses to `$$` — matching the pre-fix behavior (pinned, not a goal).
874
        assert_eq!(norm("\\(\\)"), "$$");
875
    }
876
877
    #[test]
878
    fn display_bracket_converts() {
879
        assert_eq!(norm("\\[x^2\\]"), "$$x^2$$");
880
        assert_eq!(norm("a\n\\[x\\]\nb"), "a\n$$x$$\nb");
881
    }
882
883
    // ── Multi-line display spans join onto one line ──────────────────────
884
885
    #[test]
886
    fn multiline_display_with_setext_hazard_joins() {
887
        // A lone `=` line inside a display span is a CommonMark setext
888
        // underline: unjoined, pulldown parses a heading and the math is
889
        // never seen (the raw-LaTeX bug).
890
        assert_eq!(norm("$$\nx\n=\ny\n$$"), "$$x = y$$");
891
        assert_eq!(norm("\\[\nx\n=\ny\n\\]"), "$$x = y$$");
892
        // `-` (setext H2 / list marker) likewise.
893
        assert_eq!(norm("$$\na\n- b\n$$"), "$$a - b$$");
894
    }
895
896
    #[test]
897
    fn multiline_display_bracket_joins() {
898
        assert_eq!(norm("\\[\n\\frac{a+b}{2}\n\\]"), "$$\\frac{a+b}{2}$$");
899
        // Indented continuation lines are trimmed.
900
        assert_eq!(norm("$$\n  x +\n  y\n$$"), "$$x + y$$");
901
    }
902
903
    #[test]
904
    fn multiline_equation_env_joins() {
905
        assert_eq!(
906
            norm("\\begin{equation}\nE\n=\nmc^2\n\\end{equation}"),
907
            "$$E = mc^2$$"
908
        );
909
    }
910
911
    #[test]
912
    fn mismatched_display_delimiters_still_join() {
913
        // Every opener accepts every closer, matching the old behavior where
914
        // each token normalized to `$$` independently.
915
        assert_eq!(norm("\\[\nx\n=\ny\n$$"), "$$x = y$$");
916
        assert_eq!(norm("$$\nx\n\\]"), "$$x$$");
917
    }
918
919
    #[test]
920
    fn single_line_display_spans_unchanged() {
921
        assert_eq!(norm("$$x = y$$"), "$$x = y$$");
922
        assert_eq!(norm("text $$ a $$ more"), "text $$ a $$ more");
923
        // Whitespace inside single-line spans is preserved verbatim.
924
        assert_eq!(norm("$$  x  $$"), "$$  x  $$");
925
    }
926
927
    #[test]
928
    fn display_join_aborts_at_blank_line() {
929
        // Two stray `$$` across a paragraph break must not fuse into a span.
930
        let input = "Tickets cost $$.\n\nDinner cost $$.";
931
        assert_eq!(norm(input), input);
932
        // Math with an interior blank line stays as-is too (pre-existing
933
        // breakage; joining across paragraphs would be worse).
934
        let math = "$$\nx\n\ny\n$$";
935
        assert_eq!(norm(math), math);
936
    }
937
938
    #[test]
939
    fn display_join_aborts_at_blockquote_marker() {
940
        // Quoted display math keeps its `>` markers: pulldown strips them per
941
        // line and handles the span; joining would make them span content.
942
        let input = "> $$\n> x + y\n> $$";
943
        assert_eq!(norm(input), input);
944
    }
945
946
    #[test]
947
    fn unclosed_display_dollar_stays_literal() {
948
        assert_eq!(norm("a $$ x = y"), "a $$ x = y");
949
        assert_eq!(norm("$$"), "$$");
950
        // Triple-and-more dollar runs pass through verbatim.
951
        assert_eq!(norm("$$$"), "$$$");
952
        assert_eq!(norm("$$$$"), "$$$$");
953
    }
954
955
    #[test]
956
    fn display_join_gives_up_past_cap() {
957
        // No close within MAX_MATH_SOURCE_LEN: the opener stays literal and
958
        // the interior is processed normally.
959
        let big = "y".repeat(MAX_MATH_SOURCE_LEN + 10);
960
        let input = format!("$$\nx\n{big}");
961
        assert_eq!(norm(&input), input);
962
    }
963
964
    #[test]
965
    fn display_join_handles_crlf() {
966
        assert_eq!(norm("$$\r\nx\r\n=\r\ny\r\n$$"), "$$x = y$$");
967
    }
968
969
    #[test]
970
    fn interior_dollar_escapes_are_span_content() {
971
        assert_eq!(norm("$$\nprice \\$5\n=\nz\n$$"), "$$price \\$5 = z$$");
972
    }
973
974
    // ── Inline `\(…\)` spans join interior newlines ──────────────────────
975
976
    #[test]
977
    fn multiline_inline_paren_joins() {
978
        // A wrapped inline span is equally vulnerable to setext re-parsing.
979
        assert_eq!(norm("\\(a\n=\nb\\)"), "$a = b$");
980
        assert_eq!(norm("\\(x +\n  y\\)"), "$x + y$");
981
    }
982
983
    #[test]
984
    fn equation_env_converts() {
985
        assert_eq!(norm("\\begin{equation} x=1 \\end{equation}"), "$$ x=1 $$");
986
        assert_eq!(norm("\\begin{equation*} y \\end{equation*}"), "$$ y $$");
987
    }
988
989
    #[test]
990
    fn dollar_forms_unchanged() {
991
        assert_eq!(norm("$x$"), "$x$");
992
        assert_eq!(norm("$$x$$"), "$$x$$");
993
        assert_eq!(norm("text $a+b$ more"), "text $a+b$ more");
994
    }
995
996
    #[test]
997
    fn idempotent() {
998
        for s in [
999
            "\\(x\\)",
1000
            "\\[y\\]",
1001
            "a \\(x\\) and \\[y\\] and $z$",
1002
            "\\begin{equation} q \\end{equation}",
1003
            "`\\(code\\)`",
1004
            "```\n\\(c\\)\n```\n",
1005
            "$$\nx\n=\ny\n$$",
1006
            "\\[\nx\n=\ny\n\\]",
1007
            "\\begin{equation}\nE\n=\nmc^2\n\\end{equation}",
1008
            "\\(a\n=\nb\\)",
1009
            "a $$ x = y",
1010
            "> $$\n> x\n> $$",
1011
            "Tickets cost $$.\n\nDinner cost $$.",
1012
        ] {
1013
            let once = norm(s);
1014
            let twice = norm(&once);
1015
            assert_eq!(once, twice, "not idempotent for {s:?}");
1016
        }
1017
    }
1018
1019
    // ── Escapes & currency ───────────────────────────────────────────────
1020
1021
    #[test]
1022
    fn escaped_backslash_paren_stays_literal() {
1023
        // `\\(` = escaped backslash + literal paren → must NOT become math.
1024
        assert_eq!(norm("\\\\(x\\\\)"), "\\\\(x\\\\)");
1025
        // `\\\(` = escaped backslash + real `\(` → the `\(` converts.
1026
        assert_eq!(norm("\\\\\\(x\\\\\\)"), "\\\\$x\\\\$");
1027
    }
1028
1029
    #[test]
1030
    fn escaped_dollar_stays_literal() {
1031
        assert_eq!(norm("price \\$5"), "price \\$5");
1032
    }
1033
1034
    #[test]
1035
    fn currency_not_misconverted() {
1036
        assert_eq!(norm("$5 and $10"), "$5 and $10");
1037
        assert_eq!(norm("\\(a\\) costs $5"), "$a$ costs $5");
1038
    }
1039
1040
    // ── Code is left verbatim ────────────────────────────────────────────
1041
1042
    #[test]
1043
    fn inline_code_latex_untouched() {
1044
        assert_eq!(norm("`\\(x\\)`"), "`\\(x\\)`");
1045
        assert_eq!(norm("see `\\[y\\]` here"), "see `\\[y\\]` here");
1046
        // Double-backtick code span with an embedded single backtick.
1047
        assert_eq!(norm("``a ` \\(x\\)``"), "``a ` \\(x\\)``");
1048
    }
1049
1050
    #[test]
1051
    fn fenced_code_latex_untouched() {
1052
        assert_eq!(norm("```\n\\(x\\)\n```\n"), "```\n\\(x\\)\n```\n");
1053
        assert_eq!(norm("```latex\n\\[y\\]\n```\n"), "```latex\n\\[y\\]\n```\n");
1054
        // Tilde fence.
1055
        assert_eq!(norm("~~~\n\\(x\\)\n~~~\n"), "~~~\n\\(x\\)\n~~~\n");
1056
    }
1057
1058
    #[test]
1059
    fn math_around_code_still_converts() {
1060
        assert_eq!(norm("\\(a\\) `code` \\(b\\)"), "$a$ `code` $b$");
1061
        assert_eq!(
1062
            norm("\\(a\\)\n```\nx\n```\n\\(b\\)"),
1063
            "$a$\n```\nx\n```\n$b$"
1064
        );
1065
    }
1066
1067
    #[test]
1068
    fn fence_with_three_space_indent() {
1069
        assert_eq!(
1070
            norm("   ```\n   \\(x\\)\n   ```\n"),
1071
            "   ```\n   \\(x\\)\n   ```\n"
1072
        );
1073
    }
1074
1075
    // ── Math inside tables (the bug) ─────────────────────────────────────
1076
1077
    #[test]
1078
    fn table_cell_backslash_math_converts() {
1079
        let input = "| Mode | Metric |\n|---|---|\n| Open | Decay vs \\(L_{x}\\) |\n";
1080
        let expected = "| Mode | Metric |\n|---|---|\n| Open | Decay vs $L_{x}$ |\n";
1081
        assert_eq!(norm(input), expected);
1082
    }
1083
1084
    // ── Streaming equivalence (the key invariant) ────────────────────────
1085
1086
    const RICH_DOC: &str = concat!(
1087
        "Inline \\(a+b\\), dollar $c+d$, display \\[e=mc^2\\].\n\n",
1088
        "Padded \\( x + y \\) and \\( \\alpha + \\beta \\) spans.\n\n",
1089
        "| Col | Math |\n|---|---|\n| x | \\(\\alpha\\) | $\\beta$ |\n\n",
1090
        "Code `\\(not math\\)` stays raw.\n\n",
1091
        "```latex\n\\(also not\\)\n\\[block\\]\n```\n\n",
1092
        "Env \\begin{equation} x=1 \\end{equation} done.\n\n",
1093
        "Escaped \\\\(literal\\\\), price $5 and $10.\n",
1094
        "List:\n- item \\(p\\to q\\)\n- plain\n\n",
1095
        "> quote \\[E=mc^2\\]\n\n",
1096
        "## Heading \\(h=x^3\\)\n",
1097
    );
1098
1099
    fn assert_split_invariant(doc: &str) {
1100
        let oneshot = norm(doc);
1101
1102
        // 2-way: split at every char boundary.
1103
        for split in 0..=doc.len() {
1104
            if !doc.is_char_boundary(split) {
1105
                continue;
1106
            }
1107
            let mut nz = LatexDelimiterNormalizer::new();
1108
            let mut got = nz.push(&doc[..split]);
1109
            got.push_str(&nz.push(&doc[split..]));
1110
            got.push_str(&nz.finish());
1111
            assert_eq!(got, oneshot, "2-way split at byte {split}");
1112
        }
1113
    }
1114
1115
    fn assert_char_by_char(doc: &str) {
1116
        let oneshot = norm(doc);
1117
        let mut nz = LatexDelimiterNormalizer::new();
1118
        let mut got = String::new();
1119
        for ch in doc.chars() {
1120
            got.push_str(&nz.push(ch.encode_utf8(&mut [0u8; 4])));
1121
        }
1122
        got.push_str(&nz.finish());
1123
        assert_eq!(got, oneshot, "char-by-char stream");
1124
    }
1125
1126
    #[test]
1127
    fn streaming_matches_oneshot_all_splits() {
1128
        assert_split_invariant(RICH_DOC);
1129
    }
1130
1131
    #[test]
1132
    fn streaming_matches_oneshot_char_by_char() {
1133
        assert_char_by_char(RICH_DOC);
1134
    }
1135
1136
    #[test]
1137
    fn streaming_matches_oneshot_edge_fixtures() {
1138
        for doc in [
1139
            "\\(x\\)",
1140
            "\\[x\\]",
1141
            "\\begin{equation}z\\end{equation}",
1142
            "trailing backslash \\",
1143
            "ends with paren open \\(",
1144
            " ambiguous \\beg",
1145
            "backtick run at end ```",
1146
            "  ",
1147
            "\\\\(escaped\\\\)",
1148
            "`unterminated \\(x\\)\nafter \\(y\\)",
1149
            // Padded inline spans exercise the look-ahead + trim hold-back.
1150
            "\\( x \\)",
1151
            "a \\( x+y \\) b",
1152
            "\\( \\alpha + \\beta \\)",
1153
            "\\( \\{x\\} \\)",
1154
            "\\( \\) empty",
1155
            // Unclosed padded open: held back until finish() flushes a lone `$`.
1156
            "unclosed padded \\( x + y",
1157
            // Display spans exercise the close-scan hold-back and its aborts.
1158
            "$$\nx\n=\ny\n$$",
1159
            "\\[\n\\boxed{ x\n=\ny }\n\\]",
1160
            "\\begin{equation}\na\n=\nb\n\\end{equation}",
1161
            "$$\nx\n\ny\n$$",
1162
            "> $$\n> x\n> $$",
1163
            "a $$ unclosed",
1164
            "$$$",
1165
            "trailing dollars $$",
1166
            "$$\r\nx\r\n$$",
1167
            "\\(a\n=\nb\\)",
1168
            "text $5 and $$ x $$ and $10",
1169
        ] {
1170
            assert_split_invariant(doc);
1171
            assert_char_by_char(doc);
1172
        }
1173
    }
1174
1175
    // ── finish() flushes held-back partials literally ────────────────────
1176
1177
    #[test]
1178
    fn finish_flushes_partial_backslash() {
1179
        let mut nz = LatexDelimiterNormalizer::new();
1180
        let mut got = nz.push("a\\");
1181
        got.push_str(&nz.finish());
1182
        assert_eq!(got, "a\\");
1183
    }
1184
1185
    #[test]
1186
    fn finish_flushes_partial_env() {
1187
        let mut nz = LatexDelimiterNormalizer::new();
1188
        let mut got = nz.push("x \\begin{eq");
1189
        got.push_str(&nz.finish());
1190
        assert_eq!(got, "x \\begin{eq");
1191
    }
1192
1193
    #[test]
1194
    fn reset_clears_state() {
1195
        let mut nz = LatexDelimiterNormalizer::new();
1196
        let _ = nz.push("```\ncode \\(x\\)");
1197
        nz.reset();
1198
        // After reset we are back in Normal at line start.
1199
        let mut got = nz.push("\\(y\\)");
1200
        got.push_str(&nz.finish());
1201
        assert_eq!(got, "$y$");
1202
    }
1203
1204
    #[test]
1205
    fn trailing_closing_backtick_held_until_finish_repro_auto_wake() {
1206
        let msg = "That was just a stale progress check finishing — no new work. \
1207
The review is already complete at:\n\n\
1208
`/tmp/project/results/report.html`";
1209
1210
        let mut nz = LatexDelimiterNormalizer::new();
1211
        let pre_finish = nz.push(msg);
1212
        assert!(
1213
            !pre_finish.ends_with('`'),
1214
            "pre-finish source must hold back the trailing closer; got {:?}",
1215
            &pre_finish[pre_finish.len().saturating_sub(40)..]
1216
        );
1217
        assert!(
1218
            pre_finish.contains('`') && pre_finish.contains("/tmp/project/results"),
1219
            "opener + path should already be emitted"
1220
        );
1221
1222
        let mut full = pre_finish;
1223
        full.push_str(&nz.finish());
1224
        assert!(
1225
            full.ends_with('`'),
1226
            "finish() must flush the held-back closing backtick"
1227
        );
1228
        assert_eq!(full, msg);
1229
1230
        let mut nz = LatexDelimiterNormalizer::new();
1231
        let mut streamed = nz.push(&msg[..msg.len() - 1]);
1232
        streamed.push_str(&nz.push("`"));
1233
        assert!(
1234
            !streamed.ends_with('`') || streamed.matches('`').count() < 2,
1235
            "closing backtick still held after final chunk without finish(); got {:?}",
1236
            &streamed[streamed.len().saturating_sub(40)..]
1237
        );
1238
        streamed.push_str(&nz.finish());
1239
        assert_eq!(streamed, msg);
1240
    }
1241
}
1242
1243
#[cfg(test)]
1244
mod token_soup_stress {
1245
    use super::*;
1246
1247
    /// Randomized delimiter-soup stress. Two invariants are universal and
1248
    /// pinned here for arbitrary input:
1249
    ///
1250
    /// 1. the normalizer never panics;
1251
    /// 2. streaming char-by-char matches the one-shot output (chunk-split
1252
    ///    invariance — what production streaming actually relies on).
1253
    ///
1254
    /// Full byte-idempotency is deliberately *not* asserted on soup: a
1255
    /// conversion can glue a new `$$` out of adjacent tokens (e.g. `$` + an
1256
    /// unmatched `\)` → `$$`), which a second pass would then scan as a
1257
    /// display opener. Production normalizes exactly once per stream (the
1258
    /// streaming renderer's `clone()` re-appends already-normalized source
1259
    /// verbatim), and idempotency for realistic documents is pinned by the
1260
    /// `idempotent` test's curated list.
1261
    #[test]
1262
    fn token_soup_never_panics_and_streams_consistently() {
1263
        const TOKENS: [&str; 18] = [
1264
            "$$",
1265
            "$",
1266
            "\\[",
1267
            "\\]",
1268
            "\\(",
1269
            "\\)",
1270
            "\n",
1271
            "\n\n",
1272
            "=",
1273
            "-",
1274
            ">",
1275
            "`",
1276
            "```",
1277
            "x y",
1278
            "\\begin{equation}",
1279
            "\\end{equation}",
1280
            "\\\\",
1281
            "\r\n",
1282
        ];
1283
        // Simple deterministic LCG so failures are reproducible.
1284
        let mut state: u64 = 0x243F6A8885A308D3;
1285
        let mut next = move || {
1286
            state = state
1287
                .wrapping_mul(6364136223846793005)
1288
                .wrapping_add(1442695040888963407);
1289
            (state >> 33) as usize
1290
        };
1291
        for _ in 0..4000 {
1292
            let len = 1 + next() % 12;
1293
            let doc: String = (0..len).map(|_| TOKENS[next() % TOKENS.len()]).collect();
1294
            let oneshot = normalize_latex_delimiters(&doc);
1295
            // Char-by-char streaming must match one-shot.
1296
            let mut nz = LatexDelimiterNormalizer::new();
1297
            let mut got = String::new();
1298
            for ch in doc.chars() {
1299
                got.push_str(&nz.push(ch.encode_utf8(&mut [0u8; 4])));
1300
            }
1301
            got.push_str(&nz.finish());
1302
            assert_eq!(got, oneshot, "stream mismatch for {doc:?}");
1303
        }
1304
    }
1305
}
crates/coder-lite/src/markdown/line_utils.rs added +513

@@ -0,0 +1,513 @@

1
//! Line and string utility functions for ratatui text manipulation.
2
//!
3
//! Ported from xAI's `grok-build`
4
//! (`crates/codegen/xai-grok-pager-render/src/render/line_utils.rs`), Apache-2.0.
5
//! See `LICENSE-APACHE-xai` beside this file.
6
7
use ratatui::text::{Line, Span};
8
use unicode_segmentation::UnicodeSegmentation;
9
use unicode_width::UnicodeWidthStr;
10
11
pub use crate::markdown::util::byte_offset_at_width;
12
13
/// Clone a borrowed ratatui `Line` into an owned `'static` line.
14
pub fn line_to_static(line: &Line<'_>) -> Line<'static> {
15
    Line {
16
        style: line.style,
17
        alignment: line.alignment,
18
        spans: line
19
            .spans
20
            .iter()
21
            .map(|s| Span {
22
                style: s.style,
23
                content: std::borrow::Cow::Owned(s.content.to_string()),
24
            })
25
            .collect(),
26
    }
27
}
28
29
/// Append owned copies of borrowed lines to `out`.
30
pub fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
31
    for l in src {
32
        out.push(line_to_static(l));
33
    }
34
}
35
36
/// True for a character unsafe to render from untrusted/server text:
37
/// C0/C1 controls (the terminal-escape-injection vector) plus the Unicode
38
/// bidi-control and zero-width/format set (Trojan-Source spoofing) — U+061C,
39
/// U+200B–200F, U+202A–202E, U+2060–206F, U+FEFF.
40
///
41
/// Shared by every untrusted-text strip/scrub site (chip labels, toast error
42
/// scrub, settings editor input) so the set never drifts between them.
43
pub fn is_unsafe_display_char(c: char) -> bool {
44
    c.is_control()
45
        || matches!(
46
            c,
47
            '\u{061C}'
48
            | '\u{200B}'..='\u{200F}'
49
            | '\u{202A}'..='\u{202E}'
50
            | '\u{2060}'..='\u{206F}'
51
            | '\u{FEFF}'
52
        )
53
}
54
55
/// Polyfill for nightly-only [`str::floor_char_boundary`].
56
/// Snaps a byte index down to the nearest char boundary.
57
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
58
    let index = index.min(s.len());
59
    let mut i = index;
60
    while i > 0 && !s.is_char_boundary(i) {
61
        i -= 1;
62
    }
63
    i
64
}
65
66
/// `String`-owning delegate of [`crate::markdown::util::truncate_to_width`].
67
pub fn truncate_str(s: &str, max_width: usize) -> String {
68
    crate::markdown::util::truncate_to_width(s, max_width).into_owned()
69
}
70
71
/// Truncate a styled `Line` (multiple spans) to fit within `max_width` display columns.
72
///
73
/// Walks spans left-to-right, consuming width budget. When the budget is
74
/// exhausted mid-span, that span is truncated and `…` is appended. Spans
75
/// beyond the budget are dropped. All styles are preserved.
76
///
77
/// Returns the line unchanged if it already fits.
78
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
79
    if max_width == 0 {
80
        return Line::from(vec![]);
81
    }
82
83
    let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
84
    if total <= max_width {
85
        return line;
86
    }
87
88
    // Need room for the ellipsis (1 column).
89
    let budget = max_width.saturating_sub(1);
90
    let mut used = 0usize;
91
    let mut out: Vec<Span<'static>> = Vec::new();
92
93
    for span in line.spans {
94
        let sw = span.content.width();
95
        if used + sw <= budget {
96
            // Entire span fits.
97
            used += sw;
98
            out.push(span);
99
        } else {
100
            // Partial fit — truncate this span.
101
            let remaining = budget - used;
102
            if remaining > 0 {
103
                let truncated = take_width(&span.content, remaining);
104
                out.push(Span::styled(truncated, span.style));
105
            }
106
            // Append ellipsis with the same style as the last span.
107
            let ellipsis_style = out.last().map(|s| s.style).unwrap_or_default();
108
            out.push(Span::styled("\u{2026}", ellipsis_style));
109
            return Line::from(out);
110
        }
111
    }
112
113
    // Shouldn't reach here (total > max_width checked above), but be safe.
114
    Line::from(out)
115
}
116
117
/// Clip or pad a styled `Line` to exactly `width` display columns.
118
///
119
/// Wider lines are clipped on grapheme boundaries (a multi-`char` grapheme like
120
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are padded with
121
/// trailing spaces. This keeps a rendered row "self-owning" — the app writes a
122
/// real cell in every column, so a terminal drawing a glyph wider than the app
123
/// measured cannot strand a stale cell past the row (the markdown-table ghost
124
/// glyph bug). Width uses [`UnicodeWidthStr`], matching the table layout.
125
///
126
/// `width` must be a bounded display width: the pad branch allocates
127
/// `width - total` spaces.
128
pub fn fit_line_to_width<'a>(line: Line<'a>, width: usize) -> Line<'a> {
129
    let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
130
    if total == width {
131
        return line;
132
    }
133
134
    let Line {
135
        style,
136
        alignment,
137
        mut spans,
138
    } = line;
139
140
    if total < width {
141
        spans.push(Span::raw(" ".repeat(width - total)));
142
        return Line {
143
            style,
144
            alignment,
145
            spans,
146
        };
147
    }
148
149
    // Wider than width: clip on grapheme boundaries, no ellipsis.
150
    let mut out: Vec<Span<'a>> = Vec::new();
151
    let mut used = 0usize;
152
    for span in spans {
153
        let sw = span.content.width();
154
        if used + sw <= width {
155
            used += sw;
156
            out.push(span);
157
            if used == width {
158
                break;
159
            }
160
            continue;
161
        }
162
        // This span straddles the boundary — take whole graphemes that fit.
163
        let remaining = width - used;
164
        let mut taken = String::new();
165
        let mut taken_width = 0usize;
166
        for g in span.content.graphemes(true) {
167
            let gw = g.width();
168
            if taken_width + gw > remaining {
169
                break;
170
            }
171
            taken_width += gw;
172
            taken.push_str(g);
173
        }
174
        if !taken.is_empty() {
175
            out.push(Span::styled(taken, span.style));
176
            used += taken_width;
177
        }
178
        // A straddling wide grapheme leaves a 1-column gap; pad it.
179
        if used < width {
180
            out.push(Span::raw(" ".repeat(width - used)));
181
        }
182
        break;
183
    }
184
185
    Line {
186
        style,
187
        alignment,
188
        spans: out,
189
    }
190
}
191
192
/// Take the first `n` display columns from a string.
193
fn take_width(s: &str, n: usize) -> String {
194
    s[..byte_offset_at_width(s, n)].to_string()
195
}
196
197
/// Cascade-truncate multiple text elements to fit within `avail` display columns.
198
///
199
/// Returns `(type, description, activity, meta)` truncated to fit.
200
/// Priority (highest first): type, activity, meta. Description is truncated
201
/// first. If overhead (type + activity + meta) >= avail, description is dropped
202
/// and the remaining elements are cascaded: meta is dropped first, then
203
/// activity is truncated, then type.
204
pub fn cascade_truncate(
205
    avail: usize,
206
    type_text: &str,
207
    description: &str,
208
    activity_text: &str,
209
    meta_text: &str,
210
) -> (String, String, String, String) {
211
    let overhead = type_text.width() + activity_text.width() + meta_text.width();
212
    if overhead <= avail {
213
        let desc_max = avail - overhead;
214
        (
215
            type_text.to_string(),
216
            truncate_str(description, desc_max),
217
            activity_text.to_string(),
218
            meta_text.to_string(),
219
        )
220
    } else {
221
        let mut budget = avail;
222
        let td = if type_text.width() <= budget {
223
            budget -= type_text.width();
224
            type_text.to_string()
225
        } else {
226
            let s = truncate_str(type_text, budget);
227
            budget = 0;
228
            s
229
        };
230
        let ad = if budget == 0 {
231
            String::new()
232
        } else if activity_text.width() <= budget {
233
            budget -= activity_text.width();
234
            activity_text.to_string()
235
        } else {
236
            let s = truncate_str(activity_text, budget);
237
            budget = 0;
238
            s
239
        };
240
        let md = if budget == 0 {
241
            String::new()
242
        } else if meta_text.width() <= budget {
243
            meta_text.to_string()
244
        } else {
245
            truncate_str(meta_text, budget)
246
        };
247
        (td, String::new(), ad, md)
248
    }
249
}
250
251
#[cfg(test)]
252
mod tests {
253
    use super::*;
254
255
    #[test]
256
    fn is_unsafe_display_char_covers_controls_and_bidi_format() {
257
        // Safe: ordinary printable text (incl. legitimate RTL letters).
258
        for c in ['a', ' ', '/', '\u{00e9}', '\u{05d0}'] {
259
            assert!(!is_unsafe_display_char(c), "{c:?} must be safe");
260
        }
261
        // Unsafe: C0/C1 controls + the full bidi-control / zero-width set.
262
        for c in [
263
            '\u{1b}', '\n', '\t', '\u{061C}', '\u{200B}', '\u{200F}', '\u{202E}', '\u{2066}',
264
            '\u{2069}', '\u{206F}', '\u{FEFF}',
265
        ] {
266
            assert!(
267
                is_unsafe_display_char(c),
268
                "{:#06x} must be unsafe",
269
                c as u32
270
            );
271
        }
272
    }
273
274
    // ── truncate_line tests ─────────────────────────────────────────
275
276
    #[test]
277
    fn truncate_line_fits() {
278
        let line = Line::from(vec![Span::raw("Hello "), Span::raw("world")]);
279
        let result = truncate_line(line, 20);
280
        assert_eq!(result.spans.len(), 2);
281
        assert_eq!(result.spans[0].content.as_ref(), "Hello ");
282
        assert_eq!(result.spans[1].content.as_ref(), "world");
283
    }
284
285
    #[test]
286
    fn truncate_line_cuts_mid_span() {
287
        let line = Line::from(vec![
288
            Span::raw("Edit "),
289
            Span::raw("very/long/path/to/file.rs"),
290
        ]);
291
        // Total = 29, budget = 15 → "Edit very/long…"
292
        let result = truncate_line(line, 15);
293
        let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
294
        assert!(text.ends_with('\u{2026}'));
295
        assert!(text.width() <= 15);
296
    }
297
298
    #[test]
299
    fn truncate_line_drops_later_spans() {
300
        let line = Line::from(vec![
301
            Span::raw("Search "),
302
            Span::raw("pattern"),
303
            Span::raw(" in "),
304
            Span::raw("path"),
305
            Span::raw(" (5 matches)"),
306
        ]);
307
        let result = truncate_line(line, 18);
308
        let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
309
        assert!(text.ends_with('\u{2026}'));
310
        assert!(text.width() <= 18);
311
    }
312
313
    #[test]
314
    fn truncate_line_zero_width() {
315
        let line = Line::from(vec![Span::raw("hello")]);
316
        let result = truncate_line(line, 0);
317
        assert!(result.spans.is_empty());
318
    }
319
320
    // ── fit_line_to_width tests ─────────────────────────────────────
321
322
    fn line_text(line: &Line<'static>) -> String {
323
        line.spans.iter().map(|s| s.content.as_ref()).collect()
324
    }
325
326
    #[test]
327
    fn fit_line_pads_short_line() {
328
        let line = Line::from(vec![Span::raw("│ a │")]);
329
        let out = fit_line_to_width(line, 10);
330
        assert_eq!(line_text(&out).width(), 10);
331
        assert_eq!(line_text(&out), "│ a │     ");
332
    }
333
334
    #[test]
335
    fn fit_line_exact_width_unchanged() {
336
        let line = Line::from(vec![Span::raw("hello")]);
337
        let out = fit_line_to_width(line, 5);
338
        assert_eq!(out.spans.len(), 1);
339
        assert_eq!(line_text(&out), "hello");
340
    }
341
342
    #[test]
343
    fn fit_line_clips_long_line_no_ellipsis() {
344
        let line = Line::from(vec![Span::raw("│ Column A │ Column B │")]);
345
        let out = fit_line_to_width(line, 8);
346
        assert_eq!(line_text(&out).width(), 8);
347
        assert_eq!(line_text(&out), "│ Column");
348
    }
349
350
    #[test]
351
    fn fit_line_does_not_split_emoji_grapheme() {
352
        // a(1)+b(1)+⚠️(2) = 4. Clipping to 3 must drop the width-2 grapheme
353
        // whole (never split it) and pad → "ab" + 1 space.
354
        let line = Line::from(vec![Span::raw("ab\u{26A0}\u{FE0F}")]);
355
        let out = fit_line_to_width(line, 3);
356
        assert_eq!(line_text(&out).width(), 3);
357
        assert_eq!(line_text(&out), "ab ");
358
    }
359
360
    #[test]
361
    fn fit_line_clips_grapheme_straddle_in_later_span() {
362
        // The straddle happens in a later span: keep "ab", then 1 col left →
363
        // ⚠️ (width 2) won't fit → dropped whole and padded.
364
        let line = Line::from(vec![Span::raw("ab"), Span::raw("\u{26A0}\u{FE0F}cd")]);
365
        let out = fit_line_to_width(line, 3);
366
        assert_eq!(line_text(&out).width(), 3);
367
        assert_eq!(line_text(&out), "ab ");
368
    }
369
370
    #[test]
371
    fn fit_line_drops_subsequent_spans_after_clip() {
372
        let line = Line::from(vec![
373
            Span::raw("hello"),
374
            Span::raw(" world"),
375
            Span::raw("!!!"),
376
        ]);
377
        let out = fit_line_to_width(line, 5);
378
        assert_eq!(line_text(&out), "hello");
379
        // The straddling/later spans must be dropped entirely.
380
        assert_eq!(out.spans.len(), 1);
381
    }
382
383
    #[test]
384
    fn fit_line_takes_partial_of_later_span() {
385
        let line = Line::from(vec![Span::raw("ab"), Span::raw("cdef")]);
386
        let out = fit_line_to_width(line, 4);
387
        assert_eq!(line_text(&out), "abcd");
388
        assert_eq!(line_text(&out).width(), 4);
389
    }
390
391
    #[test]
392
    fn fit_line_zero_width_returns_empty() {
393
        let line = Line::from(vec![Span::raw("│ a │")]);
394
        let out = fit_line_to_width(line, 0);
395
        assert_eq!(line_text(&out), "");
396
        assert_eq!(line_text(&out).width(), 0);
397
    }
398
399
    #[test]
400
    fn fit_line_preserves_span_styles_when_padding() {
401
        let bold = ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD);
402
        let line = Line::from(vec![Span::styled("hi", bold)]);
403
        let out = fit_line_to_width(line, 5);
404
        assert_eq!(line_text(&out).width(), 5);
405
        assert!(
406
            out.spans[0]
407
                .style
408
                .add_modifier
409
                .contains(ratatui::style::Modifier::BOLD)
410
        );
411
    }
412
413
    // ── cascade_truncate tests ────────────────────────────────────
414
415
    #[test]
416
    fn cascade_truncate_all_fit() {
417
        let (t, d, a, m) =
418
            cascade_truncate(50, "type  ", "description", " \u{2014} running", "  meta");
419
        assert_eq!(t, "type  ");
420
        assert_eq!(d, "description");
421
        assert_eq!(a, " \u{2014} running");
422
        assert_eq!(m, "  meta");
423
    }
424
425
    #[test]
426
    fn cascade_truncate_desc_truncated() {
427
        let (t, d, a, m) = cascade_truncate(
428
            25,
429
            "type  ",
430
            "long description here",
431
            " \u{2014} running",
432
            "  meta",
433
        );
434
        assert_eq!(t, "type  ");
435
        assert_eq!(d, "lo\u{2026}");
436
        assert_eq!(a, " \u{2014} running");
437
        assert_eq!(m, "  meta");
438
    }
439
440
    #[test]
441
    fn cascade_truncate_desc_gone_meta_truncated() {
442
        // overhead = 6+10+6 = 22 > avail 20 → desc gone, type 6 + activity 10 + meta truncated to 4
443
        let (t, d, a, m) = cascade_truncate(20, "type  ", "desc", " \u{2014} running", "  meta");
444
        assert_eq!(t, "type  ");
445
        assert_eq!(d, "");
446
        assert_eq!(a, " \u{2014} running");
447
        assert_eq!(m, "  m\u{2026}");
448
    }
449
450
    #[test]
451
    fn cascade_truncate_meta_and_activity_gone() {
452
        // avail=8, type=6 fits (budget=2), activity truncated to 2, meta gone
453
        let (t, d, a, m) = cascade_truncate(8, "type  ", "desc", " \u{2014} running", "  meta");
454
        assert_eq!(t, "type  ");
455
        assert_eq!(d, "");
456
        assert_eq!(a, " \u{2026}");
457
        assert_eq!(m, "");
458
    }
459
460
    #[test]
461
    fn cascade_truncate_type_truncated() {
462
        let (t, d, a, m) = cascade_truncate(3, "type  ", "desc", " \u{2014} running", "  meta");
463
        assert_eq!(t, "ty\u{2026}");
464
        assert_eq!(d, "");
465
        assert_eq!(a, "");
466
        assert_eq!(m, "");
467
    }
468
469
    #[test]
470
    fn cascade_truncate_zero_avail() {
471
        let (t, d, a, m) = cascade_truncate(0, "type  ", "desc", " \u{2014} running", "  meta");
472
        assert_eq!(t, "");
473
        assert_eq!(d, "");
474
        assert_eq!(a, "");
475
        assert_eq!(m, "");
476
    }
477
478
    #[test]
479
    fn cascade_truncate_unicode() {
480
        // ✗ = 1 display column; — = 1 display column
481
        let (t, d, a, m) = cascade_truncate(10, "\u{2717}  ", "description", " \u{2014} run", "");
482
        assert_eq!(t, "\u{2717}  ");
483
        assert_eq!(d, "\u{2026}");
484
        assert_eq!(a, " \u{2014} run");
485
        assert_eq!(m, "");
486
    }
487
488
    #[test]
489
    fn cascade_truncate_overhead_equals_avail() {
490
        // overhead exactly equals avail → desc empty, everything else fits
491
        let (t, d, a, m) = cascade_truncate(22, "type  ", "desc", " \u{2014} running", "  meta");
492
        assert_eq!(t, "type  ");
493
        assert_eq!(d, "");
494
        assert_eq!(a, " \u{2014} running");
495
        assert_eq!(m, "  meta");
496
    }
497
498
    #[test]
499
    fn cascade_truncate_avail_one() {
500
        let (t, d, a, m) = cascade_truncate(1, "type", "desc", "act", "meta");
501
        assert_eq!(t, "\u{2026}");
502
        assert_eq!((d.as_str(), a.as_str(), m.as_str()), ("", "", ""));
503
    }
504
505
    #[test]
506
    fn cascade_truncate_all_empty() {
507
        let (t, d, a, m) = cascade_truncate(10, "", "", "", "");
508
        assert_eq!(
509
            (t.as_str(), d.as_str(), a.as_str(), m.as_str()),
510
            ("", "", "", "")
511
        );
512
    }
513
}
crates/coder-lite/src/markdown/mermaid.rs added +4950

@@ -0,0 +1,5237 @@

1
//! Self-contained terminal renderer for Mermaid diagrams.
2
//!
3
//! Renders `graph`/`flowchart`, `sequenceDiagram`, and `stateDiagram` blocks
4
//! as Unicode box-drawing art; unsupported diagram types fall back to the raw
5
//! source in a framed box.
6
7
use std::collections::HashMap;
8
9
use ratatui::style::{Modifier, Style};
10
use ratatui::text::{Line, Span};
11
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13
/// Theme-derived styles used when painting a diagram.
14
#[derive(Clone, Copy)]
15
pub(crate) struct MermaidStyles {
16
    pub border: Style,
17
    pub node_text: Style,
18
    pub edge: Style,
19
    pub edge_label: Style,
20
    pub title: Style,
21
}
22
23
/// Rendered diagram: styled lines for the TUI and plain lines for ANSI output.
24
pub(crate) struct MermaidArt {
25
    pub styled_lines: Vec<Line<'static>>,
26
    pub plain_lines: Vec<String>,
27
}
28
29
const MAX_LABEL: usize = 28;
30
const PAD: usize = 1;
31
const GAP_X: usize = 3;
32
const GAP_Y: usize = 2;
33
/// Node labels wrap to at most this many display columns per line, and at most
34
/// this many lines (overflow is truncated with an ellipsis).
35
const WRAP_WIDTH: usize = 24;
36
const MAX_LINES: usize = 4;
37
/// Identifier-boundary characters preferred as break points when a single word
38
/// is too wide to fit, so it is not sliced mid-segment.
39
/// Mirrors `TOKEN_BREAK_CHARS` in `third_party/mermaid-to-svg/src/text_wrap.rs`;
40
/// the two renderers are deliberately independent, so keep these two in sync.
41
const LABEL_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/'];
42
/// Sentinel marking the trailing column of a wide glyph (never emitted).
43
const CONT: char = '\u{0}';
44
const MAX_NODES: usize = 128;
45
const MAX_EDGES: usize = 512;
46
const MAX_GROUPS: usize = 24;
47
const MAX_GROUP_DEPTH: usize = 6;
48
const MAX_CANVAS_CELLS: usize = 1 << 21;
49
50
fn char_width(c: char) -> usize {
51
    UnicodeWidthChar::width(c).unwrap_or(0)
52
}
53
54
#[derive(Clone, Copy)]
55
enum Oversize {
56
    Width,
57
    Cells,
58
}
59
60
/// Render a mermaid source block, or `None` for blank input.
61
pub(crate) fn render(
62
    src: &str,
63
    styles: &MermaidStyles,
64
    max_width: Option<usize>,
65
) -> Option<MermaidArt> {
66
    if src.trim().is_empty() {
67
        return None;
68
    }
69
70
    let outcome: Option<Result<MermaidArt, Oversize>> = parse_graph(src)
71
        .map(|graph| {
72
            if graph.groups.is_empty() {
73
                layout_flowchart(&graph, styles, max_width)
74
            } else {
75
                render_grouped(&graph, styles, max_width)
76
            }
77
        })
78
        .or_else(|| parse_state(src).map(|graph| layout_flowchart(&graph, styles, max_width)))
79
        .or_else(|| {
80
            parse_class(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width))
81
        })
82
        .or_else(|| {
83
            parse_er(src).map(|(graph, infos)| render_class(&graph, &infos, styles, max_width))
84
        })
85
        .or_else(|| parse_sequence(src).map(|seq| layout_sequence(&seq, styles, max_width)));
86
87
    let too_wide = match outcome {
88
        Some(Ok(art)) => return Some(art),
89
        Some(Err(Oversize::Width)) => true,
90
        Some(Err(Oversize::Cells)) | None => false,
91
    };
92
    Some(fallback(src, styles, max_width, too_wide))
93
}
94
95
#[derive(Clone, Copy, PartialEq)]
96
enum Shape {
97
    Rect,
98
    Round,
99
    Diamond,
100
}
101
102
struct Node {
103
    label: String,
104
    shape: Shape,
105
}
106
107
#[derive(Clone, Copy, PartialEq, Debug)]
108
enum Head {
109
    None,
110
    Arrow,
111
    Circle,
112
    Cross,
113
    Triangle,
114
    DiamondFill,
115
    DiamondOpen,
116
}
117
118
#[derive(Clone, Copy, PartialEq)]
119
enum LineKind {
120
    Solid,
121
    Dotted,
122
    Thick,
123
}
124
125
struct Edge {
126
    from: usize,
127
    to: usize,
128
    label: Option<String>,
129
    head_to: Head,
130
    head_from: Head,
131
    line: LineKind,
132
}
133
134
#[derive(Clone, Copy, PartialEq)]
135
enum Dir {
136
    Down,
137
    Up,
138
    Right,
139
    Left,
140
}
141
142
struct Group {
143
    id: String,
144
    label: String,
145
    parent: Option<usize>,
146
}
147
148
struct Graph {
149
    nodes: Vec<Node>,
150
    edges: Vec<Edge>,
151
    index: HashMap<String, usize>,
152
    groups: Vec<Group>,
153
    node_group: Vec<Option<usize>>,
154
    cur_group: Option<usize>,
155
    over_cap: bool,
156
    dir: Dir,
157
}
158
159
impl Graph {
160
    fn node_index(&mut self, id: &str, label: Option<&str>, shape: Shape) -> Option<usize> {
161
        if let Some(&i) = self.index.get(id) {
162
            if let Some(label) = label {
163
                self.nodes[i].label = label.to_string();
164
                self.nodes[i].shape = shape;
165
            }
166
            return Some(i);
167
        }
168
        if self.nodes.len() >= MAX_NODES {
169
            self.over_cap = true;
170
            return None;
171
        }
172
        let label = label.unwrap_or(id).to_string();
173
        self.index.insert(id.to_string(), self.nodes.len());
174
        self.nodes.push(Node { label, shape });
175
        self.node_group.push(self.cur_group);
176
        Some(self.nodes.len() - 1)
177
    }
178
179
    fn node_label(&mut self, id: &str, label: &str) -> Option<usize> {
180
        if let Some(&i) = self.index.get(id) {
181
            self.nodes[i].label = label.to_string();
182
            return Some(i);
183
        }
184
        self.node_index(id, Some(label), Shape::Round)
185
    }
186
}
187
188
fn parse_graph(src: &str) -> Option<Graph> {
189
    let mut statements: Vec<String> = Vec::new();
190
    for raw_line in src.lines() {
191
        split_statements(raw_line, &mut statements);
192
    }
193
194
    let header = statements.first()?;
195
    let mut header_tokens = header.split_whitespace();
196
    let kind = header_tokens.next()?.to_ascii_lowercase();
197
    if kind != "graph" && kind != "flowchart" {
198
        return None;
199
    }
200
    let dir = match header_tokens
201
        .next()
202
        .unwrap_or("TB")
203
        .to_ascii_uppercase()
204
        .as_str()
205
    {
206
        "LR" => Dir::Right,
207
        "RL" => Dir::Left,
208
        "BT" => Dir::Up,
209
        _ => Dir::Down,
210
    };
211
212
    let mut graph = Graph {
213
        nodes: Vec::new(),
214
        edges: Vec::new(),
215
        index: HashMap::new(),
216
        groups: Vec::new(),
217
        node_group: Vec::new(),
218
        cur_group: None,
219
        over_cap: false,
220
        dir,
221
    };
222
223
    let mut stack: Vec<usize> = Vec::new();
224
    for st in &statements[1..] {
225
        let first_word = st.split_whitespace().next().unwrap_or("");
226
        match first_word.to_ascii_lowercase().as_str() {
227
            "subgraph" => {
228
                if graph.groups.len() >= MAX_GROUPS || stack.len() >= MAX_GROUP_DEPTH {
229
                    return None;
230
                }
231
                let (id, label) = parse_subgraph_decl(st["subgraph".len()..].trim());
232
                graph.groups.push(Group {
233
                    id,
234
                    label,
235
                    parent: stack.last().copied(),
236
                });
237
                stack.push(graph.groups.len() - 1);
238
                graph.cur_group = stack.last().copied();
239
                continue;
240
            }
241
            "end" => {
242
                stack.pop();
243
                graph.cur_group = stack.last().copied();
244
                continue;
245
            }
246
            "classdef" | "class" | "style" | "linkstyle" | "click" | "direction" => continue,
247
            _ => {}
248
        }
249
        parse_statement(st, &mut graph);
250
        if graph.over_cap {
251
            return None;
252
        }
253
    }
254
255
    if graph.nodes.is_empty() {
256
        return None;
257
    }
258
    Some(graph)
259
}
260
261
fn parse_subgraph_decl(rest: &str) -> (String, String) {
262
    if let Some(q) = rest.strip_prefix('"')
263
        && let Some((label, _)) = q.split_once('"')
264
    {
265
        return (label.to_string(), decode_html_entities(label));
266
    }
267
    if let Some(open) = rest.find('[') {
268
        let id = rest[..open].trim();
269
        let label = rest[open + 1..].trim_end_matches(']').trim();
270
        let label = clean_label(label);
271
        if !id.is_empty() && !label.is_empty() {
272
            return (id.to_string(), label);
273
        }
274
    }
275
    (rest.to_string(), rest.to_string())
276
}
277
278
fn split_statements(line: &str, out: &mut Vec<String>) {
279
    let mut cur = String::new();
280
    let mut in_quotes = false;
281
    let mut chars = line.chars().peekable();
282
    while let Some(c) = chars.next() {
283
        if in_quotes {
284
            if c == '"' {
285
                in_quotes = false;
286
            }
287
            cur.push(c);
288
        } else {
289
            match c {
290
                '"' => {
291
                    in_quotes = true;
292
                    cur.push(c);
293
                }
294
                '%' if chars.peek() == Some(&'%') => break,
295
                ';' => flush_statement(&mut cur, out),
296
                _ => cur.push(c),
297
            }
298
        }
299
    }
300
    flush_statement(&mut cur, out);
301
}
302
303
fn flush_statement(cur: &mut String, out: &mut Vec<String>) {
304
    let trimmed = cur.trim();
305
    if !trimmed.is_empty() {
306
        out.push(trimmed.to_string());
307
    }
308
    cur.clear();
309
}
310
311
fn parse_statement(st: &str, graph: &mut Graph) {
312
    let chars: Vec<char> = st.chars().collect();
313
    let mut i = 0;
314
315
    let Some((mut prev, ni)) = parse_node_group(&chars, i, graph) else {
316
        return;
317
    };
318
    i = ni;
319
320
    loop {
321
        i = skip_spaces(&chars, i);
322
        if i >= chars.len() {
323
            break;
324
        }
325
        let Some((left, right, line, label, ni)) = parse_link(&chars, i) else {
326
            break;
327
        };
328
        i = skip_spaces(&chars, ni);
329
        let Some((next, ni)) = parse_node_group(&chars, i, graph) else {
330
            break;
331
        };
332
        i = ni;
333
        for &f in &prev {
334
            for &t in &next {
335
                if graph.edges.len() >= MAX_EDGES {
336
                    graph.over_cap = true;
337
                    return;
338
                }
339
                let (from, to, head_to, head_from) = if left == Head::Arrow && right != Head::Arrow
340
                {
341
                    (t, f, Head::Arrow, right)
342
                } else {
343
                    (f, t, right, left)
344
                };
345
                graph.edges.push(Edge {
346
                    from,
347
                    to,
348
                    label: label.clone(),
349
                    head_to,
350
                    head_from,
351
                    line,
352
                });
353
            }
354
        }
355
        prev = next;
356
    }
357
}
358
359
fn parse_node_group(
360
    chars: &[char],
361
    start: usize,
362
    graph: &mut Graph,
363
) -> Option<(Vec<usize>, usize)> {
364
    let (first, mut i) = parse_node(chars, start, graph)?;
365
    let mut group = vec![first];
366
    loop {
367
        let j = skip_spaces(chars, i);
368
        if chars.get(j) != Some(&'&') {
369
            break;
370
        }
371
        let (next, k) = parse_node(chars, j + 1, graph)?;
372
        group.push(next);
373
        i = k;
374
    }
375
    Some((group, i))
376
}
377
378
fn skip_spaces(chars: &[char], mut i: usize) -> usize {
379
    while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
380
        i += 1;
381
    }
382
    i
383
}
384
385
fn is_id_char(c: char) -> bool {
386
    c.is_alphanumeric() || c == '_'
387
}
388
389
fn parse_node(chars: &[char], start: usize, graph: &mut Graph) -> Option<(usize, usize)> {
390
    let mut i = skip_spaces(chars, start);
391
    let id_start = i;
392
    while i < chars.len() && is_id_char(chars[i]) {
393
        i += 1;
394
    }
395
    if i == id_start {
396
        return None;
397
    }
398
    let id: String = chars[id_start..i].iter().collect();
399
400
    let (shape, label, after) = match chars.get(i) {
401
        Some('[') => {
402
            if chars.get(i + 1) == Some(&'[') {
403
                read_shape(chars, i + 2, "]]", Shape::Rect)
404
            } else if chars.get(i + 1) == Some(&'(') {
405
                read_shape(chars, i + 2, ")]", Shape::Round)
406
            } else {
407
                read_shape(chars, i + 1, "]", Shape::Rect)
408
            }
409
        }
410
        Some('(') => {
411
            if chars.get(i + 1) == Some(&'(') {
412
                read_shape(chars, i + 2, "))", Shape::Round)
413
            } else if chars.get(i + 1) == Some(&'[') {
414
                read_shape(chars, i + 2, "])", Shape::Round)
415
            } else {
416
                read_shape(chars, i + 1, ")", Shape::Round)
417
            }
418
        }
419
        Some('{') => {
420
            if chars.get(i + 1) == Some(&'{') {
421
                read_shape(chars, i + 2, "}}", Shape::Diamond)
422
            } else {
423
                read_shape(chars, i + 1, "}", Shape::Diamond)
424
            }
425
        }
426
        Some('>') => read_shape(chars, i + 1, "]", Shape::Rect),
427
        _ => (None, None, i),
428
    };
429
430
    let shape = shape.unwrap_or(Shape::Rect);
431
    let label = label.as_deref();
432
    let idx = graph.node_index(&id, label, shape)?;
433
    Some((idx, after))
434
}
435
436
fn read_shape(
437
    chars: &[char],
438
    start: usize,
439
    closer: &str,
440
    shape: Shape,
441
) -> (Option<Shape>, Option<String>, usize) {
442
    let closer: Vec<char> = closer.chars().collect();
443
    let mut i = start;
444
    let mut text = String::new();
445
    let quoted = {
446
        let mut j = start;
447
        while matches!(chars.get(j), Some(' ') | Some('\t')) {
448
            j += 1;
449
        }
450
        chars.get(j) == Some(&'"')
451
    };
452
    let mut in_quotes = false;
453
    while i < chars.len() {
454
        let c = chars[i];
455
        if quoted && c == '"' {
456
            in_quotes = !in_quotes;
457
            text.push(c);
458
            i += 1;
459
            continue;
460
        }
461
        if !in_quotes && chars[i..].starts_with(closer.as_slice()) {
462
            let label = clean_label(&text);
463
            return (Some(shape), Some(label), i + closer.len());
464
        }
465
        text.push(c);
466
        i += 1;
467
    }
468
    (Some(shape), Some(clean_label(&text)), chars.len())
469
}
470
471
fn clean_label(raw: &str) -> String {
472
    let stripped = strip_html_tags(raw.trim());
473
    let trimmed = stripped.trim();
474
    let unquoted = trimmed
475
        .strip_prefix('"')
476
        .and_then(|t| t.strip_suffix('"'))
477
        .or_else(|| {
478
            trimmed
479
                .strip_prefix('\'')
480
                .and_then(|t| t.strip_suffix('\''))
481
        })
482
        .unwrap_or(trimmed)
483
        .trim();
484
    let text = if let Some(md) = unquoted.strip_prefix('`').and_then(|t| t.strip_suffix('`')) {
485
        strip_markdown(md.trim())
486
    } else {
487
        unquoted.to_string()
488
    };
489
    // Decode after tag-stripping so `<b>` is removed as markup while `&lt;b&gt;`
490
    // survives as a literal `<b>`; one decode at the single return covers both paths.
491
    decode_html_entities(&text)
492
}
493
494
const ENTITY_LOOKAHEAD: usize = 10;
495
496
// Label text decodes HTML entities once: via clean_label for bracketed labels, or explicitly at each direct-push sink.
497
fn decode_html_entities(s: &str) -> String {
498
    if !s.contains('&') {
499
        return s.to_string();
500
    }
501
    let chars: Vec<char> = s.chars().collect();
502
    let mut out = String::with_capacity(s.len());
503
    let mut i = 0;
504
    while i < chars.len() {
505
        if chars[i] != '&' {
506
            out.push(chars[i]);
507
            i += 1;
508
            continue;
509
        }
510
        // Scan window (includes the terminating `;`) so a stray `&` or over-long run stays literal.
511
        let hi = (i + 1 + ENTITY_LOOKAHEAD).min(chars.len());
512
        let semi = (i + 1..hi).find(|&j| chars[j] == ';');
513
        let decoded = semi.and_then(|j| {
514
            let body: String = chars[i + 1..j].iter().collect();
515
            decode_entity_body(&body).map(|c| (c, j))
516
        });
517
        match decoded {
518
            // Resume past the `;`; the single pass never re-scans emitted text, so
519
            // `&amp;lt;` decodes to the literal `&lt;` rather than to `<`.
520
            Some((c, j)) => {
521
                out.push(c);
522
                i = j + 1;
523
            }
524
            None => {
525
                out.push('&');
526
                i += 1;
527
            }
528
        }
529
    }
530
    out
531
}
532
533
fn decode_entity_body(body: &str) -> Option<char> {
534
    match body {
535
        "lt" => Some('<'),
536
        "gt" => Some('>'),
537
        "amp" => Some('&'),
538
        "quot" => Some('"'),
539
        "apos" => Some('\''),
540
        _ => {
541
            let num = body.strip_prefix('#')?;
542
            let code = match num.strip_prefix(['x', 'X']) {
543
                Some(hex) => u32::from_str_radix(hex, 16).ok()?,
544
                None => num.parse::<u32>().ok()?,
545
            };
546
            // Reject control chars: NUL collides with the CONT sentinel and ESC would inject ANSI into scrollback.
547
            char::from_u32(code).filter(|c| !c.is_control())
548
        }
549
    }
550
}
551
552
fn strip_markdown(s: &str) -> String {
553
    let no_code: String = s.chars().filter(|&c| c != '`').collect();
554
    let no_strong = no_code.replace("**", "").replace("__", "");
555
    let chars: Vec<char> = no_strong.chars().collect();
556
    let mut out = String::with_capacity(no_strong.len());
557
    for (i, &c) in chars.iter().enumerate() {
558
        if (c == '*' || c == '_')
559
            && !(i > 0
560
                && chars[i - 1].is_alphanumeric()
561
                && chars.get(i + 1).is_some_and(|n| n.is_alphanumeric()))
562
        {
563
            continue;
564
        }
565
        out.push(c);
566
    }
567
    out.trim().to_string()
568
}
569
570
const HTML_FORMAT_TAGS: &[&str] = &[
571
    "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "mark", "small", "big", "sub",
572
    "sup", "code", "kbd", "samp", "var", "tt", "span", "font", "q", "abbr", "cite", "pre",
573
];
574
575
fn strip_html_tags(s: &str) -> String {
576
    let chars: Vec<char> = s.chars().collect();
577
    let mut out = String::with_capacity(s.len());
578
    let mut i = 0;
579
    while i < chars.len() {
580
        if chars[i] == '<'
581
            && let Some((name, end)) = html_tag_at(&chars, i)
582
        {
583
            let lower = name.to_ascii_lowercase();
584
            if lower == "br" {
585
                out.push(' ');
586
                i = end;
587
                continue;
588
            }
589
            if HTML_FORMAT_TAGS.contains(&lower.as_str()) {
590
                i = end;
591
                continue;
592
            }
593
        }
594
        out.push(chars[i]);
595
        i += 1;
596
    }
597
    out
598
}
599
600
fn html_tag_at(chars: &[char], start: usize) -> Option<(String, usize)> {
601
    let mut i = start + 1;
602
    if chars.get(i) == Some(&'/') {
603
        i += 1;
604
    }
605
    let name_start = i;
606
    while i < chars.len() && chars[i].is_ascii_alphanumeric() {
607
        i += 1;
608
    }
609
    if i == name_start {
610
        return None;
611
    }
612
    let name: String = chars[name_start..i].iter().collect();
613
    while i < chars.len() && chars[i] != '>' {
614
        if chars[i] == '<' {
615
            return None;
616
        }
617
        i += 1;
618
    }
619
    if chars.get(i) == Some(&'>') {
620
        Some((name, i + 1))
621
    } else {
622
        None
623
    }
624
}
625
626
fn is_link_char(c: char) -> bool {
627
    matches!(c, '-' | '.' | '=' | '<' | '>')
628
}
629
630
fn parse_link(
631
    chars: &[char],
632
    start: usize,
633
) -> Option<(Head, Head, LineKind, Option<String>, usize)> {
634
    let mut i = skip_spaces(chars, start);
635
    let mut left = Head::None;
636
    if let Some(&c) = chars.get(i)
637
        && matches!(c, 'o' | 'x')
638
        && matches!(chars.get(i + 1), Some('-' | '.' | '='))
639
    {
640
        left = if c == 'o' { Head::Circle } else { Head::Cross };
641
        i += 1;
642
    }
643
    let op_start = i;
644
    while i < chars.len() && matches!(chars[i], '-' | '.' | '=' | '<' | '>') {
645
        i += 1;
646
    }
647
    if i == op_start {
648
        return None;
649
    }
650
    let op1: String = chars[op_start..i].iter().collect();
651
    if left == Head::None && op1.starts_with('<') {
652
        left = Head::Arrow;
653
    }
654
    let mut line = line_kind(&op1);
655
    let mut right = if op1.contains('>') {
656
        Head::Arrow
657
    } else {
658
        Head::None
659
    };
660
    if right == Head::None
661
        && let Some((head, ni)) = trailing_head(chars, i)
662
    {
663
        right = head;
664
        i = ni;
665
    }
666
667
    if chars.get(i) == Some(&'|') {
668
        i += 1;
669
        let l_start = i;
670
        while i < chars.len() && chars[i] != '|' {
671
            i += 1;
672
        }
673
        let label = clean_label(&chars[l_start..i].iter().collect::<String>());
674
        if chars.get(i) == Some(&'|') {
675
            i += 1;
676
        }
677
        return Some((left, right, line, non_empty(label), i));
678
    }
679
680
    if right == Head::None {
681
        let text_start = skip_spaces(chars, i);
682
        let mut j = text_start;
683
        while j < chars.len() && !is_link_char(chars[j]) {
684
            j += 1;
685
        }
686
        if j < chars.len() && j > text_start && matches!(chars[j], '-' | '.' | '=' | '>') {
687
            let text: String = chars[text_start..j].iter().collect();
688
            let op2_start = j;
689
            while j < chars.len() && is_link_char(chars[j]) {
690
                j += 1;
691
            }
692
            let op2: String = chars[op2_start..j].iter().collect();
693
            right = if op2.contains('>') {
694
                Head::Arrow
695
            } else if let Some((head, nj)) = trailing_head(chars, j) {
696
                j = nj;
697
                head
698
            } else {
699
                Head::None
700
            };
701
            if line == LineKind::Solid {
702
                line = line_kind(&op2);
703
            }
704
            return Some((left, right, line, non_empty(clean_label(&text)), j));
705
        }
706
    }
707
708
    Some((left, right, line, None, i))
709
}
710
711
fn line_kind(op: &str) -> LineKind {
712
    if op.contains('=') {
713
        LineKind::Thick
714
    } else if op.contains('.') {
715
        LineKind::Dotted
716
    } else {
717
        LineKind::Solid
718
    }
719
}
720
721
fn trailing_head(chars: &[char], i: usize) -> Option<(Head, usize)> {
722
    let head = match chars.get(i) {
723
        Some('o') => Head::Circle,
724
        Some('x') => Head::Cross,
725
        _ => return None,
726
    };
727
    match chars.get(i + 1) {
728
        None | Some(' ') | Some('\t') | Some('|') | Some('&') | Some(';') => Some((head, i + 1)),
729
        _ => None,
730
    }
731
}
732
733
fn non_empty(s: String) -> Option<String> {
734
    if s.is_empty() { None } else { Some(s) }
735
}
736
737
fn parse_state(src: &str) -> Option<Graph> {
738
    let mut statements: Vec<String> = Vec::new();
739
    for raw_line in src.lines() {
740
        split_statements(raw_line, &mut statements);
741
    }
742
    let header = statements.first()?;
743
    if !header
744
        .split_whitespace()
745
        .next()?
746
        .to_ascii_lowercase()
747
        .starts_with("statediagram")
748
    {
749
        return None;
750
    }
751
752
    let mut graph = Graph {
753
        nodes: Vec::new(),
754
        edges: Vec::new(),
755
        index: HashMap::new(),
756
        groups: Vec::new(),
757
        node_group: Vec::new(),
758
        cur_group: None,
759
        over_cap: false,
760
        dir: Dir::Down,
761
    };
762
763
    let mut in_note = false;
764
    for st in &statements[1..] {
765
        if in_note {
766
            if st.eq_ignore_ascii_case("end note") {
767
                in_note = false;
768
            }
769
            continue;
770
        }
771
        let first = st.split_whitespace().next().unwrap_or("");
772
        match first.to_ascii_lowercase().as_str() {
773
            "direction" => {
774
                graph.dir = match st
775
                    .split_whitespace()
776
                    .nth(1)
777
                    .unwrap_or("")
778
                    .to_ascii_uppercase()
779
                    .as_str()
780
                {
781
                    "LR" => Dir::Right,
782
                    "RL" => Dir::Left,
783
                    "BT" => Dir::Up,
784
                    _ => Dir::Down,
785
                };
786
            }
787
            "note" => {
788
                if !st.contains(':') {
789
                    in_note = true;
790
                }
791
            }
792
            "state" => parse_state_decl(st, &mut graph)?,
793
            "classdef" | "class" | "hide" | "scale" | "}" | "--" => {}
794
            _ => {
795
                if st.contains("-->") {
796
                    parse_transition(st, &mut graph)?;
797
                } else {
798
                    parse_state_desc(st, &mut graph)?;
799
                }
800
            }
801
        }
802
        if graph.over_cap {
803
            return None;
804
        }
805
    }
806
807
    if graph.nodes.is_empty() {
808
        return None;
809
    }
810
    Some(graph)
811
}
812
813
fn parse_state_decl(st: &str, graph: &mut Graph) -> Option<()> {
814
    let rest = st["state".len()..].trim().trim_end_matches('{').trim();
815
    if rest.is_empty() {
816
        return Some(());
817
    }
818
    if let Some(q) = rest.strip_prefix('"') {
819
        let (label, after) = q.split_once('"')?;
820
        let id = after
821
            .trim()
822
            .strip_prefix("as")
823
            .map(str::trim)
824
            .unwrap_or(label);
825
        graph.node_label(id, &decode_html_entities(label))?;
826
        return Some(());
827
    }
828
    let mut shape = Shape::Round;
829
    let mut id = rest;
830
    let mut stereotyped = false;
831
    if let Some(pos) = rest.find("<<") {
832
        let stereo = rest[pos + 2..].trim_end_matches(">>").trim();
833
        if stereo == "choice" {
834
            shape = Shape::Diamond;
835
        }
836
        id = rest[..pos].trim();
837
        stereotyped = true;
838
    }
839
    if id.is_empty() || id.contains(char::is_whitespace) {
840
        return None;
841
    }
842
    let label = if stereotyped { Some(id) } else { None };
843
    graph.node_index(id, label, shape)?;
844
    Some(())
845
}
846
847
fn parse_transition(st: &str, graph: &mut Graph) -> Option<()> {
848
    let mut rest = st;
849
    let mut prev: Option<usize> = None;
850
    while let Some((lhs, rhs)) = rest.split_once("-->") {
851
        let from_id = lhs.trim_end().trim_end_matches('-').trim();
852
        let from = match prev {
853
            Some(p) => {
854
                if !from_id.is_empty() {
855
                    return None;
856
                }
857
                p
858
            }
859
            None => {
860
                if from_id.is_empty() {
861
                    return None;
862
                }
863
                state_endpoint(graph, from_id, true)?
864
            }
865
        };
866
        let (to_part, tail) = match rhs.split_once("-->") {
867
            Some((t, _)) => (t, &rhs[t.len()..]),
868
            None => (rhs, ""),
869
        };
870
        let (to_part, label) = match to_part.split_once(':') {
871
            Some((t, l)) => (t, non_empty(decode_html_entities(l.trim()))),
872
            None => (to_part, None),
873
        };
874
        let to_id = to_part
875
            .trim_start()
876
            .trim_start_matches('>')
877
            .trim_end()
878
            .trim_end_matches('-')
879
            .trim();
880
        if to_id.is_empty() {
881
            return None;
882
        }
883
        let to = state_endpoint(graph, to_id, false)?;
884
        if graph.edges.len() >= MAX_EDGES {
885
            graph.over_cap = true;
886
            return Some(());
887
        }
888
        graph.edges.push(Edge {
889
            from,
890
            to,
891
            label,
892
            head_to: Head::Arrow,
893
            head_from: Head::None,
894
            line: LineKind::Solid,
895
        });
896
        prev = Some(to);
897
        rest = tail;
898
    }
899
    Some(())
900
}
901
902
fn state_endpoint(graph: &mut Graph, id: &str, is_source: bool) -> Option<usize> {
903
    if id == "[*]" {
904
        let key = if is_source { "[*]start" } else { "[*]end" };
905
        return graph.node_index(key, Some("●"), Shape::Round);
906
    }
907
    graph.node_index(id, None, Shape::Round)
908
}
909
910
fn parse_state_desc(st: &str, graph: &mut Graph) -> Option<()> {
911
    if let Some((id, desc)) = st.split_once(':') {
912
        let id = id.trim();
913
        let desc = desc.trim();
914
        if id.is_empty() || id.contains(char::is_whitespace) || desc.is_empty() {
915
            return None;
916
        }
917
        graph.node_label(id, &decode_html_entities(desc))?;
918
    } else if !st.contains(char::is_whitespace) {
919
        graph.node_index(st, None, Shape::Round)?;
920
    } else {
921
        return None;
922
    }
923
    Some(())
924
}
925
926
const MAX_MEMBERS: usize = 8;
927
const CLASS_OPS: &[(&str, Head, Head, LineKind)] = &[
928
    ("<|--", Head::Triangle, Head::None, LineKind::Solid),
929
    ("--|>", Head::None, Head::Triangle, LineKind::Solid),
930
    ("<|..", Head::Triangle, Head::None, LineKind::Dotted),
931
    ("..|>", Head::None, Head::Triangle, LineKind::Dotted),
932
    ("*--", Head::DiamondFill, Head::None, LineKind::Solid),
933
    ("--*", Head::None, Head::DiamondFill, LineKind::Solid),
934
    ("o--", Head::DiamondOpen, Head::None, LineKind::Solid),
935
    ("--o", Head::None, Head::DiamondOpen, LineKind::Solid),
936
    ("<--", Head::Arrow, Head::None, LineKind::Solid),
937
    ("-->", Head::None, Head::Arrow, LineKind::Solid),
938
    ("<..", Head::Arrow, Head::None, LineKind::Dotted),
939
    ("..>", Head::None, Head::Arrow, LineKind::Dotted),
940
    ("--", Head::None, Head::None, LineKind::Solid),
941
    ("..", Head::None, Head::None, LineKind::Dotted),
942
];
943
944
#[derive(Default, Clone)]
945
struct ClassInfo {
946
    annotation: Option<String>,
947
    attrs: Vec<String>,
948
    methods: Vec<String>,
949
}
950
951
fn parse_class(src: &str) -> Option<(Graph, Vec<ClassInfo>)> {
952
    let mut statements: Vec<String> = Vec::new();
953
    for raw_line in src.lines() {
954
        split_statements(raw_line, &mut statements);
955
    }
956
    let header = statements.first()?;
957
    if !header
958
        .split_whitespace()
959
        .next()?
960
        .to_ascii_lowercase()
961
        .starts_with("classdiagram")
962
    {
963
        return None;
964
    }
965
966
    let mut graph = Graph {
967
        nodes: Vec::new(),
968
        edges: Vec::new(),
969
        index: HashMap::new(),
970
        groups: Vec::new(),
971
        node_group: Vec::new(),
972
        cur_group: None,
973
        over_cap: false,
974
        dir: Dir::Down,
975
    };
976
    let mut infos: Vec<ClassInfo> = Vec::new();
977
    let mut cur_class: Option<usize> = None;
978
979
    for st in &statements[1..] {
980
        if let Some(ci) = cur_class {
981
            if st == "}" {
982
                cur_class = None;
983
            } else {
984
                push_member(&mut infos[ci], st);
985
            }
986
            continue;
987
        }
988
        let first = st.split_whitespace().next().unwrap_or("");
989
        match first.to_ascii_lowercase().as_str() {
990
            "direction" => {
991
                graph.dir = match st
992
                    .split_whitespace()
993
                    .nth(1)
994
                    .unwrap_or("")
995
                    .to_ascii_uppercase()
996
                    .as_str()
997
                {
998
                    "LR" => Dir::Right,
999
                    "RL" => Dir::Left,
1000
                    "BT" => Dir::Up,
1001
                    _ => Dir::Down,
1002
                };
1003
                continue;
1004
            }
1005
            "note" | "callback" | "click" | "link" | "style" | "cssclass" | "classdef"
1006
            | "namespace" | "}" => continue,
1007
            "class" => {
1008
                let rest = st["class".len()..].trim();
1009
                let (name, open) = match rest.strip_suffix('{') {
1010
                    Some(n) => (n.trim(), true),
1011
                    None => (rest, false),
1012
                };
1013
                if name.is_empty() || name.contains(char::is_whitespace) {
1014
                    return None;
1015
                }
1016
                let idx = graph.node_index(name, None, Shape::Rect)?;
1017
                sync_infos(&graph, &mut infos);
1018
                if open {
1019
                    cur_class = Some(idx);
1020
                }
1021
                continue;
1022
            }
1023
            _ => {}
1024
        }
1025
        if let Some(ann) = st.strip_prefix("<<") {
1026
            let (ann, rest) = ann.split_once(">>")?;
1027
            let name = rest.trim();
1028
            if name.is_empty() || name.contains(char::is_whitespace) {
1029
                return None;
1030
            }
1031
            let idx = graph.node_index(name, None, Shape::Rect)?;
1032
            sync_infos(&graph, &mut infos);
1033
            infos[idx].annotation = Some(ann.trim().to_string());
1034
            continue;
1035
        }
1036
        if let Some((from, to, head_from, head_to, line, label)) = parse_class_relation(st) {
1037
            let f = graph.node_index(&from, None, Shape::Rect)?;
1038
            sync_infos(&graph, &mut infos);
1039
            let t = graph.node_index(&to, None, Shape::Rect)?;
1040
            sync_infos(&graph, &mut infos);
1041
            if graph.edges.len() >= MAX_EDGES {
1042
                return None;
1043
            }
1044
            graph.edges.push(Edge {
1045
                from: f,
1046
                to: t,
1047
                label,
1048
                head_to,
1049
                head_from,
1050
                line,
1051
            });
1052
            continue;
1053
        }
1054
        if let Some((id, member)) = st.split_once(':') {
1055
            let id = id.trim();
1056
            let member = member.trim();
1057
            if id.is_empty() || id.contains(char::is_whitespace) || member.is_empty() {
1058
                return None;
1059
            }
1060
            let idx = graph.node_index(id, None, Shape::Rect)?;
1061
            sync_infos(&graph, &mut infos);
1062
            push_member(&mut infos[idx], member);
1063
            continue;
1064
        }
1065
        return None;
1066
    }
1067
1068
    if graph.nodes.is_empty() {
1069
        return None;
1070
    }
1071
    sync_infos(&graph, &mut infos);
1072
    Some((graph, infos))
1073
}
1074
1075
fn sync_infos(graph: &Graph, infos: &mut Vec<ClassInfo>) {
1076
    while infos.len() < graph.nodes.len() {
1077
        infos.push(ClassInfo::default());
1078
    }
1079
}
1080
1081
fn push_member(info: &mut ClassInfo, raw: &str) {
1082
    if let Some(ann) = raw.strip_prefix("<<") {
1083
        if let Some((ann, _)) = ann.split_once(">>") {
1084
            info.annotation = Some(ann.trim().to_string());
1085
        }
1086
        return;
1087
    }
1088
    let member = decode_html_entities(&display_generics(raw.trim()));
1089
    let list = if member.contains('(') {
1090
        &mut info.methods
1091
    } else {
1092
        &mut info.attrs
1093
    };
1094
    if list.len() < MAX_MEMBERS {
1095
        list.push(member);
1096
    } else if list.len() == MAX_MEMBERS {
1097
        list.push("…".to_string());
1098
    }
1099
}
1100
1101
fn parse_class_relation(
1102
    st: &str,
1103
) -> Option<(String, String, Head, Head, LineKind, Option<String>)> {
1104
    let chars: Vec<char> = st.chars().collect();
1105
    let mut found: Option<(usize, &str, Head, Head, LineKind)> = None;
1106
    'outer: for pos in 0..chars.len() {
1107
        for &(op, hf, ht, line) in CLASS_OPS {
1108
            if st[char_byte(st, pos)..].starts_with(op) {
1109
                if op.starts_with('o') && pos > 0 && is_id_char(chars[pos - 1]) {
1110
                    continue;
1111
                }
1112
                if op.ends_with('o')
1113
                    && chars
1114
                        .get(pos + op.chars().count())
1115
                        .is_some_and(|&c| is_id_char(c))
1116
                {
1117
                    continue;
1118
                }
1119
                found = Some((pos, op, hf, ht, line));
1120
                break 'outer;
1121
            }
1122
        }
1123
    }
1124
    let (pos, op, head_from, head_to, line) = found?;
1125
    let lhs = st[..char_byte(st, pos)].trim();
1126
    let rhs = st[char_byte(st, pos) + op.len()..].trim();
1127
1128
    let (lhs, card_from) = strip_cardinality_suffix(lhs);
1129
    let (rhs, card_to) = strip_cardinality_prefix(rhs);
1130
    let (to_id, rel_label) = match rhs.split_once(':') {
1131
        Some((t, l)) => (t.trim(), non_empty(decode_html_entities(l.trim()))),
1132
        None => (rhs.trim(), None),
1133
    };
1134
    if lhs.is_empty()
1135
        || to_id.is_empty()
1136
        || lhs.contains(char::is_whitespace)
1137
        || to_id.contains(char::is_whitespace)
1138
    {
1139
        return None;
1140
    }
1141
    let label = non_empty(
1142
        [card_from, rel_label.unwrap_or_default(), card_to]
1143
            .iter()
1144
            .filter(|s| !s.is_empty())
1145
            .cloned()
1146
            .collect::<Vec<_>>()
1147
            .join(" "),
1148
    );
1149
    Some((
1150
        lhs.to_string(),
1151
        to_id.to_string(),
1152
        head_from,
1153
        head_to,
1154
        line,
1155
        label,
1156
    ))
1157
}
1158
1159
fn char_byte(s: &str, char_pos: usize) -> usize {
1160
    s.char_indices()
1161
        .nth(char_pos)
1162
        .map(|(b, _)| b)
1163
        .unwrap_or(s.len())
1164
}
1165
1166
fn strip_cardinality_suffix(s: &str) -> (&str, String) {
1167
    let t = s.trim_end();
1168
    if let Some(rest) = t.strip_suffix('"')
1169
        && let Some(q) = rest.rfind('"')
1170
    {
1171
        return (rest[..q].trim_end(), rest[q + 1..].to_string());
1172
    }
1173
    (t, String::new())
1174
}
1175
1176
fn strip_cardinality_prefix(s: &str) -> (&str, String) {
1177
    let t = s.trim_start();
1178
    if let Some(rest) = t.strip_prefix('"')
1179
        && let Some(q) = rest.find('"')
1180
    {
1181
        return (rest[q + 1..].trim_start(), rest[..q].to_string());
1182
    }
1183
    (t, String::new())
1184
}
1185
1186
fn display_generics(s: &str) -> String {
1187
    let mut out = String::with_capacity(s.len());
1188
    let mut open = false;
1189
    for c in s.chars() {
1190
        if c == '~' {
1191
            out.push(if open { '>' } else { '<' });
1192
            open = !open;
1193
        } else {
1194
            out.push(c);
1195
        }
1196
    }
1197
    out
1198
}
1199
1200
fn parse_er(src: &str) -> Option<(Graph, Vec<ClassInfo>)> {
1201
    let mut statements: Vec<String> = Vec::new();
1202
    for raw_line in src.lines() {
1203
        split_statements(raw_line, &mut statements);
1204
    }
1205
    let header = statements.first()?;
1206
    if !header
1207
        .split_whitespace()
1208
        .next()?
1209
        .eq_ignore_ascii_case("erdiagram")
1210
    {
1211
        return None;
1212
    }
1213
1214
    let mut graph = Graph {
1215
        nodes: Vec::new(),
1216
        edges: Vec::new(),
1217
        index: HashMap::new(),
1218
        groups: Vec::new(),
1219
        node_group: Vec::new(),
1220
        cur_group: None,
1221
        over_cap: false,
1222
        dir: Dir::Down,
1223
    };
1224
    let mut infos: Vec<ClassInfo> = Vec::new();
1225
    let mut cur_entity: Option<usize> = None;
1226
1227
    for st in &statements[1..] {
1228
        if let Some(ei) = cur_entity {
1229
            if st == "}" {
1230
                cur_entity = None;
1231
            } else {
1232
                push_er_attribute(&mut infos[ei], st);
1233
            }
1234
            continue;
1235
        }
1236
        if let Some((rel, label_part)) = split_er_relationship(st) {
1237
            let tokens: Vec<&str> = rel.split_whitespace().collect();
1238
            let [lhs, op, rhs] = tokens.as_slice() else {
1239
                return None;
1240
            };
1241
            let (card_l, card_r, line) = parse_er_op(op)?;
1242
            let f = er_entity(&mut graph, &mut infos, lhs)?;
1243
            let t = er_entity(&mut graph, &mut infos, rhs)?;
1244
            if graph.edges.len() >= MAX_EDGES {
1245
                return None;
1246
            }
1247
            let rel_label = label_part.map(clean_label).unwrap_or_default();
1248
            let label = non_empty(
1249
                [card_l.to_string(), rel_label, card_r.to_string()]
1250
                    .iter()
1251
                    .filter(|s| !s.is_empty())
1252
                    .cloned()
1253
                    .collect::<Vec<_>>()
1254
                    .join(" "),
1255
            );
1256
            graph.edges.push(Edge {
1257
                from: f,
1258
                to: t,
1259
                label,
1260
                head_to: Head::None,
1261
                head_from: Head::None,
1262
                line,
1263
            });
1264
            continue;
1265
        }
1266
        let (decl, open) = match st.strip_suffix('{') {
1267
            Some(d) => (d.trim(), true),
1268
            None => (st.as_str(), false),
1269
        };
1270
        if decl.is_empty() || decl.split_whitespace().count() != 1 {
1271
            return None;
1272
        }
1273
        let idx = er_entity(&mut graph, &mut infos, decl)?;
1274
        if open {
1275
            cur_entity = Some(idx);
1276
        }
1277
    }
1278
1279
    if graph.nodes.is_empty() {
1280
        return None;
1281
    }
1282
    sync_infos(&graph, &mut infos);
1283
    Some((graph, infos))
1284
}
1285
1286
fn er_entity(graph: &mut Graph, infos: &mut Vec<ClassInfo>, token: &str) -> Option<usize> {
1287
    let idx = if let Some(open) = token.find('[') {
1288
        let id = &token[..open];
1289
        let label = clean_label(token[open + 1..].trim_end_matches(']'));
1290
        if id.is_empty() || label.is_empty() {
1291
            return None;
1292
        }
1293
        graph.node_label(id, &label)?
1294
    } else {
1295
        graph.node_index(token, None, Shape::Rect)?
1296
    };
1297
    sync_infos(graph, infos);
1298
    Some(idx)
1299
}
1300
1301
fn split_er_relationship(st: &str) -> Option<(&str, Option<&str>)> {
1302
    let (rel, label) = match st.split_once(':') {
1303
        Some((r, l)) => (r, Some(l.trim())),
1304
        None => (st, None),
1305
    };
1306
    let has_op = rel.split_whitespace().any(|t| parse_er_op(t).is_some());
1307
    if has_op { Some((rel, label)) } else { None }
1308
}
1309
1310
fn parse_er_op(tok: &str) -> Option<(&'static str, &'static str, LineKind)> {
1311
    if !tok.is_ascii() || tok.len() != 6 {
1312
        return None;
1313
    }
1314
    let line = match &tok[2..4] {
1315
        "--" => LineKind::Solid,
1316
        ".." => LineKind::Dotted,
1317
        _ => return None,
1318
    };
1319
    Some((er_card(&tok[..2])?, er_card(&tok[4..6])?, line))
1320
}
1321
1322
fn er_card(tok: &str) -> Option<&'static str> {
1323
    match tok {
1324
        "|o" | "o|" => Some("0..1"),
1325
        "||" => Some("1"),
1326
        "}o" | "o{" => Some("0..*"),
1327
        "}|" | "|{" => Some("1..*"),
1328
        _ => None,
1329
    }
1330
}
1331
1332
fn push_er_attribute(info: &mut ClassInfo, raw: &str) {
1333
    let mut parts: Vec<&str> = Vec::new();
1334
    for tok in raw.split_whitespace() {
1335
        if tok.starts_with('"') {
1336
            break;
1337
        }
1338
        parts.push(tok);
1339
    }
1340
    if parts.is_empty() {
1341
        return;
1342
    }
1343
    let line = decode_html_entities(&parts.join(" "));
1344
    if info.attrs.len() < MAX_MEMBERS {
1345
        info.attrs.push(line);
1346
    } else if info.attrs.len() == MAX_MEMBERS {
1347
        info.attrs.push("…".to_string());
1348
    }
1349
}
1350
1351
fn render_class(
1352
    graph: &Graph,
1353
    infos: &[ClassInfo],
1354
    styles: &MermaidStyles,
1355
    max_width: Option<usize>,
1356
) -> Result<MermaidArt, Oversize> {
1357
    let extras: Vec<NodeExtra> = graph
1358
        .nodes
1359
        .iter()
1360
        .zip(infos)
1361
        .map(|(node, info)| {
1362
            let mut title = Vec::new();
1363
            if let Some(a) = &info.annotation {
1364
                title.push(format!("«{a}»"));
1365
            }
1366
            title.push(display_generics(&node.label));
1367
            NodeExtra::Compartments(vec![title, info.attrs.clone(), info.methods.clone()])
1368
        })
1369
        .collect();
1370
    let mut canvas = layout_canvas(graph, &extras, max_width)?;
1371
    match graph.dir {
1372
        Dir::Up => canvas.flip_vertical(),
1373
        Dir::Left => canvas.flip_horizontal(),
1374
        _ => {}
1375
    }
1376
    let (styled_lines, plain_lines) = canvas.to_lines(styles);
1377
    Ok(MermaidArt {
1378
        styled_lines,
1379
        plain_lines,
1380
    })
1381
}
1382
1383
const U: u8 = 1;
1384
const D: u8 = 2;
1385
const L: u8 = 4;
1386
const R: u8 = 8;
1387
1388
#[derive(Clone, Copy, PartialEq)]
1389
enum Cls {
1390
    Empty,
1391
    Border,
1392
    Text,
1393
    Edge,
1394
    EdgeLabel,
1395
}
1396
1397
const STY_DOT: u8 = 1;
1398
const STY_THICK: u8 = 2;
1399
const STY_SOLID: u8 = 4;
1400
1401
struct Canvas {
1402
    w: usize,
1403
    h: usize,
1404
    ch: Vec<char>,
1405
    cls: Vec<Cls>,
1406
    mask: Vec<u8>,
1407
    style: Vec<u8>,
1408
    occupied: Vec<bool>,
1409
    cur_style: u8,
1410
}
1411
1412
impl Canvas {
1413
    fn new(w: usize, h: usize) -> Self {
1414
        let n = w * h;
1415
        Self {
1416
            w,
1417
            h,
1418
            ch: vec![' '; n],
1419
            cls: vec![Cls::Empty; n],
1420
            mask: vec![0; n],
1421
            style: vec![0; n],
1422
            occupied: vec![false; n],
1423
            cur_style: STY_SOLID,
1424
        }
1425
    }
1426
1427
    fn idx(&self, x: usize, y: usize) -> usize {
1428
        y * self.w + x
1429
    }
1430
1431
    fn set(&mut self, x: usize, y: usize, c: char, cls: Cls) {
1432
        if x >= self.w || y >= self.h {
1433
            return;
1434
        }
1435
        let i = self.idx(x, y);
1436
        self.ch[i] = c;
1437
        self.cls[i] = cls;
1438
    }
1439
1440
    fn add_bits(&mut self, x: usize, y: usize, bits: u8) {
1441
        if x >= self.w || y >= self.h {
1442
            return;
1443
        }
1444
        let i = self.idx(x, y);
1445
        if self.occupied[i] {
1446
            return;
1447
        }
1448
        self.mask[i] |= bits;
1449
        self.style[i] |= self.cur_style;
1450
        if self.cls[i] != Cls::Border {
1451
            self.cls[i] = Cls::Edge;
1452
        }
1453
    }
1454
1455
    fn blit(&mut self, sub: &Canvas, ox: usize, oy: usize) {
1456
        for sy in 0..sub.h {
1457
            for sx in 0..sub.w {
1458
                let (x, y) = (ox + sx, oy + sy);
1459
                if x >= self.w || y >= self.h {
1460
                    continue;
1461
                }
1462
                let si = sub.idx(sx, sy);
1463
                let di = self.idx(x, y);
1464
                self.ch[di] = sub.ch[si];
1465
                self.cls[di] = sub.cls[si];
1466
                self.style[di] = sub.style[si];
1467
                self.occupied[di] = true;
1468
            }
1469
        }
1470
    }
1471
1472
    fn junction(&mut self, x: usize, y: usize, bits: u8) {
1473
        if x >= self.w || y >= self.h {
1474
            return;
1475
        }
1476
        let i = self.idx(x, y);
1477
        self.mask[i] |= bits;
1478
        if self.cls[i] != Cls::Border {
1479
            self.cls[i] = Cls::Edge;
1480
        }
1481
    }
1482
1483
    fn seg_v(&mut self, x: usize, y0: usize, y1: usize) {
1484
        let (a, b) = (y0.min(y1), y0.max(y1));
1485
        for y in a..=b {
1486
            let mut bits = 0;
1487
            if y > a {
1488
                bits |= U;
1489
            }
1490
            if y < b {
1491
                bits |= D;
1492
            }
1493
            self.add_bits(x, y, bits);
1494
        }
1495
    }
1496
1497
    fn seg_h(&mut self, y: usize, x0: usize, x1: usize) {
1498
        let (a, b) = (x0.min(x1), x0.max(x1));
1499
        for x in a..=b {
1500
            let mut bits = 0;
1501
            if x > a {
1502
                bits |= L;
1503
            }
1504
            if x < b {
1505
                bits |= R;
1506
            }
1507
            self.add_bits(x, y, bits);
1508
        }
1509
    }
1510
1511
    fn finalize_mask(&mut self) {
1512
        for i in 0..self.ch.len() {
1513
            if self.mask[i] != 0 && self.ch[i] == ' ' {
1514
                let c = mask_char(self.mask[i]);
1515
                self.ch[i] = match self.style[i] {
1516
                    STY_DOT => dotted_char(c),
1517
                    STY_THICK => thick_char(c),
1518
                    _ => c,
1519
                };
1520
            }
1521
        }
1522
    }
1523
1524
    /// Mirror top-to-bottom for `BT` (rows reorder; within-row text is
1525
    /// unaffected, so labels stay readable). Box-drawing glyphs flip too.
1526
    fn flip_vertical(&mut self) {
1527
        for y in 0..self.h / 2 {
1528
            let y2 = self.h - 1 - y;
1529
            for x in 0..self.w {
1530
                let (i, j) = (self.idx(x, y), self.idx(x, y2));
1531
                self.ch.swap(i, j);
1532
                self.cls.swap(i, j);
1533
            }
1534
        }
1535
        for c in self.ch.iter_mut() {
1536
            *c = flip_glyph_v(*c);
1537
        }
1538
    }
1539
1540
    /// Mirror left-to-right for `RL`. Mirroring reverses each row, so after
1541
    /// flipping glyphs we reverse each text/label run back to reading order.
1542
    fn flip_horizontal(&mut self) {
1543
        for y in 0..self.h {
1544
            for x in 0..self.w / 2 {
1545
                let x2 = self.w - 1 - x;
1546
                let (i, j) = (self.idx(x, y), self.idx(x2, y));
1547
                self.ch.swap(i, j);
1548
                self.cls.swap(i, j);
1549
            }
1550
        }
1551
        for c in self.ch.iter_mut() {
1552
            *c = flip_glyph_h(*c);
1553
        }
1554
        for y in 0..self.h {
1555
            let mut x = 0;
1556
            while x < self.w {
1557
                let cls = self.cls[self.idx(x, y)];
1558
                if cls == Cls::Text || cls == Cls::EdgeLabel {
1559
                    let start = self.idx(x, y);
1560
                    while x < self.w && self.cls[self.idx(x, y)] == cls {
1561
                        x += 1;
1562
                    }
1563
                    let end = self.idx(x, y);
1564
                    self.ch[start..end].reverse();
1565
                } else {
1566
                    x += 1;
1567
                }
1568
            }
1569
        }
1570
    }
1571
1572
    fn to_lines(&self, styles: &MermaidStyles) -> (Vec<Line<'static>>, Vec<String>) {
1573
        let mut styled = Vec::with_capacity(self.h);
1574
        let mut plain = Vec::with_capacity(self.h);
1575
        for y in 0..self.h {
1576
            let mut last = self.w;
1577
            for x in (0..self.w).rev() {
1578
                let c = self.ch[self.idx(x, y)];
1579
                if c != ' ' && c != CONT {
1580
                    last = x + 1;
1581
                    break;
1582
                }
1583
            }
1584
            let mut spans: Vec<Span<'static>> = Vec::new();
1585
            let mut plain_row = String::new();
1586
            let mut run = String::new();
1587
            let mut run_cls = Cls::Empty;
1588
            for x in 0..last {
1589
                let i = self.idx(x, y);
1590
                let c = self.ch[i];
1591
                if c == CONT {
1592
                    continue;
1593
                }
1594
                let cls = self.cls[i];
1595
                plain_row.push(c);
1596
                if cls != run_cls && !run.is_empty() {
1597
                    spans.push(Span::styled(
1598
                        std::mem::take(&mut run),
1599
                        style_for(run_cls, styles),
1600
                    ));
1601
                }
1602
                run_cls = cls;
1603
                run.push(c);
1604
            }
1605
            if !run.is_empty() {
1606
                spans.push(Span::styled(run, style_for(run_cls, styles)));
1607
            }
1608
            styled.push(Line::from(spans));
1609
            plain.push(plain_row.trim_end().to_string());
1610
        }
1611
        (styled, plain)
1612
    }
1613
}
1614
1615
fn style_for(cls: Cls, styles: &MermaidStyles) -> Style {
1616
    match cls {
1617
        Cls::Empty => Style::default(),
1618
        Cls::Border => styles.border,
1619
        Cls::Text => styles.node_text,
1620
        Cls::Edge => styles.edge,
1621
        Cls::EdgeLabel => styles.edge_label,
1622
    }
1623
}
1624
1625
fn mask_char(mask: u8) -> char {
1626
    match mask {
1627
        0 => ' ',
1628
        m if m == U || m == D || m == U | D => '│',
1629
        m if m == L || m == R || m == L | R => '─',
1630
        m if m == D | R => '┌',
1631
        m if m == D | L => '┐',
1632
        m if m == U | R => '└',
1633
        m if m == U | L => '┘',
1634
        m if m == U | D | R => '├',
1635
        m if m == U | D | L => '┤',
1636
        m if m == D | L | R => '┬',
1637
        m if m == U | L | R => '┴',
1638
        _ => '┼',
1639
    }
1640
}
1641
1642
fn dotted_char(c: char) -> char {
1643
    match c {
1644
        '─' => '╌',
1645
        '│' => '╎',
1646
        other => other,
1647
    }
1648
}
1649
1650
fn thick_char(c: char) -> char {
1651
    match c {
1652
        '─' => '━',
1653
        '│' => '┃',
1654
        '┌' => '┏',
1655
        '┐' => '┓',
1656
        '└' => '┗',
1657
        '┘' => '┛',
1658
        '├' => '┣',
1659
        '┤' => '┫',
1660
        '┬' => '┳',
1661
        '┴' => '┻',
1662
        '┼' => '╋',
1663
        other => other,
1664
    }
1665
}
1666
1667
fn flip_glyph_v(c: char) -> char {
1668
    match c {
1669
        '┌' => '└',
1670
        '└' => '┌',
1671
        '┐' => '┘',
1672
        '┘' => '┐',
1673
        '┏' => '┗',
1674
        '┗' => '┏',
1675
        '┓' => '┛',
1676
        '┛' => '┓',
1677
        '╭' => '╰',
1678
        '╰' => '╭',
1679
        '╮' => '╯',
1680
        '╯' => '╮',
1681
        '┬' => '┴',
1682
        '┴' => '┬',
1683
        '┳' => '┻',
1684
        '┻' => '┳',
1685
        '▼' => '▲',
1686
        '▲' => '▼',
1687
        '▽' => '△',
1688
        '△' => '▽',
1689
        other => other,
1690
    }
1691
}
1692
1693
fn flip_glyph_h(c: char) -> char {
1694
    match c {
1695
        '┌' => '┐',
1696
        '┐' => '┌',
1697
        '└' => '┘',
1698
        '┘' => '└',
1699
        '┏' => '┓',
1700
        '┓' => '┏',
1701
        '┗' => '┛',
1702
        '┛' => '┗',
1703
        '╭' => '╮',
1704
        '╮' => '╭',
1705
        '╰' => '╯',
1706
        '╯' => '╰',
1707
        '├' => '┤',
1708
        '┤' => '├',
1709
        '┣' => '┫',
1710
        '┫' => '┣',
1711
        '▶' => '◄',
1712
        '◄' => '▶',
1713
        '▷' => '◁',
1714
        '◁' => '▷',
1715
        other => other,
1716
    }
1717
}
1718
1719
struct Placed {
1720
    x: usize,
1721
    y: usize,
1722
    w: usize,
1723
    h: usize,
1724
    cx: usize,
1725
    cy: usize,
1726
    rank: usize,
1727
}
1728
1729
struct NodeSizes {
1730
    box_w: Vec<usize>,
1731
    box_h: Vec<usize>,
1732
    lay_w: Vec<usize>,
1733
    lay_h: Vec<usize>,
1734
    extra_h: Vec<usize>,
1735
    self_label_w: Vec<usize>,
1736
}
1737
1738
fn layout_flowchart(
1739
    graph: &Graph,
1740
    styles: &MermaidStyles,
1741
    max_width: Option<usize>,
1742
) -> Result<MermaidArt, Oversize> {
1743
    let extras: Vec<NodeExtra> = (0..graph.nodes.len()).map(|_| NodeExtra::Plain).collect();
1744
    let mut canvas = layout_canvas(graph, &extras, max_width)?;
1745
    match graph.dir {
1746
        Dir::Up => canvas.flip_vertical(),
1747
        Dir::Left => canvas.flip_horizontal(),
1748
        _ => {}
1749
    }
1750
    let (styled_lines, plain_lines) = canvas.to_lines(styles);
1751
    Ok(MermaidArt {
1752
        styled_lines,
1753
        plain_lines,
1754
    })
1755
}
1756
1757
enum NodeExtra {
1758
    Plain,
1759
    Frame(Canvas),
1760
    Compartments(Vec<Vec<String>>),
1761
}
1762
1763
fn layout_canvas(
1764
    graph: &Graph,
1765
    extras: &[NodeExtra],
1766
    max_width: Option<usize>,
1767
) -> Result<Canvas, Oversize> {
1768
    let n = graph.nodes.len();
1769
    if n == 0 {
1770
        return Err(Oversize::Cells);
1771
    }
1772
1773
    let ranks = compute_ranks(graph);
1774
    let max_rank = *ranks.iter().max().unwrap_or(&0);
1775
1776
    let mut by_rank: Vec<Vec<usize>> = vec![Vec::new(); max_rank + 1];
1777
    for (idx, &r) in ranks.iter().enumerate() {
1778
        by_rank[r].push(idx);
1779
    }
1780
    order_ranks(&mut by_rank, &graph.edges, &ranks);
1781
1782
    let wrapped: Vec<Vec<String>> = graph
1783
        .nodes
1784
        .iter()
1785
        .map(|node| wrap_label(&node.label, WRAP_WIDTH, MAX_LINES))
1786
        .collect();
1787
    let mut box_w: Vec<usize> = (0..n)
1788
        .map(|i| match &extras[i] {
1789
            NodeExtra::Frame(sub) => {
1790
                let title_w = fit_label(&graph.nodes[i].label, WRAP_WIDTH).width();
1791
                (sub.w + 2).max(title_w + 4)
1792
            }
1793
            NodeExtra::Compartments(sections) => {
1794
                sections
1795
                    .iter()
1796
                    .flatten()
1797
                    .map(|l| l.width())
1798
                    .max()
1799
                    .unwrap_or(1)
1800
                    .max(1)
1801
                    + 2 * PAD
1802
                    + 2
1803
            }
1804
            NodeExtra::Plain => {
1805
                wrapped[i]
1806
                    .iter()
1807
                    .map(|l| l.width())
1808
                    .max()
1809
                    .unwrap_or(1)
1810
                    .max(1)
1811
                    + 2 * PAD
1812
                    + 2
1813
            }
1814
        })
1815
        .collect();
1816
    let box_h: Vec<usize> = (0..n)
1817
        .map(|i| match &extras[i] {
1818
            NodeExtra::Frame(sub) => sub.h + 2,
1819
            NodeExtra::Compartments(sections) => {
1820
                let filled = sections.iter().filter(|s| !s.is_empty()).count();
1821
                sections.iter().map(|s| s.len()).sum::<usize>() + filled.saturating_sub(1) + 2
1822
            }
1823
            NodeExtra::Plain => wrapped[i].len() + 2,
1824
        })
1825
        .collect();
1826
1827
    let mut extra_h = vec![0usize; n];
1828
    let mut self_label_w = vec![0usize; n];
1829
    for e in &graph.edges {
1830
        if e.from == e.to {
1831
            extra_h[e.from] = 2;
1832
            if let Some(l) = &e.label {
1833
                self_label_w[e.from] = self_label_w[e.from].max(l.width().min(MAX_LABEL));
1834
            }
1835
        }
1836
    }
1837
    for i in 0..n {
1838
        if extra_h[i] > 0 {
1839
            box_w[i] = box_w[i].max(7);
1840
        }
1841
    }
1842
    let lay_w: Vec<usize> = (0..n)
1843
        .map(|i| {
1844
            box_w[i]
1845
                + if self_label_w[i] > 0 {
1846
                    2 * (self_label_w[i] + 3)
1847
                } else {
1848
                    0
1849
                }
1850
        })
1851
        .collect();
1852
    let lay_h: Vec<usize> = (0..n).map(|i| box_h[i] + extra_h[i]).collect();
1853
    let sizes = NodeSizes {
1854
        box_w,
1855
        box_h,
1856
        lay_w,
1857
        lay_h,
1858
        extra_h,
1859
        self_label_w,
1860
    };
1861
1862
    let mut placed: Vec<Placed> = (0..n)
1863
        .map(|_| Placed {
1864
            x: 0,
1865
            y: 0,
1866
            w: 0,
1867
            h: 0,
1868
            cx: 0,
1869
            cy: 0,
1870
            rank: 0,
1871
        })
1872
        .collect();
1873
1874
    // BT/RL reuse the TD/LR layout, then flip the finished canvas (so text
1875
    // stays readable) into the bottom-up / right-to-left orientation.
1876
    let vertical = matches!(graph.dir, Dir::Down | Dir::Up);
1877
    let plan = if vertical {
1878
        place_td(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed)
1879
    } else {
1880
        place_lr(&ranks, max_rank, &by_rank, &sizes, graph, &mut placed)
1881
    };
1882
    let (canvas_w, canvas_h) = plan.canvas;
1883
1884
    if let Some(mw) = max_width
1885
        && canvas_w > mw
1886
    {
1887
        return Err(Oversize::Width);
1888
    }
1889
    if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS {
1890
        return Err(Oversize::Cells);
1891
    }
1892
1893
    let mut canvas = Canvas::new(canvas_w, canvas_h);
1894
    for idx in 0..n {
1895
        match &extras[idx] {
1896
            NodeExtra::Frame(sub) => {
1897
                draw_frame(&mut canvas, &placed[idx], &graph.nodes[idx].label, sub)
1898
            }
1899
            NodeExtra::Compartments(sections) => {
1900
                draw_class_box(&mut canvas, &placed[idx], sections)
1901
            }
1902
            NodeExtra::Plain => draw_box(
1903
                &mut canvas,
1904
                &placed[idx],
1905
                &wrapped[idx],
1906
                graph.nodes[idx].shape,
1907
            ),
1908
        }
1909
    }
1910
    for (i, edge) in graph.edges.iter().enumerate() {
1911
        canvas.cur_style = match edge.line {
1912
            LineKind::Solid => STY_SOLID,
1913
            LineKind::Dotted => STY_DOT,
1914
            LineKind::Thick => STY_THICK,
1915
        };
1916
        if edge.from == edge.to {
1917
            route_self(&mut canvas, &placed[edge.from], edge);
1918
            continue;
1919
        }
1920
        let (from, to) = (&placed[edge.from], &placed[edge.to]);
1921
        let adjacent = to.rank == from.rank + 1;
1922
        let bus = plan.band_end[from.rank] + plan.edge_bus[i];
1923
        let lane = plan.lane_base + plan.edge_lane[i];
1924
        match (vertical, adjacent) {
1925
            (true, true) => route_forward(&mut canvas, from, to, edge, bus),
1926
            (true, false) => route_back(&mut canvas, from, to, edge, lane),
1927
            (false, true) => route_forward_lr(&mut canvas, from, to, edge, bus),
1928
            (false, false) => route_back_lr(&mut canvas, from, to, edge, lane),
1929
        }
1930
    }
1931
1932
    canvas.finalize_mask();
1933
    Ok(canvas)
1934
}
1935
1936
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
1937
enum Item {
1938
    Node(usize),
1939
    Group(usize),
1940
}
1941
1942
fn render_grouped(
1943
    graph: &Graph,
1944
    styles: &MermaidStyles,
1945
    max_width: Option<usize>,
1946
) -> Result<MermaidArt, Oversize> {
1947
    let mut proxy: HashMap<usize, usize> = HashMap::new();
1948
    for (gi, g) in graph.groups.iter().enumerate() {
1949
        if let Some(&ni) = graph.index.get(&g.id) {
1950
            proxy.insert(ni, gi);
1951
        }
1952
    }
1953
1954
    let group_chain = |g: Option<usize>| -> Vec<usize> {
1955
        let mut chain = Vec::new();
1956
        let mut cur = g;
1957
        while let Some(gi) = cur {
1958
            chain.push(gi);
1959
            cur = graph.groups[gi].parent;
1960
        }
1961
        chain.reverse();
1962
        chain
1963
    };
1964
    let endpoint = |n: usize| -> (Item, Vec<usize>) {
1965
        match proxy.get(&n) {
1966
            Some(&gi) => (Item::Group(gi), group_chain(graph.groups[gi].parent)),
1967
            None => (Item::Node(n), group_chain(graph.node_group[n])),
1968
        }
1969
    };
1970
1971
    let mut scope_edges: HashMap<Option<usize>, Vec<(Item, Item, usize)>> = HashMap::new();
1972
    let mut referenced: Vec<bool> = vec![false; graph.groups.len()];
1973
    for (ei, e) in graph.edges.iter().enumerate() {
1974
        let (item_f, chain_f) = endpoint(e.from);
1975
        let (item_t, chain_t) = endpoint(e.to);
1976
        let k = chain_f
1977
            .iter()
1978
            .zip(&chain_t)
1979
            .take_while(|(a, b)| a == b)
1980
            .count();
1981
        let scope = if k == 0 { None } else { Some(chain_f[k - 1]) };
1982
        let f = if chain_f.len() > k {
1983
            Item::Group(chain_f[k])
1984
        } else {
1985
            item_f
1986
        };
1987
        let t = if chain_t.len() > k {
1988
            Item::Group(chain_t[k])
1989
        } else {
1990
            item_t
1991
        };
1992
        if let Item::Group(gi) = f {
1993
            referenced[gi] = true;
1994
        }
1995
        if let Item::Group(gi) = t {
1996
            referenced[gi] = true;
1997
        }
1998
        scope_edges.entry(scope).or_default().push((f, t, ei));
1999
    }
2000
2001
    let mut direct_nodes: HashMap<Option<usize>, Vec<usize>> = HashMap::new();
2002
    for (ni, g) in graph.node_group.iter().enumerate() {
2003
        if !proxy.contains_key(&ni) {
2004
            direct_nodes.entry(*g).or_default().push(ni);
2005
        }
2006
    }
2007
    let mut keep = vec![false; graph.groups.len()];
2008
    for gi in (0..graph.groups.len()).rev() {
2009
        let has_nodes = direct_nodes.get(&Some(gi)).is_some_and(|v| !v.is_empty());
2010
        let has_children =
2011
            (0..graph.groups.len()).any(|c| graph.groups[c].parent == Some(gi) && keep[c]);
2012
        keep[gi] = has_nodes || has_children || referenced[gi];
2013
    }
2014
2015
    let mut canvas = build_scope(graph, None, &scope_edges, &direct_nodes, &keep, max_width)?;
2016
    match graph.dir {
2017
        Dir::Up => canvas.flip_vertical(),
2018
        Dir::Left => canvas.flip_horizontal(),
2019
        _ => {}
2020
    }
2021
    let (styled_lines, plain_lines) = canvas.to_lines(styles);
2022
    Ok(MermaidArt {
2023
        styled_lines,
2024
        plain_lines,
2025
    })
2026
}
2027
2028
fn build_scope(
2029
    graph: &Graph,
2030
    scope: Option<usize>,
2031
    scope_edges: &HashMap<Option<usize>, Vec<(Item, Item, usize)>>,
2032
    direct_nodes: &HashMap<Option<usize>, Vec<usize>>,
2033
    keep: &[bool],
2034
    max_width: Option<usize>,
2035
) -> Result<Canvas, Oversize> {
2036
    let mut items: Vec<Item> = Vec::new();
2037
    if let Some(nodes) = direct_nodes.get(&scope) {
2038
        items.extend(nodes.iter().map(|&n| Item::Node(n)));
2039
    }
2040
    let child_groups: Vec<usize> = (0..graph.groups.len())
2041
        .filter(|&gi| graph.groups[gi].parent == scope && keep[gi])
2042
        .collect();
2043
    items.extend(child_groups.iter().map(|&gi| Item::Group(gi)));
2044
2045
    if items.is_empty() {
2046
        return Ok(Canvas::new(1, 1));
2047
    }
2048
2049
    let mut index_of: HashMap<Item, usize> = HashMap::new();
2050
    let mut nodes: Vec<Node> = Vec::new();
2051
    let mut extras: Vec<NodeExtra> = Vec::new();
2052
    for item in &items {
2053
        index_of.insert(*item, nodes.len());
2054
        match item {
2055
            Item::Node(ni) => {
2056
                nodes.push(Node {
2057
                    label: graph.nodes[*ni].label.clone(),
2058
                    shape: graph.nodes[*ni].shape,
2059
                });
2060
                extras.push(NodeExtra::Plain);
2061
            }
2062
            Item::Group(gi) => {
2063
                let sub = build_scope(graph, Some(*gi), scope_edges, direct_nodes, keep, None)?;
2064
                nodes.push(Node {
2065
                    label: graph.groups[*gi].label.clone(),
2066
                    shape: Shape::Rect,
2067
                });
2068
                extras.push(NodeExtra::Frame(sub));
2069
            }
2070
        }
2071
    }
2072
2073
    let mut edges: Vec<Edge> = Vec::new();
2074
    if let Some(list) = scope_edges.get(&scope) {
2075
        for (f, t, ei) in list {
2076
            let (Some(&fi), Some(&ti)) = (index_of.get(f), index_of.get(t)) else {
2077
                continue;
2078
            };
2079
            let e = &graph.edges[*ei];
2080
            edges.push(Edge {
2081
                from: fi,
2082
                to: ti,
2083
                label: e.label.clone(),
2084
                head_to: e.head_to,
2085
                head_from: e.head_from,
2086
                line: e.line,
2087
            });
2088
        }
2089
    }
2090
2091
    let synth = Graph {
2092
        nodes,
2093
        edges,
2094
        index: HashMap::new(),
2095
        groups: Vec::new(),
2096
        node_group: Vec::new(),
2097
        cur_group: None,
2098
        over_cap: false,
2099
        dir: graph.dir,
2100
    };
2101
    layout_canvas(&synth, &extras, max_width)
2102
}
2103
2104
fn draw_class_box(canvas: &mut Canvas, p: &Placed, sections: &[Vec<String>]) {
2105
    draw_box(canvas, p, &[], Shape::Rect);
2106
    let inner = p.w.saturating_sub(2 * PAD + 2).max(1);
2107
    let mut row = p.y + 1;
2108
    let mut first = true;
2109
    for (si, section) in sections.iter().enumerate() {
2110
        if section.is_empty() {
2111
            continue;
2112
        }
2113
        if !first {
2114
            canvas.set(p.x, row, '├', Cls::Border);
2115
            for x in (p.x + 1)..(p.x + p.w - 1) {
2116
                canvas.set(x, row, '─', Cls::Border);
2117
            }
2118
            canvas.set(p.x + p.w - 1, row, '┤', Cls::Border);
2119
            row += 1;
2120
        }
2121
        first = false;
2122
        for line in section {
2123
            let text = fit_label(line, inner);
2124
            let tx = if si == 0 {
2125
                p.x + 1 + PAD + inner.saturating_sub(text.width()) / 2
2126
            } else {
2127
                p.x + 1 + PAD
2128
            };
2129
            draw_seq_text(canvas, &text, tx, row, Cls::Text);
2130
            row += 1;
2131
        }
2132
    }
2133
}
2134
2135
fn draw_frame(canvas: &mut Canvas, p: &Placed, title: &str, sub: &Canvas) {
2136
    draw_box(canvas, p, &[], Shape::Rect);
2137
    let t = fit_label(title, p.w.saturating_sub(4));
2138
    draw_seq_text(canvas, &format!(" {t} "), p.x + 1, p.y, Cls::Text);
2139
    let ox = p.x + 1 + (p.w - 2 - sub.w) / 2;
2140
    let oy = p.y + 1 + (p.h - 2 - sub.h) / 2;
2141
    canvas.blit(sub, ox, oy);
2142
}
2143
2144
fn bus_spans_td(
2145
    graph: &Graph,
2146
    ranks: &[usize],
2147
    centers: &[usize],
2148
    r: usize,
2149
    exact: bool,
2150
) -> Vec<(usize, usize, usize, usize, usize)> {
2151
    graph
2152
        .edges
2153
        .iter()
2154
        .enumerate()
2155
        .filter(|(_, e)| {
2156
            let jogs = if exact {
2157
                centers[e.from] != centers[e.to]
2158
            } else {
2159
                centers[e.from].abs_diff(centers[e.to]) > 1
2160
            };
2161
            e.from != e.to && ranks[e.from] == r && ranks[e.to] == r + 1 && jogs
2162
        })
2163
        .map(|(i, e)| {
2164
            let a = centers[e.from].min(centers[e.to]);
2165
            let b = centers[e.from].max(centers[e.to]);
2166
            (a, b, e.from, e.to, i)
2167
        })
2168
        .collect()
2169
}
2170
2171
fn lane_spans(
2172
    graph: &Graph,
2173
    ranks: &[usize],
2174
    placed: &[Placed],
2175
    vertical: bool,
2176
) -> Vec<(usize, usize, usize, usize, usize)> {
2177
    graph
2178
        .edges
2179
        .iter()
2180
        .enumerate()
2181
        .filter(|(_, e)| e.from != e.to && ranks[e.to] != ranks[e.from] + 1)
2182
        .map(|(i, e)| {
2183
            let (pf, pt) = (&placed[e.from], &placed[e.to]);
2184
            let (a, b) = if vertical {
2185
                (pf.cy.min(pt.cy), pf.cy.max(pt.cy))
2186
            } else {
2187
                (pf.cx.min(pt.cx), pf.cx.max(pt.cx))
2188
            };
2189
            (a, b, e.from, e.to, i)
2190
        })
2191
        .collect()
2192
}
2193
2194
fn place_td(
2195
    ranks: &[usize],
2196
    max_rank: usize,
2197
    by_rank: &[Vec<usize>],
2198
    sizes: &NodeSizes,
2199
    graph: &Graph,
2200
    placed: &mut [Placed],
2201
) -> RoutePlan {
2202
    let centers = assign_positions(by_rank, &sizes.lay_w, GAP_X, &graph.edges, ranks);
2203
2204
    let mut edge_bus = vec![0usize; graph.edges.len()];
2205
    let mut bus_tracks = vec![0usize; max_rank + 1];
2206
    for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) {
2207
        let spans = bus_spans_td(graph, ranks, &centers, r, false);
2208
        if spans.is_empty() {
2209
            continue;
2210
        }
2211
        let (assigned, count) = assign_tracks(&spans);
2212
        for (idx, slot) in assigned {
2213
            edge_bus[idx] = slot;
2214
        }
2215
        *tracks = count;
2216
    }
2217
2218
    let rank_h: Vec<usize> = by_rank
2219
        .iter()
2220
        .map(|row| {
2221
            row.iter()
2222
                .map(|&i| sizes.box_h[i] + sizes.extra_h[i])
2223
                .max()
2224
                .unwrap_or(3)
2225
        })
2226
        .collect();
2227
    let mut rank_y = vec![0usize; max_rank + 1];
2228
    for r in 1..=max_rank {
2229
        let gap = GAP_Y.max(bus_tracks[r - 1] + 1);
2230
        rank_y[r] = rank_y[r - 1] + rank_h[r - 1] + gap;
2231
    }
2232
    let canvas_h = rank_y[max_rank] + rank_h[max_rank];
2233
    let band_end: Vec<usize> = (0..=max_rank).map(|r| rank_y[r] + rank_h[r]).collect();
2234
2235
    let mut diagram_w = 1;
2236
    for (r, row) in by_rank.iter().enumerate() {
2237
        for &idx in row {
2238
            let w = sizes.box_w[idx];
2239
            let h = sizes.box_h[idx];
2240
            let cx = centers[idx];
2241
            let x = cx.saturating_sub(w / 2);
2242
            let y = rank_y[r] + (rank_h[r] - h - sizes.extra_h[idx]) / 2;
2243
            placed[idx] = Placed {
2244
                x,
2245
                y,
2246
                w,
2247
                h,
2248
                cx,
2249
                cy: y + h / 2,
2250
                rank: r,
2251
            };
2252
            diagram_w = diagram_w.max(x + w);
2253
            if sizes.extra_h[idx] > 0 && sizes.self_label_w[idx] > 0 {
2254
                diagram_w = diagram_w.max(x + w + 2 + sizes.self_label_w[idx]);
2255
            }
2256
        }
2257
    }
2258
2259
    let mut content_w = diagram_w;
2260
    for e in &graph.edges {
2261
        if e.from == e.to {
2262
            continue;
2263
        }
2264
        if let Some(label) = &e.label {
2265
            let lw = label.width().min(MAX_LABEL);
2266
            if ranks[e.to] == ranks[e.from] + 1 {
2267
                content_w = content_w.max(placed[e.to].cx + 2 + lw);
2268
            } else {
2269
                content_w = content_w.max(diagram_w + lw + 1);
2270
            }
2271
        }
2272
    }
2273
2274
    let mut edge_lane = vec![0usize; graph.edges.len()];
2275
    let lanes = lane_spans(graph, ranks, placed, true);
2276
    let (canvas_w, lane_base) = if lanes.is_empty() {
2277
        (content_w, 0)
2278
    } else {
2279
        let (assigned, count) = assign_tracks(&lanes);
2280
        for (idx, slot) in assigned {
2281
            edge_lane[idx] = slot;
2282
        }
2283
        (content_w + 1 + count, content_w + 1)
2284
    };
2285
2286
    RoutePlan {
2287
        canvas: (canvas_w, canvas_h),
2288
        band_end,
2289
        edge_bus,
2290
        lane_base,
2291
        edge_lane,
2292
    }
2293
}
2294
2295
fn place_lr(
2296
    ranks: &[usize],
2297
    max_rank: usize,
2298
    by_rank: &[Vec<usize>],
2299
    sizes: &NodeSizes,
2300
    graph: &Graph,
2301
    placed: &mut [Placed],
2302
) -> RoutePlan {
2303
    let col_w: Vec<usize> = by_rank
2304
        .iter()
2305
        .map(|row| row.iter().map(|&i| sizes.box_w[i]).max().unwrap_or(0))
2306
        .collect();
2307
2308
    let max_label = graph
2309
        .edges
2310
        .iter()
2311
        .filter(|e| e.from == e.to || ranks[e.to] == ranks[e.from] + 1)
2312
        .filter_map(|e| e.label.as_ref().map(|l| l.width().min(MAX_LABEL)))
2313
        .max()
2314
        .unwrap_or(0);
2315
    let base_gap = (GAP_X + 1).max(max_label + 3);
2316
2317
    let centers = assign_positions(by_rank, &sizes.lay_h, 1, &graph.edges, ranks);
2318
2319
    let mut edge_bus = vec![0usize; graph.edges.len()];
2320
    let mut bus_tracks = vec![0usize; max_rank + 1];
2321
    for (r, tracks) in bus_tracks.iter_mut().enumerate().take(max_rank) {
2322
        let spans = bus_spans_td(graph, ranks, &centers, r, true);
2323
        if spans.is_empty() {
2324
            continue;
2325
        }
2326
        let (assigned, count) = assign_tracks(&spans);
2327
        for (idx, slot) in assigned {
2328
            edge_bus[idx] = slot;
2329
        }
2330
        *tracks = count;
2331
    }
2332
2333
    let mut rank_x = vec![0usize; max_rank + 1];
2334
    for r in 1..=max_rank {
2335
        let gap = base_gap.max(bus_tracks[r - 1] + 1);
2336
        rank_x[r] = rank_x[r - 1] + col_w[r - 1] + gap;
2337
    }
2338
    let canvas_w = rank_x[max_rank]
2339
        + col_w[max_rank]
2340
        + by_rank[max_rank]
2341
            .iter()
2342
            .filter(|&&i| sizes.extra_h[i] > 0 && sizes.self_label_w[i] > 0)
2343
            .map(|&i| 2 + sizes.self_label_w[i])
2344
            .max()
2345
            .unwrap_or(0);
2346
    let band_end: Vec<usize> = (0..=max_rank).map(|r| rank_x[r] + col_w[r]).collect();
2347
2348
    let mut diagram_h = 1;
2349
    for (r, row) in by_rank.iter().enumerate() {
2350
        let x = rank_x[r];
2351
        for &idx in row {
2352
            let w = sizes.box_w[idx];
2353
            let h = sizes.box_h[idx];
2354
            let cy = centers[idx];
2355
            let y = cy.saturating_sub((h + sizes.extra_h[idx]) / 2);
2356
            placed[idx] = Placed {
2357
                x,
2358
                y,
2359
                w,
2360
                h,
2361
                cx: x + w / 2,
2362
                cy: y + h / 2,
2363
                rank: r,
2364
            };
2365
            diagram_h = diagram_h.max(y + h + sizes.extra_h[idx]);
2366
        }
2367
    }
2368
2369
    let mut edge_lane = vec![0usize; graph.edges.len()];
2370
    let lanes = lane_spans(graph, ranks, placed, false);
2371
    let (canvas_h, lane_base) = if lanes.is_empty() {
2372
        (diagram_h, 0)
2373
    } else {
2374
        let (assigned, count) = assign_tracks(&lanes);
2375
        for (idx, slot) in assigned {
2376
            edge_lane[idx] = slot;
2377
        }
2378
        (diagram_h + 1 + count, diagram_h + 1)
2379
    };
2380
2381
    RoutePlan {
2382
        canvas: (canvas_w, canvas_h),
2383
        band_end,
2384
        edge_bus,
2385
        lane_base,
2386
        edge_lane,
2387
    }
2388
}
2389
2390
struct RoutePlan {
2391
    canvas: (usize, usize),
2392
    band_end: Vec<usize>,
2393
    edge_bus: Vec<usize>,
2394
    lane_base: usize,
2395
    edge_lane: Vec<usize>,
2396
}
2397
2398
fn assign_tracks(spans: &[(usize, usize, usize, usize, usize)]) -> (Vec<(usize, usize)>, usize) {
2399
    let mut sorted = spans.to_vec();
2400
    sorted.sort_unstable();
2401
    let mut tracks: Vec<Vec<(usize, usize, usize, usize)>> = Vec::new();
2402
    let mut out = Vec::with_capacity(sorted.len());
2403
    for &(s, e, f, t, idx) in &sorted {
2404
        let compatible = |members: &Vec<(usize, usize, usize, usize)>| {
2405
            members
2406
                .iter()
2407
                .all(|&(s2, e2, f2, t2)| e2 + 2 <= s || e + 2 <= s2 || f2 == f || t2 == t)
2408
        };
2409
        let slot = match tracks.iter().position(compatible) {
2410
            Some(x) => x,
2411
            None => {
2412
                tracks.push(Vec::new());
2413
                tracks.len() - 1
2414
            }
2415
        };
2416
        tracks[slot].push((s, e, f, t));
2417
        out.push((idx, slot));
2418
    }
2419
    (out, tracks.len())
2420
}
2421
2422
/// Reorder nodes within each rank to minimize edge crossings (Sugiyama-style
2423
/// barycenter sweeps): alternate down/up passes sort each rank by the mean
2424
/// position of its forward neighbours, keeping the ordering with the fewest
2425
/// crossings between adjacent ranks.
2426
fn order_ranks(by_rank: &mut [Vec<usize>], edges: &[Edge], ranks: &[usize]) {
2427
    let n = ranks.len();
2428
    if by_rank.len() < 2 || n < 3 {
2429
        return;
2430
    }
2431
    let mut parents: Vec<Vec<usize>> = vec![Vec::new(); n];
2432
    let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
2433
    for e in edges {
2434
        if e.from != e.to && ranks[e.to] > ranks[e.from] {
2435
            parents[e.to].push(e.from);
2436
            children[e.from].push(e.to);
2437
        }
2438
    }
2439
2440
    let mut pos = vec![0usize; n];
2441
    let set_pos = |by_rank: &[Vec<usize>], pos: &mut Vec<usize>| {
2442
        for row in by_rank {
2443
            for (i, &v) in row.iter().enumerate() {
2444
                pos[v] = i;
2445
            }
2446
        }
2447
    };
2448
    set_pos(by_rank, &mut pos);
2449
2450
    let mut best: Vec<Vec<usize>> = by_rank.to_vec();
2451
    let mut best_crossings = count_crossings(edges, ranks, &pos);
2452
    if best_crossings == 0 {
2453
        return;
2454
    }
2455
2456
    for it in 0..8 {
2457
        if it % 2 == 0 {
2458
            for row in by_rank.iter_mut().skip(1) {
2459
                sort_by_barycenter(row, &parents, &pos);
2460
                for (i, &v) in row.iter().enumerate() {
2461
                    pos[v] = i;
2462
                }
2463
            }
2464
        } else {
2465
            let last = by_rank.len() - 1;
2466
            for row in by_rank[..last].iter_mut().rev() {
2467
                sort_by_barycenter(row, &children, &pos);
2468
                for (i, &v) in row.iter().enumerate() {
2469
                    pos[v] = i;
2470
                }
2471
            }
2472
        }
2473
        let crossings = count_crossings(edges, ranks, &pos);
2474
        if crossings < best_crossings {
2475
            best_crossings = crossings;
2476
            best = by_rank.to_vec();
2477
        }
2478
        if best_crossings == 0 {
2479
            break;
2480
        }
2481
    }
2482
2483
    for (row, b) in by_rank.iter_mut().zip(best) {
2484
        *row = b;
2485
    }
2486
}
2487
2488
fn sort_by_barycenter(row: &mut [usize], neigh: &[Vec<usize>], pos: &[usize]) {
2489
    let mut keyed: Vec<(f64, usize)> = row
2490
        .iter()
2491
        .map(|&v| {
2492
            let key = if neigh[v].is_empty() {
2493
                pos[v] as f64
2494
            } else {
2495
                neigh[v].iter().map(|&u| pos[u] as f64).sum::<f64>() / neigh[v].len() as f64
2496
            };
2497
            (key, v)
2498
        })
2499
        .collect();
2500
    keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
2501
    for (slot, (_, v)) in row.iter_mut().zip(keyed) {
2502
        *slot = v;
2503
    }
2504
}
2505
2506
fn count_crossings(edges: &[Edge], ranks: &[usize], pos: &[usize]) -> usize {
2507
    let adjacent: Vec<(usize, usize, usize)> = edges
2508
        .iter()
2509
        .filter(|e| e.from != e.to && ranks[e.to] == ranks[e.from] + 1)
2510
        .map(|e| (ranks[e.from], pos[e.from], pos[e.to]))
2511
        .collect();
2512
    let mut crossings = 0;
2513
    for (i, a) in adjacent.iter().enumerate() {
2514
        for b in &adjacent[i + 1..] {
2515
            if a.0 == b.0 && ((a.1 < b.1 && a.2 > b.2) || (a.1 > b.1 && a.2 < b.2)) {
2516
                crossings += 1;
2517
            }
2518
        }
2519
    }
2520
    crossings
2521
}
2522
2523
/// Assign a center coordinate (along the cross-axis) to every node so nodes line
2524
/// up under their neighbours. Iterative barycenter relaxation: each node drifts
2525
/// toward the average of its forward neighbours while ranks keep order and a
2526
/// minimum `sep` between boxes, which straightens chains and centers branches.
2527
fn assign_positions(
2528
    by_rank: &[Vec<usize>],
2529
    size: &[usize],
2530
    sep: usize,
2531
    edges: &[Edge],
2532
    ranks: &[usize],
2533
) -> Vec<usize> {
2534
    let n = size.len();
2535
    let mut parents: Vec<Vec<usize>> = vec![Vec::new(); n];
2536
    let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
2537
    for e in edges {
2538
        if e.from != e.to && ranks[e.to] > ranks[e.from] {
2539
            parents[e.to].push(e.from);
2540
            children[e.from].push(e.to);
2541
        }
2542
    }
2543
2544
    let mut pos = vec![0f64; n];
2545
    for row in by_rank {
2546
        let mut x = 0f64;
2547
        for &v in row {
2548
            let half = size[v] as f64 / 2.0;
2549
            x += half;
2550
            pos[v] = x;
2551
            x += half + sep as f64;
2552
        }
2553
    }
2554
2555
    for it in 0..10 {
2556
        if it % 2 == 0 {
2557
            for row in by_rank.iter() {
2558
                relax_rank(row, &parents, &mut pos, size, sep);
2559
            }
2560
        } else {
2561
            for row in by_rank.iter().rev() {
2562
                relax_rank(row, &children, &mut pos, size, sep);
2563
            }
2564
        }
2565
    }
2566
2567
    let min_left = (0..n)
2568
        .map(|v| pos[v] - size[v] as f64 / 2.0)
2569
        .fold(f64::INFINITY, f64::min);
2570
    let min_left = if min_left.is_finite() { min_left } else { 0.0 };
2571
    (0..n)
2572
        .map(|v| (pos[v] - min_left).round().max(0.0) as usize)
2573
        .collect()
2574
}
2575
2576
fn relax_rank(nodes: &[usize], neigh: &[Vec<usize>], pos: &mut [f64], size: &[usize], sep: usize) {
2577
    let n = nodes.len();
2578
    if n == 0 {
2579
        return;
2580
    }
2581
    let desired: Vec<f64> = nodes
2582
        .iter()
2583
        .map(|&v| {
2584
            if neigh[v].is_empty() {
2585
                pos[v]
2586
            } else {
2587
                neigh[v].iter().map(|&u| pos[u]).sum::<f64>() / neigh[v].len() as f64
2588
            }
2589
        })
2590
        .collect();
2591
2592
    let half = |i: usize| size[nodes[i]] as f64 / 2.0;
2593
    let mut left = vec![0f64; n];
2594
    let mut right = vec![0f64; n];
2595
    for i in 0..n {
2596
        left[i] = if i == 0 {
2597
            desired[i]
2598
        } else {
2599
            desired[i].max(left[i - 1] + half(i - 1) + sep as f64 + half(i))
2600
        };
2601
    }
2602
    for i in (0..n).rev() {
2603
        right[i] = if i == n - 1 {
2604
            desired[i]
2605
        } else {
2606
            desired[i].min(right[i + 1] - half(i + 1) - sep as f64 - half(i))
2607
        };
2608
    }
2609
    for i in 0..n {
2610
        pos[nodes[i]] = (left[i] + right[i]) / 2.0;
2611
    }
2612
    for i in 1..n {
2613
        let min_p = pos[nodes[i - 1]] + half(i - 1) + sep as f64 + half(i);
2614
        if pos[nodes[i]] < min_p {
2615
            pos[nodes[i]] = min_p;
2616
        }
2617
    }
2618
}
2619
2620
fn wrap_label(label: &str, width: usize, max_lines: usize) -> Vec<String> {
2621
    let width = width.max(1);
2622
    let char_w = |c: char| char_width(c).max(1);
2623
    let mut lines: Vec<String> = Vec::new();
2624
    let mut cur = String::new();
2625
    let mut cur_w = 0usize;
2626
    for word in label.split_whitespace() {
2627
        let ww = word.width();
2628
        if ww > width {
2629
            if !cur.is_empty() {
2630
                lines.push(std::mem::take(&mut cur));
2631
            }
2632
            let mut chunk = String::new();
2633
            let mut chunk_w = 0usize;
2634
            for ch in word.chars() {
2635
                let cw = char_w(ch);
2636
                if chunk_w + cw > width && !chunk.is_empty() {
2637
                    // Prefer breaking after the last identifier boundary so a long
2638
                    // token is not sliced mid-segment; fall back to a per-char break.
2639
                    let carry = match chunk.rfind(LABEL_BREAK_CHARS) {
2640
                        Some(p) => chunk.split_off(p + 1),
2641
                        None => String::new(),
2642
                    };
2643
                    lines.push(std::mem::take(&mut chunk));
2644
                    chunk_w = carry.chars().map(char_w).sum();
2645
                    chunk = carry;
2646
                }
2647
                chunk.push(ch);
2648
                chunk_w += cw;
2649
            }
2650
            cur = chunk;
2651
            cur_w = chunk_w;
2652
        } else if cur.is_empty() {
2653
            cur.push_str(word);
2654
            cur_w = ww;
2655
        } else if cur_w + 1 + ww <= width {
2656
            cur.push(' ');
2657
            cur.push_str(word);
2658
            cur_w += 1 + ww;
2659
        } else {
2660
            lines.push(std::mem::take(&mut cur));
2661
            cur.push_str(word);
2662
            cur_w = ww;
2663
        }
2664
    }
2665
    if !cur.is_empty() {
2666
        lines.push(cur);
2667
    }
2668
    if lines.is_empty() {
2669
        lines.push(String::new());
2670
    }
2671
    if lines.len() > max_lines {
2672
        lines.truncate(max_lines);
2673
        if let Some(last) = lines.last_mut() {
2674
            let target = width.saturating_sub(1).max(1);
2675
            let mut s = String::new();
2676
            let mut sw = 0usize;
2677
            for ch in last.chars() {
2678
                let cw = char_w(ch);
2679
                if sw + cw > target {
2680
                    break;
2681
                }
2682
                s.push(ch);
2683
                sw += cw;
2684
            }
2685
            s.push('…');
2686
            *last = s;
2687
        }
2688
    }
2689
    lines
2690
}
2691
2692
fn fit_label(label: &str, inner: usize) -> String {
2693
    if label.width() <= inner {
2694
        return label.to_string();
2695
    }
2696
    let mut out = String::new();
2697
    let mut used = 0usize;
2698
    for c in label.chars() {
2699
        let cw = char_width(c);
2700
        if used + cw + 1 > inner {
2701
            break;
2702
        }
2703
        out.push(c);
2704
        used += cw;
2705
    }
2706
    out.push('…');
2707
    out
2708
}
2709
2710
fn draw_box(canvas: &mut Canvas, p: &Placed, lines: &[String], shape: Shape) {
2711
    let (x, y, w, h) = (p.x, p.y, p.w, p.h);
2712
    let right = x + w - 1;
2713
    let bottom = y + h - 1;
2714
2715
    let (tl, tr, bl, br) = match shape {
2716
        Shape::Round | Shape::Diamond => ('╭', '╮', '╰', '╯'),
2717
        Shape::Rect => ('┌', '┐', '└', '┘'),
2718
    };
2719
    canvas.set(x, y, tl, Cls::Border);
2720
    canvas.set(right, y, tr, Cls::Border);
2721
    canvas.set(x, bottom, bl, Cls::Border);
2722
    canvas.set(right, bottom, br, Cls::Border);
2723
2724
    for cx in (x + 1)..right {
2725
        canvas.add_bits(cx, y, L | R);
2726
        canvas.add_bits(cx, bottom, L | R);
2727
    }
2728
    for cy in (y + 1)..bottom {
2729
        canvas.add_bits(x, cy, U | D);
2730
        canvas.add_bits(right, cy, U | D);
2731
    }
2732
2733
    for cy in y..=bottom {
2734
        for cx in x..=right {
2735
            let i = canvas.idx(cx, cy);
2736
            canvas.occupied[i] = true;
2737
        }
2738
    }
2739
2740
    let inner = w.saturating_sub(2 * PAD + 2).max(1);
2741
    for (li, line) in lines.iter().enumerate() {
2742
        let row = y + 1 + li;
2743
        let text = fit_label(line, inner);
2744
        let tw = text.width();
2745
        let text_x = x + 1 + PAD + inner.saturating_sub(tw) / 2;
2746
        let mut cur = text_x;
2747
        for c in text.chars() {
2748
            let cw = char_width(c).max(1);
2749
            canvas.set(cur, row, c, Cls::Text);
2750
            // Wide glyphs (CJK, emoji) own a second column; mark it as a
2751
            // continuation so the line builder doesn't emit a stray space.
2752
            for k in 1..cw {
2753
                canvas.set(cur + k, row, CONT, Cls::Text);
2754
            }
2755
            cur += cw;
2756
        }
2757
    }
2758
}
2759
2760
fn route_forward(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) {
2761
    let tx = to.cx;
2762
    let bx = if from.cx.abs_diff(tx) <= 1 {
2763
        tx
2764
    } else {
2765
        from.cx
2766
    };
2767
    let by = from.y + from.h - 1;
2768
    let head_row = to.y - 1;
2769
2770
    canvas.junction(bx, by, D);
2771
    canvas.seg_v(bx, by, bus);
2772
    if bx == tx {
2773
        canvas.seg_v(bx, bus, head_row);
2774
    } else {
2775
        canvas.seg_h(bus, bx, tx);
2776
        canvas.seg_v(tx, bus, head_row);
2777
    }
2778
2779
    if edge.head_to == Head::None {
2780
        canvas.add_bits(tx, head_row, U);
2781
    } else {
2782
        canvas.set(tx, head_row, head_glyph(edge.head_to, '▼'), Cls::Edge);
2783
    }
2784
    if edge.head_from != Head::None {
2785
        canvas.set(bx, by, head_glyph(edge.head_from, '▲'), Cls::Edge);
2786
    }
2787
2788
    if let Some(label) = &edge.label {
2789
        place_label(canvas, label, head_row, tx + 1);
2790
    }
2791
}
2792
2793
fn head_glyph(head: Head, arrow: char) -> char {
2794
    match head {
2795
        Head::Circle => 'o',
2796
        Head::Cross => '×',
2797
        Head::DiamondFill => '◆',
2798
        Head::DiamondOpen => '◇',
2799
        Head::Triangle => match arrow {
2800
            '▼' => '▽',
2801
            '▲' => '△',
2802
            '◄' => '◁',
2803
            '▶' => '▷',
2804
            other => other,
2805
        },
2806
        _ => arrow,
2807
    }
2808
}
2809
2810
fn route_self(canvas: &mut Canvas, p: &Placed, edge: &Edge) {
2811
    let bottom = p.y + p.h - 1;
2812
    let exit_x = p.cx + 1;
2813
    let ret_x = p.x + p.w - 2;
2814
    if ret_x <= exit_x || bottom + 2 >= canvas.h {
2815
        return;
2816
    }
2817
    let (v, h, bl, br) = match edge.line {
2818
        LineKind::Dotted => ('╎', '╌', '╰', '╯'),
2819
        LineKind::Thick => ('┃', '━', '┗', '┛'),
2820
        LineKind::Solid => ('│', '─', '╰', '╯'),
2821
    };
2822
    canvas.junction(exit_x, bottom, D);
2823
    canvas.set(exit_x, bottom + 1, v, Cls::Edge);
2824
    canvas.set(exit_x, bottom + 2, bl, Cls::Edge);
2825
    for x in (exit_x + 1)..ret_x {
2826
        canvas.set(x, bottom + 2, h, Cls::Edge);
2827
    }
2828
    canvas.set(ret_x, bottom + 2, br, Cls::Edge);
2829
    canvas.set(ret_x, bottom + 1, head_glyph(edge.head_to, '▲'), Cls::Edge);
2830
    if let Some(label) = &edge.label {
2831
        place_label(canvas, label, bottom + 1, p.x + p.w + 1);
2832
    }
2833
}
2834
2835
fn route_back(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_x: usize) {
2836
    let sx = from.x + from.w - 1;
2837
    let sy = from.cy;
2838
    let tx = to.x + to.w - 1;
2839
    let tyc = to.cy;
2840
2841
    canvas.junction(sx, sy, R);
2842
    canvas.seg_h(sy, sx, lane_x);
2843
    canvas.seg_v(lane_x, sy, tyc);
2844
    canvas.seg_h(tyc, tx + 1, lane_x);
2845
2846
    if edge.head_to == Head::None {
2847
        canvas.add_bits(tx + 1, tyc, R);
2848
    } else {
2849
        canvas.set(tx + 1, tyc, head_glyph(edge.head_to, '◄'), Cls::Edge);
2850
    }
2851
    if edge.head_from != Head::None {
2852
        canvas.set(sx, sy, head_glyph(edge.head_from, '◄'), Cls::Edge);
2853
    }
2854
2855
    if let Some(label) = &edge.label {
2856
        place_label(
2857
            canvas,
2858
            label,
2859
            tyc.saturating_sub(1),
2860
            lane_x.saturating_sub(label.width() + 1),
2861
        );
2862
    }
2863
}
2864
2865
fn route_forward_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, bus: usize) {
2866
    let rx = from.x + from.w - 1;
2867
    let ry = from.cy;
2868
    let ly = to.cy;
2869
    let head_col = to.x - 1;
2870
2871
    canvas.junction(rx, ry, R);
2872
    canvas.seg_h(ry, rx, bus);
2873
    if ry == ly {
2874
        canvas.seg_h(ry, bus, head_col);
2875
    } else {
2876
        canvas.seg_v(bus, ry, ly);
2877
        canvas.seg_h(ly, bus, head_col);
2878
    }
2879
2880
    if edge.head_to == Head::None {
2881
        canvas.add_bits(head_col, ly, R);
2882
    } else {
2883
        canvas.set(head_col, ly, head_glyph(edge.head_to, '▶'), Cls::Edge);
2884
    }
2885
    if edge.head_from != Head::None {
2886
        canvas.set(rx, ry, head_glyph(edge.head_from, '◄'), Cls::Edge);
2887
    }
2888
2889
    if let Some(label) = &edge.label {
2890
        place_label(canvas, label, ly.saturating_sub(1), bus + 1);
2891
    }
2892
}
2893
2894
fn route_back_lr(canvas: &mut Canvas, from: &Placed, to: &Placed, edge: &Edge, lane_y: usize) {
2895
    let sx = from.cx;
2896
    let sy = from.y + from.h - 1;
2897
    let tx = to.cx;
2898
    let ty = to.y + to.h - 1;
2899
2900
    canvas.junction(sx, sy, D);
2901
    canvas.seg_v(sx, sy, lane_y);
2902
    canvas.seg_h(lane_y, sx, tx);
2903
    canvas.seg_v(tx, lane_y, ty + 1);
2904
2905
    if edge.head_to == Head::None {
2906
        canvas.add_bits(tx, ty + 1, D);
2907
    } else {
2908
        canvas.set(tx, ty + 1, head_glyph(edge.head_to, '▲'), Cls::Edge);
2909
    }
2910
    if edge.head_from != Head::None {
2911
        canvas.set(sx, sy, head_glyph(edge.head_from, '▲'), Cls::Edge);
2912
    }
2913
2914
    if let Some(label) = &edge.label {
2915
        place_label(canvas, label, lane_y.saturating_sub(1), (sx + tx) / 2);
2916
    }
2917
}
2918
2919
fn place_label(canvas: &mut Canvas, label: &str, row: usize, start_x: usize) {
2920
    if row >= canvas.h {
2921
        return;
2922
    }
2923
    let text = fit_label(label, MAX_LABEL);
2924
    let mut x = start_x;
2925
    for c in text.chars() {
2926
        let cw = char_width(c).max(1);
2927
        if x + cw > canvas.w {
2928
            break;
2929
        }
2930
        let blocked = (0..cw).any(|k| {
2931
            let i = canvas.idx(x + k, row);
2932
            canvas.ch[i] != ' ' || canvas.mask[i] != 0 || canvas.occupied[i]
2933
        });
2934
        if blocked {
2935
            break;
2936
        }
2937
        canvas.set(x, row, c, Cls::EdgeLabel);
2938
        for k in 1..cw {
2939
            canvas.set(x + k, row, CONT, Cls::EdgeLabel);
2940
        }
2941
        x += cw;
2942
    }
2943
}
2944
2945
fn compute_ranks(graph: &Graph) -> Vec<usize> {
2946
    let n = graph.nodes.len();
2947
    let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
2948
    let mut indeg = vec![0usize; n];
2949
    for e in &graph.edges {
2950
        if e.from != e.to {
2951
            children[e.from].push(e.to);
2952
            indeg[e.to] += 1;
2953
        }
2954
    }
2955
2956
    let mut color = vec![0u8; n];
2957
    let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
2958
    let mut order: Vec<usize> = Vec::with_capacity(n);
2959
2960
    let roots: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
2961
    for start in roots.iter().copied().chain(0..n) {
2962
        if color[start] == 0 {
2963
            dfs_dag(start, &children, &mut color, &mut dag, &mut order);
2964
        }
2965
    }
2966
2967
    let mut rank = vec![0usize; n];
2968
    for &u in order.iter().rev() {
2969
        for &v in &dag[u] {
2970
            rank[v] = rank[v].max(rank[u] + 1);
2971
        }
2972
    }
2973
    rank
2974
}
2975
2976
fn dfs_dag(
2977
    start: usize,
2978
    children: &[Vec<usize>],
2979
    color: &mut [u8],
2980
    dag: &mut [Vec<usize>],
2981
    order: &mut Vec<usize>,
2982
) {
2983
    let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
2984
    color[start] = 1;
2985
    while let Some(frame) = stack.last_mut() {
2986
        let u = frame.0;
2987
        if frame.1 < children[u].len() {
2988
            let v = children[u][frame.1];
2989
            frame.1 += 1;
2990
            if color[v] == 1 {
2991
                continue;
2992
            }
2993
            dag[u].push(v);
2994
            if color[v] == 0 {
2995
                color[v] = 1;
2996
                stack.push((v, 0));
2997
            }
2998
        } else {
2999
            color[u] = 2;
3000
            order.push(u);
3001
            stack.pop();
3002
        }
3003
    }
3004
}
3005
3006
const SEQ_GAP: usize = 5;
3007
const SEQ_OPS: &[(&str, bool, SeqHead)] = &[
3008
    ("-->>", true, SeqHead::Arrow),
3009
    ("->>", false, SeqHead::Arrow),
3010
    ("--x", true, SeqHead::Cross),
3011
    ("-x", false, SeqHead::Cross),
3012
    ("--)", true, SeqHead::Arrow),
3013
    ("-)", false, SeqHead::Arrow),
3014
    ("-->", true, SeqHead::Arrow),
3015
    ("->", false, SeqHead::Arrow),
3016
];
3017
3018
#[derive(Clone, Copy, PartialEq)]
3019
enum SeqHead {
3020
    Arrow,
3021
    Cross,
3022
}
3023
3024
enum NoteAnchor {
3025
    Over(usize, usize),
3026
    Left(usize),
3027
    Right(usize),
3028
}
3029
3030
enum SeqItem {
3031
    Message {
3032
        from: usize,
3033
        to: usize,
3034
        text: Option<String>,
3035
        dashed: bool,
3036
        head: SeqHead,
3037
    },
3038
    Note {
3039
        anchor: NoteAnchor,
3040
        text: String,
3041
    },
3042
    Divider {
3043
        text: String,
3044
    },
3045
}
3046
3047
struct Sequence {
3048
    labels: Vec<String>,
3049
    index: HashMap<String, usize>,
3050
    items: Vec<SeqItem>,
3051
}
3052
3053
impl Sequence {
3054
    fn participant(&mut self, id: &str, label: Option<&str>) -> Option<usize> {
3055
        if let Some(&i) = self.index.get(id) {
3056
            if let Some(label) = label {
3057
                self.labels[i] = label.to_string();
3058
            }
3059
            return Some(i);
3060
        }
3061
        if self.labels.len() >= MAX_NODES {
3062
            return None;
3063
        }
3064
        self.index.insert(id.to_string(), self.labels.len());
3065
        self.labels.push(label.unwrap_or(id).to_string());
3066
        Some(self.labels.len() - 1)
3067
    }
3068
}
3069
3070
fn parse_sequence(src: &str) -> Option<Sequence> {
3071
    let mut statements: Vec<String> = Vec::new();
3072
    for raw_line in src.lines() {
3073
        split_statements(raw_line, &mut statements);
3074
    }
3075
    let header = statements.first()?;
3076
    if !header
3077
        .split_whitespace()
3078
        .next()?
3079
        .eq_ignore_ascii_case("sequencediagram")
3080
    {
3081
        return None;
3082
    }
3083
3084
    let mut seq = Sequence {
3085
        labels: Vec::new(),
3086
        index: HashMap::new(),
3087
        items: Vec::new(),
3088
    };
3089
    let mut autonumber = false;
3090
    let mut msg_count = 0usize;
3091
    let mut blocks: Vec<bool> = Vec::new();
3092
3093
    for st in &statements[1..] {
3094
        let first = st.split_whitespace().next().unwrap_or("");
3095
        match first.to_ascii_lowercase().as_str() {
3096
            "participant" | "actor" => {
3097
                let rest = st[first.len()..].trim();
3098
                if rest.is_empty() {
3099
                    return None;
3100
                }
3101
                let (id, label) = match rest.split_once(" as ") {
3102
                    Some((id, label)) => (id.trim(), Some(clean_label(label))),
3103
                    None => (rest, None),
3104
                };
3105
                seq.participant(id, label.as_deref())?;
3106
            }
3107
            "autonumber" => autonumber = true,
3108
            "activate" | "deactivate" | "create" | "destroy" | "title" | "acctitle"
3109
            | "accdescr" | "links" | "link" | "properties" => {}
3110
            "note" => {
3111
                let rest = st[first.len()..].trim();
3112
                let (text_part, anchor) = parse_note_anchor(rest, &mut seq)?;
3113
                if seq.items.len() >= MAX_EDGES {
3114
                    return None;
3115
                }
3116
                seq.items.push(SeqItem::Note {
3117
                    anchor,
3118
                    text: text_part,
3119
                });
3120
            }
3121
            "loop" | "alt" | "opt" | "par" | "critical" | "break" | "else" | "and" | "option" => {
3122
                if matches!(
3123
                    first.to_ascii_lowercase().as_str(),
3124
                    "else" | "and" | "option"
3125
                ) {
3126
                    if blocks.last() != Some(&true) {
3127
                        continue;
3128
                    }
3129
                } else {
3130
                    blocks.push(true);
3131
                }
3132
                if seq.items.len() >= MAX_EDGES {
3133
                    return None;
3134
                }
3135
                seq.items.push(SeqItem::Divider {
3136
                    text: decode_html_entities(st),
3137
                });
3138
            }
3139
            "rect" | "box" => blocks.push(false),
3140
            "end" => {
3141
                if blocks.pop() == Some(true) {
3142
                    if seq.items.len() >= MAX_EDGES {
3143
                        return None;
3144
                    }
3145
                    seq.items.push(SeqItem::Divider {
3146
                        text: "end".to_string(),
3147
                    });
3148
                }
3149
            }
3150
            _ => {
3151
                let (from, to, mut text, dashed, head) = parse_seq_message(st, &mut seq)?;
3152
                if autonumber {
3153
                    msg_count += 1;
3154
                    text = Some(match text {
3155
                        Some(t) => format!("{msg_count}. {t}"),
3156
                        None => format!("{msg_count}."),
3157
                    });
3158
                }
3159
                if seq.items.len() >= MAX_EDGES {
3160
                    return None;
3161
                }
3162
                seq.items.push(SeqItem::Message {
3163
                    from,
3164
                    to,
3165
                    text,
3166
                    dashed,
3167
                    head,
3168
                });
3169
            }
3170
        }
3171
    }
3172
3173
    if seq.labels.is_empty() {
3174
        return None;
3175
    }
3176
    Some(seq)
3177
}
3178
3179
fn parse_note_anchor(rest: &str, seq: &mut Sequence) -> Option<(String, NoteAnchor)> {
3180
    let lower = rest.to_ascii_lowercase();
3181
    let (ids_and_text, kind) = if let Some(r) = lower.strip_prefix("over ") {
3182
        (&rest[rest.len() - r.len()..], 0u8)
3183
    } else if let Some(r) = lower.strip_prefix("left of ") {
3184
        (&rest[rest.len() - r.len()..], 1)
3185
    } else if let Some(r) = lower.strip_prefix("right of ") {
3186
        (&rest[rest.len() - r.len()..], 2)
3187
    } else {
3188
        return None;
3189
    };
3190
    let (ids, text) = ids_and_text.split_once(':')?;
3191
    let text = decode_html_entities(text.trim());
3192
    let mut parts = ids.split(',').map(str::trim).filter(|s| !s.is_empty());
3193
    let a = seq.participant(parts.next()?, None)?;
3194
    let anchor = match kind {
3195
        0 => {
3196
            let b = match parts.next() {
3197
                Some(id) => seq.participant(id, None)?,
3198
                None => a,
3199
            };
3200
            NoteAnchor::Over(a.min(b), a.max(b))
3201
        }
3202
        1 => NoteAnchor::Left(a),
3203
        _ => NoteAnchor::Right(a),
3204
    };
3205
    Some((text, anchor))
3206
}
3207
3208
fn parse_seq_message(
3209
    st: &str,
3210
    seq: &mut Sequence,
3211
) -> Option<(usize, usize, Option<String>, bool, SeqHead)> {
3212
    let mut found: Option<(usize, &str, bool, SeqHead)> = None;
3213
    for (pos, _) in st.char_indices() {
3214
        for &(op, dashed, head) in SEQ_OPS {
3215
            if st[pos..].starts_with(op) {
3216
                found = Some((pos, op, dashed, head));
3217
                break;
3218
            }
3219
        }
3220
        if found.is_some() {
3221
            break;
3222
        }
3223
    }
3224
    let (pos, op, dashed, head) = found?;
3225
    let from_id = st[..pos].trim();
3226
    if from_id.is_empty() {
3227
        return None;
3228
    }
3229
    let rest = st[pos + op.len()..]
3230
        .trim_start()
3231
        .trim_start_matches(['+', '-']);
3232
    let (to_id, text) = match rest.split_once(':') {
3233
        Some((to, text)) => (to.trim(), non_empty(decode_html_entities(text.trim()))),
3234
        None => (rest.trim(), None),
3235
    };
3236
    if to_id.is_empty() {
3237
        return None;
3238
    }
3239
    let from = seq.participant(from_id, None)?;
3240
    let to = seq.participant(to_id, None)?;
3241
    Some((from, to, text, dashed, head))
3242
}
3243
3244
fn note_geometry(xs: &[usize], anchor: &NoteAnchor, text_w: usize) -> (usize, usize) {
3245
    match *anchor {
3246
        NoteAnchor::Over(l, r) => {
3247
            let center = (xs[l] + xs[r]) / 2;
3248
            let w = (xs[r] - xs[l] + 5).max(text_w + 2 * PAD + 2);
3249
            (center.saturating_sub(w / 2), w)
3250
        }
3251
        NoteAnchor::Left(i) => {
3252
            let w = text_w + 2 * PAD + 2;
3253
            (xs[i].saturating_sub(2 + w - 1), w)
3254
        }
3255
        NoteAnchor::Right(i) => (xs[i] + 2, text_w + 2 * PAD + 2),
3256
    }
3257
}
3258
3259
fn layout_sequence(
3260
    seq: &Sequence,
3261
    styles: &MermaidStyles,
3262
    max_width: Option<usize>,
3263
) -> Result<MermaidArt, Oversize> {
3264
    let n = seq.labels.len();
3265
    let labels: Vec<String> = seq
3266
        .labels
3267
        .iter()
3268
        .map(|l| fit_label(l, WRAP_WIDTH))
3269
        .collect();
3270
    let box_w: Vec<usize> = labels
3271
        .iter()
3272
        .map(|l| l.width().max(1) + 2 * PAD + 2)
3273
        .collect();
3274
    let box_h = 3usize;
3275
3276
    let item_text_w = |text: &Option<String>| text.as_deref().map(|t| t.width()).unwrap_or(0);
3277
3278
    let mut gaps: Vec<usize> = (0..n.saturating_sub(1))
3279
        .map(|i| SEQ_GAP.max(box_w[i].div_ceil(2) + box_w[i + 1].div_ceil(2) + 1))
3280
        .collect();
3281
3282
    let mut reqs: Vec<(usize, usize, usize)> = Vec::new();
3283
    for item in &seq.items {
3284
        match item {
3285
            SeqItem::Message { from, to, text, .. } => {
3286
                let tw = item_text_w(text);
3287
                if from != to {
3288
                    let (l, r) = (*from.min(to), *from.max(to));
3289
                    reqs.push((l, r, (tw + 2).max(4)));
3290
                } else if *from + 1 < n {
3291
                    reqs.push((*from, *from + 1, 5 + tw + 2));
3292
                }
3293
            }
3294
            SeqItem::Note { anchor, text } => {
3295
                let tw = text.width();
3296
                match *anchor {
3297
                    NoteAnchor::Over(l, r) if l < r => reqs.push((l, r, tw.saturating_sub(1))),
3298
                    NoteAnchor::Over(i, _) => {
3299
                        let half = (tw + 4).div_ceil(2) + 2;
3300
                        if i > 0 {
3301
                            reqs.push((i - 1, i, half));
3302
                        }
3303
                        if i + 1 < n {
3304
                            reqs.push((i, i + 1, half));
3305
                        }
3306
                    }
3307
                    NoteAnchor::Left(i) if i > 0 => reqs.push((i - 1, i, tw + 7)),
3308
                    NoteAnchor::Right(i) if i + 1 < n => reqs.push((i, i + 1, tw + 7)),
3309
                    _ => {}
3310
                }
3311
            }
3312
            SeqItem::Divider { .. } => {}
3313
        }
3314
    }
3315
    reqs.sort_by_key(|&(l, r, _)| r - l);
3316
    for (l, r, need) in reqs {
3317
        let cur: usize = gaps[l..r].iter().sum();
3318
        if cur < need {
3319
            gaps[r - 1] += need - cur;
3320
        }
3321
    }
3322
3323
    let mut xs = vec![0usize; n];
3324
    xs[0] = box_w[0] / 2;
3325
    for i in 1..n {
3326
        xs[i] = xs[i - 1] + gaps[i - 1];
3327
    }
3328
3329
    let mut canvas_w = xs[n - 1] + box_w[n - 1].div_ceil(2) + 1;
3330
    for item in &seq.items {
3331
        match item {
3332
            SeqItem::Message { from, to, text, .. } if from == to => {
3333
                canvas_w = canvas_w.max(xs[*from] + 5 + item_text_w(text) + 1);
3334
            }
3335
            SeqItem::Note { anchor, text } => {
3336
                let (x, w) = note_geometry(&xs, anchor, text.width());
3337
                canvas_w = canvas_w.max(x + w + 1);
3338
            }
3339
            SeqItem::Divider { text } => {
3340
                canvas_w = canvas_w.max(text.width() + 4);
3341
            }
3342
            _ => {}
3343
        }
3344
    }
3345
3346
    let mut rows: Vec<usize> = Vec::with_capacity(seq.items.len());
3347
    let mut y = box_h + 1;
3348
    for item in &seq.items {
3349
        rows.push(y);
3350
        y += match item {
3351
            SeqItem::Message { from, to, text, .. } => {
3352
                if from == to {
3353
                    4
3354
                } else if text.is_some() {
3355
                    3
3356
                } else {
3357
                    2
3358
                }
3359
            }
3360
            SeqItem::Note { .. } => 4,
3361
            SeqItem::Divider { .. } => 2,
3362
        };
3363
    }
3364
    let bottom_top = y;
3365
    let canvas_h = bottom_top + box_h;
3366
3367
    if let Some(mw) = max_width
3368
        && canvas_w > mw
3369
    {
3370
        return Err(Oversize::Width);
3371
    }
3372
    if canvas_w.saturating_mul(canvas_h) > MAX_CANVAS_CELLS {
3373
        return Err(Oversize::Cells);
3374
    }
3375
3376
    let mut canvas = Canvas::new(canvas_w, canvas_h);
3377
    for i in 0..n {
3378
        for by in [0, bottom_top] {
3379
            let p = Placed {
3380
                x: xs[i].saturating_sub(box_w[i] / 2),
3381
                y: by,
3382
                w: box_w[i],
3383
                h: box_h,
3384
                cx: xs[i],
3385
                cy: by + 1,
3386
                rank: 0,
3387
            };
3388
            draw_box(
3389
                &mut canvas,
3390
                &p,
3391
                std::slice::from_ref(&labels[i]),
3392
                Shape::Rect,
3393
            );
3394
        }
3395
    }
3396
    for (item, &r) in seq.items.iter().zip(&rows) {
3397
        if let SeqItem::Note { anchor, text } = item {
3398
            let (x, w) = note_geometry(&xs, anchor, text.width());
3399
            let p = Placed {
3400
                x,
3401
                y: r,
3402
                w,
3403
                h: 3,
3404
                cx: x + w / 2,
3405
                cy: r + 1,
3406
                rank: 0,
3407
            };
3408
            draw_box(&mut canvas, &p, std::slice::from_ref(text), Shape::Rect);
3409
        }
3410
    }
3411
    for &x in &xs {
3412
        canvas.junction(x, box_h - 1, D);
3413
        canvas.seg_v(x, box_h, bottom_top - 1);
3414
        canvas.junction(x, bottom_top, U);
3415
    }
3416
3417
    for (item, &r) in seq.items.iter().zip(&rows) {
3418
        match item {
3419
            SeqItem::Message {
3420
                from,
3421
                to,
3422
                text,
3423
                dashed,
3424
                head,
3425
            } => {
3426
                let line_ch = if *dashed { '╌' } else { '─' };
3427
                if from == to {
3428
                    let x = xs[*from];
3429
                    canvas.junction(x, r, R);
3430
                    canvas.set(x + 1, r, line_ch, Cls::Edge);
3431
                    canvas.set(x + 2, r, line_ch, Cls::Edge);
3432
                    canvas.set(x + 3, r, '╮', Cls::Edge);
3433
                    canvas.set(x + 3, r + 1, '│', Cls::Edge);
3434
                    canvas.set(
3435
                        x + 1,
3436
                        r + 2,
3437
                        if *head == SeqHead::Cross { '×' } else { '◄' },
3438
                        Cls::Edge,
3439
                    );
3440
                    canvas.set(x + 2, r + 2, line_ch, Cls::Edge);
3441
                    canvas.set(x + 3, r + 2, '╯', Cls::Edge);
3442
                    if let Some(t) = text {
3443
                        draw_seq_text(&mut canvas, t, x + 5, r + 1, Cls::Text);
3444
                    }
3445
                } else {
3446
                    let (x0, x1) = (xs[*from], xs[*to]);
3447
                    let rightward = x1 > x0;
3448
                    let arrow_row = if text.is_some() { r + 1 } else { r };
3449
                    let (lo, hi) = (x0.min(x1), x0.max(x1));
3450
                    canvas.junction(x0, arrow_row, if rightward { R } else { L });
3451
                    for x in (lo + 1)..hi {
3452
                        canvas.set(x, arrow_row, line_ch, Cls::Edge);
3453
                    }
3454
                    let head_ch = match (head, rightward) {
3455
                        (SeqHead::Cross, _) => '×',
3456
                        (SeqHead::Arrow, true) => '▶',
3457
                        (SeqHead::Arrow, false) => '◄',
3458
                    };
3459
                    let head_x = if rightward { x1 - 1 } else { x1 + 1 };
3460
                    canvas.set(head_x, arrow_row, head_ch, Cls::Edge);
3461
                    if let Some(t) = text {
3462
                        let span = hi - lo - 1;
3463
                        let t = fit_label(t, span.max(1));
3464
                        let tx = lo + 1 + span.saturating_sub(t.width()) / 2;
3465
                        draw_seq_text(&mut canvas, &t, tx, r, Cls::Text);
3466
                    }
3467
                }
3468
            }
3469
            SeqItem::Note { .. } => {}
3470
            SeqItem::Divider { text } => {
3471
                for x in 0..canvas_w {
3472
                    canvas.set(x, r, '─', Cls::Edge);
3473
                }
3474
                let t = fit_label(text, canvas_w.saturating_sub(4));
3475
                draw_seq_text(&mut canvas, &format!(" {t} "), 2, r, Cls::EdgeLabel);
3476
            }
3477
        }
3478
    }
3479
3480
    canvas.finalize_mask();
3481
    let (styled_lines, plain_lines) = canvas.to_lines(styles);
3482
    Ok(MermaidArt {
3483
        styled_lines,
3484
        plain_lines,
3485
    })
3486
}
3487
3488
fn draw_seq_text(canvas: &mut Canvas, text: &str, x: usize, y: usize, cls: Cls) {
3489
    let mut cur = x;
3490
    for c in text.chars() {
3491
        let cw = char_width(c).max(1);
3492
        for k in 0..cw {
3493
            if cur + k < canvas.w && y < canvas.h {
3494
                let i = canvas.idx(cur + k, y);
3495
                canvas.mask[i] = 0;
3496
            }
3497
            canvas.set(cur + k, y, if k == 0 { c } else { CONT }, cls);
3498
        }
3499
        cur += cw;
3500
    }
3501
}
3502
3503
const TOO_WIDE_HINT: &str =
3504
    "This diagram is too wide to display here \u{2014} open the image to view it in full.";
3505
3506
fn fallback(
3507
    src: &str,
3508
    styles: &MermaidStyles,
3509
    max_width: Option<usize>,
3510
    too_wide: bool,
3511
) -> MermaidArt {
3512
    let header = first_word(src);
3513
    let title = format!(" mermaid: {header} ");
3514
    let limit = max_width.map(|m| m.saturating_sub(4).max(8));
3515
    let body: Vec<String> = src
3516
        .lines()
3517
        .map(|l| l.trim_end())
3518
        .skip_while(|l| l.is_empty())
3519
        .flat_map(|l| chunk_line(l, limit))
3520
        .collect();
3521
    let content_w = body
3522
        .iter()
3523
        .map(|l| l.width())
3524
        .chain(std::iter::once(title.width()))
3525
        .max()
3526
        .unwrap_or(0);
3527
    let inner = content_w + 2;
3528
3529
    let mut styled = Vec::new();
3530
    let mut plain = Vec::new();
3531
3532
    let mut top = String::from("╭");
3533
    top.push_str(&title);
3534
    for _ in 0..inner.saturating_sub(title.width()) {
3535
        top.push('─');
3536
    }
3537
    top.push('╮');
3538
    styled.push(Line::from(vec![
3539
        Span::styled("╭".to_string(), styles.border),
3540
        Span::styled(title.clone(), styles.title),
3541
        Span::styled(
3542
            format!("{}╮", "─".repeat(inner.saturating_sub(title.width()))),
3543
            styles.border,
3544
        ),
3545
    ]));
3546
    plain.push(top);
3547
3548
    for line in &body {
3549
        let pad = content_w.saturating_sub(line.width());
3550
        styled.push(Line::from(vec![
3551
            Span::styled("│ ".to_string(), styles.border),
3552
            Span::styled(line.clone(), styles.node_text),
3553
            Span::styled(format!("{} │", " ".repeat(pad)), styles.border),
3554
        ]));
3555
        plain.push(format!("│ {}{} │", line, " ".repeat(pad)));
3556
    }
3557
3558
    let bottom = format!("╰{}╯", "─".repeat(inner));
3559
    styled.push(Line::from(Span::styled(bottom.clone(), styles.border)));
3560
    plain.push(bottom);
3561
3562
    if too_wide {
3563
        let hint_style = styles.border.add_modifier(Modifier::ITALIC);
3564
        for chunk in wrap_words(TOO_WIDE_HINT, max_width) {
3565
            styled.push(Line::from(Span::styled(chunk.clone(), hint_style)));
3566
            plain.push(chunk);
3567
        }
3568
    }
3569
3570
    MermaidArt {
3571
        styled_lines: styled,
3572
        plain_lines: plain,
3573
    }
3574
}
3575
3576
fn chunk_line(line: &str, limit: Option<usize>) -> Vec<String> {
3577
    let Some(limit) = limit else {
3578
        return vec![line.to_string()];
3579
    };
3580
    if line.width() <= limit {
3581
        return vec![line.to_string()];
3582
    }
3583
    let mut out = Vec::new();
3584
    let mut cur = String::new();
3585
    let mut cur_w = 0usize;
3586
    for c in line.chars() {
3587
        let cw = char_width(c).max(1);
3588
        if cur_w + cw > limit && !cur.is_empty() {
3589
            out.push(std::mem::take(&mut cur));
3590
            cur_w = 0;
3591
        }
3592
        cur.push(c);
3593
        cur_w += cw;
3594
    }
3595
    if !cur.is_empty() {
3596
        out.push(cur);
3597
    }
3598
    out
3599
}
3600
3601
fn wrap_words(text: &str, limit: Option<usize>) -> Vec<String> {
3602
    let Some(limit) = limit else {
3603
        return vec![text.to_string()];
3604
    };
3605
    let mut lines: Vec<String> = Vec::new();
3606
    let mut cur = String::new();
3607
    for word in text.split(' ').filter(|w| !w.is_empty()) {
3608
        if cur.is_empty() {
3609
            cur.push_str(word);
3610
        } else if cur.width() + 1 + word.width() <= limit {
3611
            cur.push(' ');
3612
            cur.push_str(word);
3613
        } else {
3614
            lines.push(std::mem::take(&mut cur));
3615
            cur.push_str(word);
3616
        }
3617
    }
3618
    if !cur.is_empty() {
3619
        lines.push(cur);
3620
    }
3621
    lines
3622
        .into_iter()
3623
        .flat_map(|l| chunk_line(&l, Some(limit)))
3624
        .collect()
3625
}
3626
3627
fn first_word(src: &str) -> String {
3628
    src.split_whitespace()
3629
        .next()
3630
        .unwrap_or("diagram")
3631
        .to_string()
3632
}
3633
3634
#[cfg(test)]
3635
mod tests {
3636
    use super::*;
3637
3638
    fn styles() -> MermaidStyles {
3639
        let s = Style::default();
3640
        MermaidStyles {
3641
            border: s,
3642
            node_text: s,
3643
            edge: s,
3644
            edge_label: s,
3645
            title: s,
3646
        }
3647
    }
3648
3649
    fn plain(src: &str) -> String {
3650
        render(src, &styles(), Some(120))
3651
            .unwrap()
3652
            .plain_lines
3653
            .join("\n")
3654
    }
3655
3656
    #[test]
3657
    fn parses_nodes_edges_and_direction() {
3658
        let g = parse_graph("flowchart LR\n  A[Start] --> B[End]").unwrap();
3659
        assert_eq!(g.nodes.len(), 2);
3660
        assert_eq!(g.edges.len(), 1);
3661
        assert_eq!(g.nodes[0].label, "Start");
3662
        assert_eq!(g.nodes[1].label, "End");
3663
        assert!(g.dir == Dir::Right);
3664
    }
3665
3666
    #[test]
3667
    fn non_flowchart_returns_none_from_parse() {
3668
        assert!(parse_graph("sequenceDiagram\n  A->>B: hi").is_none());
3669
    }
3670
3671
    #[test]
3672
    fn html_tags_are_stripped_from_labels() {
3673
        let g = parse_graph("flowchart TD\n  A[\"<b>Bold</b> and <i>italic</i>\"] --> B").unwrap();
3674
        assert_eq!(g.nodes[0].label, "Bold and italic");
3675
    }
3676
3677
    #[test]
3678
    fn br_tag_becomes_a_space() {
3679
        let g = parse_graph("flowchart TD\n  A[\"Line1<br/>Line2<br>Line3\"]").unwrap();
3680
        assert_eq!(g.nodes[0].label, "Line1 Line2 Line3");
3681
    }
3682
3683
    #[test]
3684
    fn markdown_string_strips_bold_italic_and_code() {
3685
        let g = parse_graph(
3686
            "flowchart TD\n  A[\"`**Start** here`\"] --> B[\"`Save to **database**`\"]\n  B --> C[\"`**Done!**`\"]",
3687
        )
3688
        .unwrap();
3689
        assert_eq!(g.nodes[0].label, "Start here");
3690
        assert_eq!(g.nodes[1].label, "Save to database");
3691
        assert_eq!(g.nodes[2].label, "Done!");
3692
    }
3693
3694
    #[test]
3695
    fn markdown_string_preserves_snake_case_and_strips_inline_code() {
3696
        let g = parse_graph("flowchart TD\n  A[\"`_italic_ uses `vocab_size` with __all__`\"]")
3697
            .unwrap();
3698
        assert_eq!(g.nodes[0].label, "italic uses vocab_size with all");
3699
    }
3700
3701
    #[test]
3702
    fn markdown_string_edge_label_is_stripped() {
3703
        let g =
3704
            parse_graph("flowchart TD\n  A -->|\"`**yes**`\"| B\n  A -->|\"`__no__`\"| C").unwrap();
3705
        assert_eq!(g.edges[0].label.as_deref(), Some("yes"));
3706
        assert_eq!(g.edges[1].label.as_deref(), Some("no"));
3707
    }
3708
3709
    #[test]
3710
    fn plain_label_keeps_literal_text_and_underscores() {
3711
        // Not a markdown string (no backtick wrapper): Mermaid renders it
3712
        // literally, so brackets, snake_case, and any `*`/`_` must survive.
3713
        let g = parse_graph("flowchart TD\n  A[\"[ 464, 3797 ] seq_len d_model\"]").unwrap();
3714
        assert_eq!(g.nodes[0].label, "[ 464, 3797 ] seq_len d_model");
3715
    }
3716
3717
    #[test]
3718
    fn code_and_span_tags_are_stripped() {
3719
        let g = parse_graph(
3720
            "flowchart TD\n  A[\"<code>vocab_size</code> <span style=\\\"color:red\\\">x</span>\"]",
3721
        )
3722
        .unwrap();
3723
        assert_eq!(g.nodes[0].label, "vocab_size x");
3724
    }
3725
3726
    #[test]
3727
    fn bare_angle_brackets_are_kept() {
3728
        let g = parse_graph("flowchart TD\n  A[\"a < b and c > d\"]").unwrap();
3729
        assert_eq!(g.nodes[0].label, "a < b and c > d");
3730
    }
3731
3732
    #[test]
3733
    fn generic_types_are_not_stripped_as_html() {
3734
        // `<String>` / `<i32>` / `<id>` look like tags but are not HTML
3735
        // formatting tags, so they must survive (only b/i/code/span/… etc. and
3736
        // <br> are stripped).
3737
        let g = parse_graph(
3738
            "flowchart TD\n  A[\"Returns Vec<String>\"] --> B[\"Option<i32> for <id>\"]",
3739
        )
3740
        .unwrap();
3741
        assert_eq!(g.nodes[0].label, "Returns Vec<String>");
3742
        assert_eq!(g.nodes[1].label, "Option<i32> for <id>");
3743
    }
3744
3745
    #[test]
3746
    fn decode_html_entities_covers_named_numeric_and_double_escape() {
3747
        assert_eq!(
3748
            decode_html_entities("&lt;a&gt; &amp; &quot;x&quot; &apos;y&apos;"),
3749
            "<a> & \"x\" 'y'"
3750
        );
3751
        assert_eq!(decode_html_entities("it&#39;s &#60;ok&#62;"), "it's <ok>");
3752
        assert_eq!(
3753
            decode_html_entities("&#x3c;tag&#X3E; &#x27;q&#x27;"),
3754
            "<tag> 'q'"
3755
        );
3756
        // `&amp;lt;` must yield the literal `&lt;`, never `<`.
3757
        assert_eq!(decode_html_entities("&amp;lt;"), "&lt;");
3758
        assert_eq!(decode_html_entities("a &foo; b & c"), "a &foo; b & c");
3759
        // Control chars (NUL collides with CONT, ESC injects ANSI) never decode.
3760
        assert_eq!(decode_html_entities("a&#27;b&#0;c"), "a&#27;b&#0;c");
3761
        assert_eq!(decode_html_entities("x&#x1b;y"), "x&#x1b;y");
3762
    }
3763
3764
    #[test]
3765
    fn entity_escaped_flowchart_label_decodes_in_box_art() {
3766
        let src = "flowchart LR\n  YAML[\"models-config/&lt;model&gt;/&lt;env&gt;.yaml\\nenterprise_api_config:\"]\n  PY[\"model_config_map.py\\nlanguage_model_dict_to_proto()\"]\n  YAML --> PY";
3767
        let g = parse_graph(src).unwrap();
3768
        assert!(
3769
            g.nodes[0]
3770
                .label
3771
                .contains("models-config/<model>/<env>.yaml"),
3772
            "{}",
3773
            g.nodes[0].label
3774
        );
3775
        let art = plain(src);
3776
        assert!(art.contains("<model>") && art.contains("<env>"), "{art}");
3777
        assert!(!art.contains("&lt;") && !art.contains("&gt;"), "{art}");
3778
    }
3779
3780
    #[test]
3781
    fn direct_push_sinks_decode_entities() {
3782
        // Entities contain `;`, which split_statements treats as a separator, so
3783
        // they reach a sink intact only inside quotes; assert through the real
3784
        // parsers where such quoting works.
3785
        let g = parse_state(
3786
            "stateDiagram-v2\n  state \"work &lt;job&gt;\" as J\n  Idle --> Run: \"on &lt;go&gt;\"\n  Run: \"d &lt;e&gt;\"",
3787
        )
3788
        .unwrap();
3789
        let node = |s: &str| g.nodes.iter().any(|n| n.label.contains(s));
3790
        let edge = |s: &str| {
3791
            g.edges
3792
                .iter()
3793
                .any(|e| e.label.as_deref().is_some_and(|l| l.contains(s)))
3794
        };
3795
        assert!(node("work <job>") && node("d <e>") && edge("on <go>"));
3796
        assert!(!node("&lt;") && !edge("&lt;"));
3797
3798
        let (cg, _) = parse_class("classDiagram\n  A --> B : \"uses &lt;X&gt;\"").unwrap();
3799
        assert!(cg.edges.iter().any(|e| {
3800
            e.label
3801
                .as_deref()
3802
                .is_some_and(|l| l.contains("uses <X>") && !l.contains("&lt;"))
3803
        }));
3804
3805
        let s = parse_sequence(
3806
            "sequenceDiagram\n  A->>B: \"call &lt;svc&gt;\"\n  Note over A,B: \"memo &lt;o&gt;\"\n  alt \"c &lt;x&gt;\"\n    A->>B: ok\n  end",
3807
        )
3808
        .unwrap();
3809
        assert!(s.items.iter().any(|it| matches!(it,
3810
            SeqItem::Message { text: Some(t), .. } if t.contains("call <svc>") && !t.contains("&lt;"))));
3811
        assert!(s.items.iter().any(|it| matches!(it,
3812
            SeqItem::Note { text, .. } if text.contains("memo <o>") && !text.contains("&lt;"))));
3813
        assert!(s.items.iter().any(|it| matches!(it,
3814
            SeqItem::Divider { text } if text.contains("c <x>") && !text.contains("&lt;"))));
3815
3816
        // Class members and ER attributes have no clean quoted form (splitter
3817
        // fragments unquoted `;`; ER drops quoted text as a comment), so exercise
3818
        // those decodes at the finalizer directly.
3819
        let mut member = ClassInfo::default();
3820
        push_member(&mut member, "+run &lt;R&gt;");
3821
        assert_eq!(member.attrs, vec!["+run <R>".to_string()]);
3822
        let mut attr = ClassInfo::default();
3823
        push_er_attribute(&mut attr, "string &lt;pk&gt;");
3824
        assert_eq!(attr.attrs, vec!["string <pk>".to_string()]);
3825
    }
3826
3827
    #[test]
3828
    fn quoted_label_with_inner_brackets_is_one_node() {
3829
        let g = parse_graph(
3830
            "flowchart TD\n  IDs[\"<b>Token IDs</b><br/>[ 464, 3797 ]<br/><i>indices</i>\"]",
3831
        )
3832
        .unwrap();
3833
        assert_eq!(g.nodes.len(), 1, "inner brackets must not split the node");
3834
        assert_eq!(g.edges.len(), 0, "no phantom edges from <br/> + brackets");
3835
        assert_eq!(g.nodes[0].label, "Token IDs [ 464, 3797 ] indices");
3836
    }
3837
3838
    #[test]
3839
    fn unquoted_label_with_embedded_quote_closes_at_bracket() {
3840
        let g = parse_graph("flowchart TD\n  A[5\" pipe] --> B[24\" display]").unwrap();
3841
        assert_eq!(g.nodes.len(), 2);
3842
        assert_eq!(g.edges.len(), 1);
3843
        assert_eq!(g.nodes[0].label, "5\" pipe");
3844
        assert_eq!(g.nodes[1].label, "24\" display");
3845
    }
3846
3847
    #[test]
3848
    fn quoted_label_with_inner_parens_is_one_node() {
3849
        let g =
3850
            parse_graph("flowchart TD\n  A[\"Tokenizer (BPE / WordPiece)\"] --> B[Done]").unwrap();
3851
        assert_eq!(g.nodes.len(), 2);
3852
        assert_eq!(g.edges.len(), 1);
3853
        assert_eq!(g.nodes[0].label, "Tokenizer (BPE / WordPiece)");
3854
    }
3855
3856
    #[test]
3857
    fn diagram_with_html_labels_renders_without_tag_artifacts() {
3858
        let src = "flowchart TD\n  IDs[\"<b>3. Token IDs</b><br/>[ 464, 3797 ]<br/><i>indices</i>\"] --> Out[\"<b>done</b>\"]";
3859
        let out = plain(src);
3860
        assert!(!out.contains("<b>"), "raw HTML tag leaked:\n{out}");
3861
        assert!(!out.contains("</"), "raw closing tag leaked:\n{out}");
3862
        assert!(!out.contains("br/"), "phantom br artifact leaked:\n{out}");
3863
        assert!(out.contains("Token IDs"), "label text missing:\n{out}");
3864
    }
3865
3866
    #[test]
3867
    fn ranks_ignore_back_edges() {
3868
        let g = parse_graph("graph TD\n A-->B\n B-->C\n C-->A").unwrap();
3869
        let r = compute_ranks(&g);
3870
        let idx = |id: &str| g.index[id];
3871
        assert_eq!(r[idx("A")], 0);
3872
        assert_eq!(r[idx("B")], 1);
3873
        assert_eq!(r[idx("C")], 2);
3874
    }
3875
3876
    #[test]
3877
    fn td_render_has_boxes_labels_and_arrow() {
3878
        let out = plain("graph TD\n A[Start] --> B[End]");
3879
        assert!(out.contains("Start"), "{out}");
3880
        assert!(out.contains("End"), "{out}");
3881
        assert!(out.contains('┌') || out.contains('╭'), "{out}");
3882
        assert!(out.contains('▼'), "{out}");
3883
    }
3884
3885
    #[test]
3886
    fn edge_label_is_rendered() {
3887
        let out = plain("graph TD\n A-->|yes| B");
3888
        assert!(out.contains("yes"), "{out}");
3889
    }
3890
3891
    #[test]
3892
    fn lr_is_shorter_than_td_for_a_chain() {
3893
        let chain = "A --> B --> C --> D";
3894
        let td = render(&format!("graph TD\n {chain}"), &styles(), Some(120))
3895
            .unwrap()
3896
            .plain_lines
3897
            .len();
3898
        let lr = render(&format!("flowchart LR\n {chain}"), &styles(), Some(120))
3899
            .unwrap()
3900
            .plain_lines
3901
            .len();
3902
        assert!(lr < td, "expected LR ({lr}) shorter than TD ({td})");
3903
    }
3904
3905
    #[test]
3906
    fn unsupported_diagram_uses_fallback_box() {
3907
        let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d");
3908
        assert!(out.contains("mermaid: gantt"), "{out}");
3909
        assert!(out.contains("Plan"), "{out}");
3910
    }
3911
3912
    #[test]
3913
    fn blank_source_returns_none() {
3914
        assert!(render("   \n  ", &styles(), Some(80)).is_none());
3915
    }
3916
3917
    #[test]
3918
    fn inline_label_with_x_or_o_letters() {
3919
        let g = parse_graph("graph TD\n A -- no exit --> B").unwrap();
3920
        assert_eq!(g.nodes.len(), 2);
3921
        assert_eq!(g.edges.len(), 1);
3922
        assert_eq!(g.edges[0].label.as_deref(), Some("no exit"));
3923
    }
3924
3925
    #[test]
3926
    fn wide_glyph_box_stays_aligned() {
3927
        let lines = render("graph TD\n A[日本語ab]", &styles(), Some(120))
3928
            .unwrap()
3929
            .plain_lines;
3930
        let widths: Vec<usize> = lines
3931
            .iter()
3932
            .filter(|l| !l.trim().is_empty())
3933
            .map(|l| l.width())
3934
            .collect();
3935
        assert!(
3936
            widths.windows(2).all(|w| w[0] == w[1]),
3937
            "box rows must share one width: {widths:?}\n{lines:?}"
3938
        );
3939
        assert!(!lines.iter().any(|l| l.contains(CONT)), "sentinel leaked");
3940
    }
3941
3942
    #[test]
3943
    fn merge_has_single_arrowhead() {
3944
        let out = plain("graph TD\n A[aaa] --> D[ddddddd]\n B[bb] --> D\n C[ccccc] --> D");
3945
        let arrows = out.chars().filter(|&c| c == '▼').count();
3946
        assert_eq!(arrows, 1, "merge edges share one arrowhead:\n{out}");
3947
        assert!(!out.contains("▼▼"), "must not stack arrowheads:\n{out}");
3948
    }
3949
3950
    #[test]
3951
    fn long_label_wraps_without_truncation() {
3952
        let out =
3953
            plain("graph TD\n A[Check if the user has permission to access resource] --> B[Done]");
3954
        assert!(out.contains("permission"), "{out}");
3955
        assert!(out.contains("resource"), "{out}");
3956
        assert!(!out.contains('…'), "should wrap, not truncate:\n{out}");
3957
    }
3958
3959
    #[test]
3960
    fn very_long_label_truncates_after_max_lines() {
3961
        let long = "alpha ".repeat(40);
3962
        let out = plain(&format!("graph TD\n A[{}] --> B[x]", long.trim()));
3963
        assert!(out.contains('…'), "should truncate past max lines:\n{out}");
3964
    }
3965
3966
    #[test]
3967
    fn wrap_label_breaks_long_identifier_on_boundary() {
3968
        let lines = wrap_label("mark_filter_restore_context", WRAP_WIDTH, MAX_LINES);
3969
        // The first line ends on an identifier boundary, not a mid-segment slice.
3970
        assert!(
3971
            lines[0].ends_with('_'),
3972
            "first line must end on a boundary: {lines:?}"
3973
        );
3974
        // Every break (all but the last line) lands on a boundary char.
3975
        for line in &lines[..lines.len() - 1] {
3976
            assert!(
3977
                line.ends_with(LABEL_BREAK_CHARS),
3978
                "line must break on a boundary: {line:?}"
3979
            );
3980
        }
3981
        // Nothing is lost: the wrapped lines reconstruct the original word.
3982
        assert_eq!(lines.concat(), "mark_filter_restore_context");
3983
    }
3984
3985
    #[test]
3986
    fn wrap_label_token_without_break_char_falls_back_per_char() {
3987
        let token = "a".repeat(40);
3988
        let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES);
3989
        // No boundary char -> per-char hard break across multiple lines.
3990
        assert!(lines.len() >= 2, "must hard-break: {lines:?}");
3991
        // 40 narrow chars fit in <= MAX_LINES, so nothing is truncated or lost.
3992
        assert_eq!(lines.concat(), token);
3993
    }
3994
3995
    #[test]
3996
    fn flowchart_long_identifier_breaks_on_boundary_not_mid_segment() {
3997
        let out = plain("graph TD\n A[mark_filter_restore_context] --> B[Done]");
3998
        // The boundary-respecting pieces are present in the rendered art; the
3999
        // `wrap_label_breaks_long_identifier_on_boundary` unit test proves there
4000
        // is no mid-segment slice (losslessly), so no offset-coupled guard here.
4001
        assert!(out.contains("mark_filter_restore_"), "{out}");
4002
        assert!(out.contains("context"), "{out}");
4003
    }
4004
4005
    #[test]
4006
    fn wrap_label_mixed_boundary_then_no_boundary_tail() {
4007
        let token = String::from("ab_") + &"c".repeat(40);
4008
        let lines = wrap_label(&token, WRAP_WIDTH, MAX_LINES);
4009
        // The boundary is taken first ...
4010
        assert!(
4011
            lines[0].ends_with('_'),
4012
            "first break on boundary: {lines:?}"
4013
        );
4014
        // ... then the long no-boundary tail falls back to a per-char break.
4015
        assert!(
4016
            lines[1..].iter().any(|l| !l.contains(LABEL_BREAK_CHARS)),
4017
            "a later line must be a per-char break: {lines:?}"
4018
        );
4019
        // 43 cols < MAX_LINES*WRAP_WIDTH, so it must not truncate; fully lossless.
4020
        assert_eq!(lines.concat(), token);
4021
    }
4022
4023
    #[test]
4024
    fn wrap_label_boundary_breaking_still_truncates_at_max_lines() {
4025
        let id = ["segment"; 20].join("_");
4026
        let lines = wrap_label(&id, WRAP_WIDTH, MAX_LINES);
4027
        // The identifier far exceeds MAX_LINES*WRAP_WIDTH, so it truncates ...
4028
        assert_eq!(lines.len(), MAX_LINES);
4029
        // ... with the ellipsis still on the final line.
4030
        assert!(
4031
            lines.last().unwrap().ends_with('…'),
4032
            "truncation must keep the ellipsis: {lines:?}"
4033
        );
4034
    }
4035
4036
    #[test]
4037
    fn bt_flips_orientation() {
4038
        let out = plain("flowchart BT\n A[first] --> B[second] --> C[third]");
4039
        let lines: Vec<&str> = out.lines().collect();
4040
        let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
4041
        assert!(
4042
            row("third") < row("first"),
4043
            "BT: 'third' should sit above 'first':\n{out}"
4044
        );
4045
    }
4046
4047
    #[test]
4048
    fn rl_flips_orientation() {
4049
        let out = plain("flowchart RL\n A[first] --> B[second] --> C[third]");
4050
        let line = out.lines().find(|l| l.contains("first")).unwrap();
4051
        assert!(
4052
            line.find("third") < line.find("first"),
4053
            "RL: 'third' should sit left of 'first':\n{out}"
4054
        );
4055
    }
4056
4057
    #[test]
4058
    fn undirected_piped_label_has_no_arrowhead() {
4059
        let out = plain("graph TD\n A ---|maybe| B");
4060
        assert!(out.contains("maybe"), "{out}");
4061
        assert!(
4062
            !out.contains('▼'),
4063
            "undirected link should not draw an arrow:\n{out}"
4064
        );
4065
    }
4066
4067
    #[test]
4068
    fn chain_edges_are_straight() {
4069
        let out = plain("graph TD\n A[aaaa] --> B[b] --> C[cccccccc]");
4070
        for line in out.lines() {
4071
            assert!(
4072
                !line.contains('└') || !line.contains('┐'),
4073
                "chain should not jog: {line:?}"
4074
            );
4075
        }
4076
    }
4077
4078
    #[test]
4079
    fn adversarial_chain_falls_back() {
4080
        let mut src = String::from("graph TD\n");
4081
        for i in 0..10_000 {
4082
            src.push_str(&format!(" N{i} --> N{}\n", i + 1));
4083
        }
4084
        let out = plain(&src);
4085
        assert!(out.contains("mermaid: graph"), "expected fallback:\n{out}");
4086
    }
4087
4088
    #[test]
4089
    fn single_statement_chain_over_cap_falls_back() {
4090
        let mut src = String::from("graph LR\n ");
4091
        for i in 0..10_000 {
4092
            src.push_str(&format!("N{i}-->"));
4093
        }
4094
        src.push_str("N10000");
4095
        let out = plain(&src);
4096
        assert!(out.contains("mermaid: graph"), "expected fallback");
4097
    }
4098
4099
    #[test]
4100
    fn deep_chain_within_caps_renders() {
4101
        let mut src = String::from("graph TD\n");
4102
        for i in 0..100 {
4103
            src.push_str(&format!(" N{i} --> N{}\n", i + 1));
4104
        }
4105
        let out = render(&src, &styles(), Some(200)).unwrap().plain_lines;
4106
        let joined = out.join("\n");
4107
        assert!(joined.contains("N0"), "{joined}");
4108
        assert!(joined.contains("N100"), "{joined}");
4109
        assert!(joined.contains('▼'), "{joined}");
4110
    }
4111
4112
    #[test]
4113
    fn fallback_styled_and_plain_widths_match() {
4114
        let art = render("gantt\n title Plan\n a\n", &styles(), Some(120)).unwrap();
4115
        assert_eq!(art.styled_lines.len(), art.plain_lines.len());
4116
        let frame_w = art.plain_lines[0].width();
4117
        for (styled, plain) in art.styled_lines.iter().zip(&art.plain_lines) {
4118
            let styled_w: usize = styled
4119
                .spans
4120
                .iter()
4121
                .map(|s| s.content.as_ref().width())
4122
                .sum();
4123
            assert_eq!(styled_w, plain.width(), "styled/plain widths diverge");
4124
            assert_eq!(plain.width(), frame_w, "fallback box must be rectangular");
4125
        }
4126
    }
4127
4128
    #[test]
4129
    fn over_wide_diagram_falls_back() {
4130
        let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]";
4131
        let out = render(src, &styles(), Some(40)).unwrap().plain_lines;
4132
        let joined = out.join("\n");
4133
        assert!(
4134
            joined.contains("mermaid: flowchart"),
4135
            "expected fallback for over-wide diagram:\n{joined}"
4136
        );
4137
        let max_w = out.iter().map(|l| l.width()).max().unwrap_or(0);
4138
        let fits = render(src, &styles(), Some(120)).unwrap().plain_lines;
4139
        assert!(
4140
            fits.iter().any(|l| l.contains('▶')),
4141
            "same diagram should render when it fits"
4142
        );
4143
        assert!(max_w <= src.len(), "fallback width bounded by source");
4144
    }
4145
4146
    #[test]
4147
    fn too_wide_fallback_appends_hint_below_box() {
4148
        let src = "flowchart LR\n A[aaaaaaaaaaaaaaaaaaaa] --> B[bbbbbbbbbbbbbbbbbbbb] --> C[cccccccccccccccccccc]";
4149
        let out = render(src, &styles(), Some(40)).unwrap().plain_lines;
4150
        let joined = out.join("\n");
4151
4152
        assert!(
4153
            joined.contains("mermaid: flowchart"),
4154
            "plain header:\n{joined}"
4155
        );
4156
        assert!(
4157
            !joined.contains("(too wide)"),
4158
            "header stays plain:\n{joined}"
4159
        );
4160
        assert!(
4161
            joined.contains("flowchart LR"),
4162
            "raw source kept:\n{joined}"
4163
        );
4164
4165
        let bottom = out
4166
            .iter()
4167
            .position(|l| l.contains('╰'))
4168
            .expect("box bottom");
4169
        let note = out
4170
            .iter()
4171
            .position(|l| l.contains("too wide"))
4172
            .expect("note row");
4173
        assert!(note > bottom, "note must be below the box:\n{joined}");
4174
        assert!(
4175
            joined.contains("open the image"),
4176
            "note points at the image:\n{joined}"
4177
        );
4178
4179
        assert!(
4180
            out.iter().all(|l| l.width() <= 40),
4181
            "fits 40 cols:\n{joined}"
4182
        );
4183
    }
4184
4185
    #[test]
4186
    fn unsupported_diagram_fallback_not_flagged_too_wide() {
4187
        let out = plain("gantt\n title Plan\n section A\n task :a1, 2024-01-01, 30d");
4188
        assert!(out.contains("mermaid: gantt"), "{out}");
4189
        assert!(
4190
            !out.contains("too wide"),
4191
            "unsupported type is not a width problem:\n{out}"
4192
        );
4193
    }
4194
4195
    #[test]
4196
    fn fitting_diagram_has_no_width_warning() {
4197
        let out = plain("flowchart LR\n A[Start] --> B[End]");
4198
        assert!(
4199
            !out.contains("too wide"),
4200
            "fitting diagram must not warn:\n{out}"
4201
        );
4202
        assert!(
4203
            !out.contains("mermaid: flowchart"),
4204
            "should draw art, not box:\n{out}"
4205
        );
4206
        assert!(out.contains('▶'), "should draw edges:\n{out}");
4207
    }
4208
4209
    #[test]
4210
    fn bidirectional_link_draws_both_arrowheads() {
4211
        let lr = plain("flowchart LR\n A <--> B");
4212
        assert!(lr.contains('◄') && lr.contains('▶'), "{lr}");
4213
        let td = plain("graph TD\n A <--> B");
4214
        assert!(td.contains('▲') && td.contains('▼'), "{td}");
4215
    }
4216
4217
    #[test]
4218
    fn reversed_arrow_swaps_edge_direction() {
4219
        let g = parse_graph("graph TD\n A <-- B").unwrap();
4220
        let idx = |id: &str| g.index[id];
4221
        assert_eq!(g.edges.len(), 1);
4222
        assert_eq!(g.edges[0].from, idx("B"));
4223
        assert_eq!(g.edges[0].to, idx("A"));
4224
        assert_eq!(g.edges[0].head_to, Head::Arrow);
4225
        assert_eq!(g.edges[0].head_from, Head::None);
4226
        let out = plain("graph TD\n A <-- B");
4227
        let lines: Vec<&str> = out.lines().collect();
4228
        let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
4229
        assert!(row("B") < row("A"), "B should rank above A:\n{out}");
4230
    }
4231
4232
    #[test]
4233
    fn semicolon_and_comment_survive_inside_quoted_label() {
4234
        let g = parse_graph("graph TD\n A[\"wait; 50%% done\"] --> B").unwrap();
4235
        assert_eq!(g.nodes.len(), 2);
4236
        assert_eq!(g.nodes[0].label, "wait; 50%% done");
4237
    }
4238
4239
    #[test]
4240
    fn comment_outside_quotes_is_stripped() {
4241
        let g =
4242
            parse_graph("graph TD %% main flow\n A --> B %% trailing\n %% full line\n").unwrap();
4243
        assert_eq!(g.nodes.len(), 2);
4244
        assert_eq!(g.edges.len(), 1);
4245
    }
4246
4247
    #[test]
4248
    fn skip_edge_routes_around_intermediate_boxes() {
4249
        let out = plain("graph TD\n A --> B\n B --> C\n A --> C");
4250
        assert!(!out.contains('┼'), "no border corruption:\n{out}");
4251
        assert!(
4252
            out.contains('◄'),
4253
            "skip edge enters target from lane:\n{out}"
4254
        );
4255
    }
4256
4257
    fn ordered_ranks(src: &str) -> (Graph, Vec<usize>, Vec<Vec<usize>>) {
4258
        let g = parse_graph(src).unwrap();
4259
        let ranks = compute_ranks(&g);
4260
        let max_rank = *ranks.iter().max().unwrap();
4261
        let mut by_rank: Vec<Vec<usize>> = vec![Vec::new(); max_rank + 1];
4262
        for (idx, &r) in ranks.iter().enumerate() {
4263
            by_rank[r].push(idx);
4264
        }
4265
        order_ranks(&mut by_rank, &g.edges, &ranks);
4266
        (g, ranks, by_rank)
4267
    }
4268
4269
    #[test]
4270
    fn order_ranks_removes_avoidable_crossing() {
4271
        let (g, ranks, by_rank) = ordered_ranks("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C");
4272
        let mut pos = vec![0usize; g.nodes.len()];
4273
        for row in &by_rank {
4274
            for (i, &v) in row.iter().enumerate() {
4275
                pos[v] = i;
4276
            }
4277
        }
4278
        assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0);
4279
        let idx = |id: &str| g.index[id];
4280
        assert!(pos[idx("D")] < pos[idx("C")], "D follows parent A leftward");
4281
    }
4282
4283
    #[test]
4284
    fn order_ranks_keeps_crossing_free_order() {
4285
        let (g, ranks, by_rank) = ordered_ranks("graph TD\n A --> C\n B --> D");
4286
        let idx = |id: &str| g.index[id];
4287
        assert_eq!(by_rank[0], vec![idx("A"), idx("B")]);
4288
        assert_eq!(by_rank[1], vec![idx("C"), idx("D")]);
4289
        let mut pos = vec![0usize; g.nodes.len()];
4290
        for row in &by_rank {
4291
            for (i, &v) in row.iter().enumerate() {
4292
                pos[v] = i;
4293
            }
4294
        }
4295
        assert_eq!(count_crossings(&g.edges, &ranks, &pos), 0);
4296
    }
4297
4298
    #[test]
4299
    fn crossing_edges_render_untangled() {
4300
        let out = plain("graph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C");
4301
        let row = out
4302
            .lines()
4303
            .find(|l| l.contains("ccc") && l.contains("ddd"))
4304
            .unwrap();
4305
        assert!(
4306
            row.find("ddd") < row.find("ccc"),
4307
            "children reorder under their parents:\n{out}"
4308
        );
4309
        assert!(!out.contains('┼'), "{out}");
4310
    }
4311
4312
    #[test]
4313
    fn three_layer_weave_untangles() {
4314
        let (g, ranks, by_rank) = ordered_ranks(
4315
            "graph TD\n X[x]\n Y[y]\n A --> Y\n B --> X\n X --> Q\n Y --> P\n P[p]\n Q[q]",
4316
        );
4317
        let mut pos = vec![0usize; g.nodes.len()];
4318
        for row in &by_rank {
4319
            for (i, &v) in row.iter().enumerate() {
4320
                pos[v] = i;
4321
            }
4322
        }
4323
        assert_eq!(
4324
            count_crossings(&g.edges, &ranks, &pos),
4325
            0,
4326
            "both layers untangle"
4327
        );
4328
    }
4329
4330
    #[test]
4331
    fn unavoidable_crossing_gets_separate_bus_rows() {
4332
        let crossing = plain("graph TD\n A --> D[ddd]\n A --> C[ccc]\n B --> C\n B --> D");
4333
        let parallel = plain("graph TD\n A --> C[ccc]\n B --> D[ddd]");
4334
        assert!(crossing.contains('┼'), "wire crossing renders:\n{crossing}");
4335
        assert_eq!(
4336
            crossing.lines().count(),
4337
            parallel.lines().count() + 1,
4338
            "crossing pair claims one extra bus row:\n{crossing}"
4339
        );
4340
        assert_eq!(
4341
            crossing.chars().filter(|&c| c == '▼').count(),
4342
            2,
4343
            "{crossing}"
4344
        );
4345
    }
4346
4347
    #[test]
4348
    fn fan_out_keeps_single_bus_row() {
4349
        let out = plain("graph TD\n A --> C[ccc]\n A --> D[ddd]");
4350
        let baseline = plain("graph TD\n A --> C[ccc]");
4351
        assert_eq!(
4352
            out.lines().count(),
4353
            baseline.lines().count(),
4354
            "shared-source jogs share one bus row:\n{out}"
4355
        );
4356
        assert!(!out.contains('┼'), "{out}");
4357
    }
4358
4359
    #[test]
4360
    fn shared_target_back_edges_share_one_lane() {
4361
        let two = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> A");
4362
        let one = plain("graph TD\n A --> B\n B --> C\n C --> A");
4363
        assert_eq!(
4364
            two.lines().map(|l| l.width()).max(),
4365
            one.lines().map(|l| l.width()).max(),
4366
            "shared-target back edges merge into one lane:\n{two}"
4367
        );
4368
        assert_eq!(two.matches('◄').count(), 1, "{two}");
4369
    }
4370
4371
    #[test]
4372
    fn distinct_back_edges_get_separate_lanes() {
4373
        let split = plain("graph TD\n A --> B\n B --> C\n B --> A\n C --> B");
4374
        let single = plain("graph TD\n A --> B\n B --> C\n C --> B");
4375
        assert_eq!(split.matches('◄').count(), 2, "{split}");
4376
        assert!(
4377
            split.lines().map(|l| l.width()).max() > single.lines().map(|l| l.width()).max(),
4378
            "overlapping unrelated back edges claim a second lane:\n{split}"
4379
        );
4380
    }
4381
4382
    #[test]
4383
    fn fallback_wraps_long_lines_to_max_width() {
4384
        let out = render(
4385
            "gantt\n title a very long line that should wrap inside the fallback box nicely",
4386
            &styles(),
4387
            Some(40),
4388
        )
4389
        .unwrap()
4390
        .plain_lines;
4391
        assert!(out.iter().all(|l| l.width() <= 40), "{}", out.join("\n"));
4392
        for line in &out[1..out.len() - 1] {
4393
            assert!(
4394
                line.starts_with('│') && line.ends_with('│'),
4395
                "body rows keep both borders: {line:?}"
4396
            );
4397
        }
4398
        assert!(out.join("\n").contains("nicely"), "{}", out.join("\n"));
4399
    }
4400
4401
    #[test]
4402
    fn class_renders_compartments() {
4403
        let out = plain(
4404
            "classDiagram\n class Animal {\n +int age\n +isMammal() bool\n }\n Animal <|-- Duck",
4405
        );
4406
        assert!(out.contains("Animal"), "{out}");
4407
        assert!(out.contains("+int age"), "{out}");
4408
        assert!(out.contains("+isMammal() bool"), "{out}");
4409
        assert!(
4410
            out.contains('├') && out.contains('┤'),
4411
            "section rules:\n{out}"
4412
        );
4413
        let lines: Vec<&str> = out.lines().collect();
4414
        let name = lines.iter().position(|l| l.contains("Animal")).unwrap();
4415
        let attr = lines.iter().position(|l| l.contains("+int age")).unwrap();
4416
        let method = lines
4417
            .iter()
4418
            .position(|l| l.contains("+isMammal() bool"))
4419
            .unwrap();
4420
        assert!(name < attr && attr < method, "{out}");
4421
    }
4422
4423
    #[test]
4424
    fn class_inheritance_triangle_at_parent() {
4425
        let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish");
4426
        assert!(out.contains('△'), "hollow triangle:\n{out}");
4427
        let lines: Vec<&str> = out.lines().collect();
4428
        let animal = lines.iter().position(|l| l.contains("Animal")).unwrap();
4429
        let duck = lines.iter().position(|l| l.contains("Duck")).unwrap();
4430
        assert!(animal < duck, "parent above child:\n{out}");
4431
        let tri = lines.iter().position(|l| l.contains('△')).unwrap();
4432
        assert!(
4433
            tri >= animal && tri < duck,
4434
            "triangle at parent end:\n{out}"
4435
        );
4436
    }
4437
4438
    #[test]
4439
    fn class_realization_is_dotted_triangle() {
4440
        let g = parse_class("classDiagram\n IShape <|.. Circle").unwrap().0;
4441
        assert_eq!(g.edges[0].head_from, Head::Triangle);
4442
        assert!(g.edges[0].line == LineKind::Dotted);
4443
        let out = plain("classDiagram\n IShape <|.. Circle");
4444
        assert!(out.contains('╎') || out.contains('╌'), "{out}");
4445
    }
4446
4447
    #[test]
4448
    fn class_composition_and_aggregation_diamonds() {
4449
        let out = plain("classDiagram\n Car *-- Engine\n Pond o-- Duck");
4450
        assert!(out.contains('◆'), "filled diamond:\n{out}");
4451
        assert!(out.contains('◇'), "open diamond:\n{out}");
4452
    }
4453
4454
    #[test]
4455
    fn class_dependency_dotted_arrow() {
4456
        let g = parse_class("classDiagram\n A ..> B").unwrap().0;
4457
        assert_eq!(g.edges[0].head_to, Head::Arrow);
4458
        assert!(g.edges[0].line == LineKind::Dotted);
4459
    }
4460
4461
    #[test]
4462
    fn class_colon_members_merge_with_block() {
4463
        let out = plain(
4464
            "classDiagram\n class Duck {\n +swim()\n }\n Duck : +String beakColor\n S --> Duck",
4465
        );
4466
        assert!(out.contains("+swim()"), "{out}");
4467
        assert!(out.contains("+String beakColor"), "{out}");
4468
    }
4469
4470
    #[test]
4471
    fn class_annotation_renders_guillemets() {
4472
        let out = plain("classDiagram\n <<interface>> Shape\n Shape <|.. Circle");
4473
        assert!(out.contains("«interface»"), "{out}");
4474
    }
4475
4476
    #[test]
4477
    fn class_generics_display_as_angle_brackets() {
4478
        let out = plain("classDiagram\n Shape~T~ : +area() T\n S --> Shape~T~");
4479
        assert!(out.contains("Shape<T>"), "{out}");
4480
        assert!(!out.contains('~'), "{out}");
4481
    }
4482
4483
    #[test]
4484
    fn class_cardinalities_fold_into_label() {
4485
        let out = plain("classDiagram\n Student \"many\" --> \"1\" School : attends");
4486
        assert!(out.contains("many attends 1"), "{out}");
4487
    }
4488
4489
    #[test]
4490
    fn class_from_end_head_survives_fan_out_jog() {
4491
        let out = plain("classDiagram\n Animal <|-- Duck\n Animal <|-- Fish\n Animal <|-- Cow");
4492
        assert_eq!(
4493
            out.matches('△').count() + out.matches('▽').count(),
4494
            1,
4495
            "merged from-end glyph on the parent border:\n{out}"
4496
        );
4497
    }
4498
4499
    #[test]
4500
    fn class_empty_class_is_plain_titled_box() {
4501
        let out = plain("classDiagram\n class Loner\n A --> Loner");
4502
        assert!(out.contains("Loner"), "{out}");
4503
    }
4504
4505
    #[test]
4506
    fn class_unknown_statement_falls_back() {
4507
        let out = plain("classDiagram\n A --> B\n total garbage here");
4508
        assert!(out.contains("mermaid: classDiagram"), "{out}");
4509
    }
4510
4511
    #[test]
4512
    fn class_member_cap_ellipsis() {
4513
        let mut src = String::from("classDiagram\n class Big {\n");
4514
        for i in 0..12 {
4515
            src.push_str(&format!(" +field{i}\n"));
4516
        }
4517
        src.push_str(" }\n A --> Big");
4518
        let out = plain(&src);
4519
        assert!(out.contains("+field7"), "{out}");
4520
        assert!(!out.contains("+field9"), "{out}");
4521
        assert!(out.contains('…'), "{out}");
4522
    }
4523
4524
    #[test]
4525
    fn class_direction_lr() {
4526
        let out = plain("classDiagram\n direction LR\n A --> B");
4527
        let line = out.lines().find(|l| l.contains('A')).unwrap();
4528
        assert!(line.contains('B'), "{out}");
4529
    }
4530
4531
    #[test]
4532
    fn er_renders_entities_and_relationship_labels() {
4533
        let out = plain(
4534
            "erDiagram\n CUSTOMER ||--o{ ORDER : places\n CUSTOMER {\n string name PK \"full name\"\n int custNumber\n }",
4535
        );
4536
        assert!(out.contains("CUSTOMER"), "{out}");
4537
        assert!(out.contains("ORDER"), "{out}");
4538
        assert!(out.contains("string name PK"), "{out}");
4539
        assert!(
4540
            !out.contains("full name"),
4541
            "attribute comments dropped:\n{out}"
4542
        );
4543
        assert!(out.contains("1 places 0..*"), "{out}");
4544
        assert!(out.contains('├'), "attribute compartment rule:\n{out}");
4545
    }
4546
4547
    #[test]
4548
    fn er_cardinality_map() {
4549
        let cases = [
4550
            ("||--||", "1", "1"),
4551
            ("|o--o|", "0..1", "0..1"),
4552
            ("}o--o{", "0..*", "0..*"),
4553
            ("}|--|{", "1..*", "1..*"),
4554
            ("||--o{", "1", "0..*"),
4555
        ];
4556
        for (op, l, r) in cases {
4557
            let (cl, cr, line) = parse_er_op(op).unwrap();
4558
            assert_eq!((cl, cr), (l, r), "{op}");
4559
            assert!(line == LineKind::Solid);
4560
        }
4561
        assert!(parse_er_op("||..o{").unwrap().2 == LineKind::Dotted);
4562
        assert!(parse_er_op("||==o{").is_none());
4563
        assert!(parse_er_op("garbage").is_none());
4564
    }
4565
4566
    #[test]
4567
    fn er_non_identifying_renders_dotted() {
4568
        let out = plain("erDiagram\n A ||..o{ B : uses");
4569
        assert!(out.contains('╎') || out.contains('╌'), "{out}");
4570
    }
4571
4572
    #[test]
4573
    fn er_relationships_have_no_arrowheads() {
4574
        let out = plain("erDiagram\n A ||--o{ B : has");
4575
        for head in ['▼', '▲', '◄', '▶', '△', '◆', '◇'] {
4576
            assert!(!out.contains(head), "{head} in:\n{out}");
4577
        }
4578
    }
4579
4580
    #[test]
4581
    fn er_entity_alias_label() {
4582
        let out = plain("erDiagram\n p[Person] ||--o{ a[\"Bank Account\"] : owns");
4583
        assert!(out.contains("Person"), "{out}");
4584
        assert!(out.contains("Bank Account"), "{out}");
4585
    }
4586
4587
    #[test]
4588
    fn er_unquoted_label_and_bare_entity_decl() {
4589
        let g = parse_er("erDiagram\n LONER\n A ||--|| B : linked")
4590
            .unwrap()
4591
            .0;
4592
        assert_eq!(g.nodes.len(), 3);
4593
        let out = plain("erDiagram\n LONER\n A ||--|| B : linked");
4594
        assert!(out.contains("LONER"), "{out}");
4595
        assert!(out.contains("1 linked 1"), "{out}");
4596
    }
4597
4598
    #[test]
4599
    fn er_attribute_cap_ellipsis() {
4600
        let mut src = String::from("erDiagram\n BIG {\n");
4601
        for i in 0..12 {
4602
            src.push_str(&format!(" int f{i}\n"));
4603
        }
4604
        src.push_str(" }\n BIG ||--|| OTHER : x");
4605
        let out = plain(&src);
4606
        assert!(out.contains("int f7"), "{out}");
4607
        assert!(!out.contains("int f9"), "{out}");
4608
        assert!(out.contains('…'), "{out}");
4609
    }
4610
4611
    #[test]
4612
    fn er_unknown_statement_falls_back() {
4613
        let out = plain("erDiagram\n A ||--|| B : ok\n utter nonsense statement");
4614
        assert!(out.contains("mermaid: erDiagram"), "{out}");
4615
    }
4616
4617
    #[test]
4618
    fn subgraph_renders_titled_frame() {
4619
        let out = plain(
4620
            "graph TD\n S[Start] --> one\n subgraph one [Group One]\n A --> B\n end\n one --> E[End]",
4621
        );
4622
        assert!(out.contains(" Group One "), "{out}");
4623
        let lines: Vec<&str> = out.lines().collect();
4624
        let title = lines.iter().position(|l| l.contains("Group One")).unwrap();
4625
        let a = lines.iter().position(|l| l.contains("│ A │")).unwrap();
4626
        let b = lines.iter().position(|l| l.contains("│ B │")).unwrap();
4627
        let frame_close = lines
4628
            .iter()
4629
            .rposition(|l| l.trim_start().starts_with('└'))
4630
            .unwrap();
4631
        assert!(title < a && a < b && b <= frame_close, "{out}");
4632
        assert!(out.contains("Start") && out.contains("End"), "{out}");
4633
        assert_eq!(out.matches('▼').count(), 3, "{out}");
4634
    }
4635
4636
    #[test]
4637
    fn subgraph_edge_between_groups() {
4638
        let out = plain(
4639
            "graph TD\n subgraph api [API]\n A1 --> A2\n end\n subgraph db [Storage]\n B1\n end\n api --> db",
4640
        );
4641
        assert!(out.contains(" API "), "{out}");
4642
        assert!(out.contains(" Storage "), "{out}");
4643
        let lines: Vec<&str> = out.lines().collect();
4644
        let api = lines.iter().position(|l| l.contains("API")).unwrap();
4645
        let db = lines.iter().position(|l| l.contains("Storage")).unwrap();
4646
        assert!(api < db, "API frame ranks above Storage:\n{out}");
4647
    }
4648
4649
    #[test]
4650
    fn subgraph_nested_frames() {
4651
        let out = plain(
4652
            "graph TD\n subgraph outer [Outer]\n subgraph inner [Inner]\n X --> Y\n end\n W --> X\n end\n S --> outer",
4653
        );
4654
        assert!(out.contains(" Outer "), "{out}");
4655
        assert!(out.contains(" Inner "), "{out}");
4656
        let lines: Vec<&str> = out.lines().collect();
4657
        let outer = lines.iter().position(|l| l.contains("Outer")).unwrap();
4658
        let inner = lines.iter().position(|l| l.contains("Inner")).unwrap();
4659
        assert!(outer < inner, "{out}");
4660
    }
4661
4662
    #[test]
4663
    fn subgraph_cross_member_edge_attaches_to_frame() {
4664
        let out = plain("graph LR\n S --> A\n subgraph g [Workers]\n A --> B\n end\n B --> T");
4665
        assert!(out.contains(" Workers "), "{out}");
4666
        assert!(out.contains('S') && out.contains('T'), "{out}");
4667
        assert_eq!(out.matches('▶').count(), 3, "{out}");
4668
        let row = out.lines().find(|l| l.contains("│ A ├")).unwrap();
4669
        assert!(
4670
            row.find('S') < row.find('A'),
4671
            "A stays outside the group (first definition wins):\n{out}"
4672
        );
4673
    }
4674
4675
    #[test]
4676
    fn subgraph_id_referenced_before_declaration() {
4677
        let g = parse_graph("graph TD\n X --> two\n subgraph two\n C --> D\n end").unwrap();
4678
        assert_eq!(g.groups.len(), 1);
4679
        let out = plain("graph TD\n X --> two\n subgraph two\n C --> D\n end");
4680
        assert!(out.contains(" two "), "frame titled by id:\n{out}");
4681
        assert!(out.contains("│ C │"), "{out}");
4682
    }
4683
4684
    #[test]
4685
    fn subgraph_quoted_and_plain_titles() {
4686
        let out = plain("graph TD\n subgraph \"My Stuff\"\n A\n end\n S --> A");
4687
        assert!(out.contains(" My Stuff "), "{out}");
4688
        let out2 = plain("graph TD\n subgraph batch jobs\n B\n end\n S --> B");
4689
        assert!(out2.contains(" batch jobs "), "{out2}");
4690
        let out3 = plain("graph TD\n subgraph \"a &lt;b&gt;\"\n C\n end\n S --> C");
4691
        assert!(out3.contains("a <b>") && !out3.contains("&lt;"), "{out3}");
4692
    }
4693
4694
    #[test]
4695
    fn subgraph_empty_is_dropped() {
4696
        let out = plain("graph TD\n subgraph ghost\n end\n A --> B");
4697
        assert!(!out.contains("ghost"), "{out}");
4698
        assert!(out.contains('▼'), "{out}");
4699
    }
4700
4701
    #[test]
4702
    fn subgraph_bt_flips_frame_and_contents() {
4703
        let out = plain("flowchart BT\n S --> one\n subgraph one [Up]\n A --> B\n end");
4704
        assert!(out.contains(" Up "), "{out}");
4705
        let lines: Vec<&str> = out.lines().collect();
4706
        let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
4707
        assert!(row("│ B │") < row("│ A │"), "contents flip with BT:\n{out}");
4708
        assert!(row(" Up ") < row("S"), "frame above source in BT:\n{out}");
4709
        assert!(out.contains('▲'), "{out}");
4710
    }
4711
4712
    #[test]
4713
    fn subgraph_depth_over_cap_falls_back() {
4714
        let mut src = String::from("graph TD\n");
4715
        for i in 0..8 {
4716
            src.push_str(&format!(" subgraph g{i}\n"));
4717
        }
4718
        src.push_str(" A --> B\n");
4719
        for _ in 0..8 {
4720
            src.push_str(" end\n");
4721
        }
4722
        let out = plain(&src);
4723
        assert!(out.contains("mermaid: graph"), "{out}");
4724
    }
4725
4726
    #[test]
4727
    fn subgraph_groupless_path_unchanged() {
4728
        let g = parse_graph("graph TD\n A --> B").unwrap();
4729
        assert!(g.groups.is_empty());
4730
    }
4731
4732
    #[test]
4733
    fn fan_out_creates_cross_product_edges() {
4734
        let g = parse_graph("graph TD\n A & B --> C & D").unwrap();
4735
        assert_eq!(g.nodes.len(), 4);
4736
        assert_eq!(g.edges.len(), 4);
4737
        let idx = |id: &str| g.index[id];
4738
        let has = |f: &str, t: &str| g.edges.iter().any(|e| e.from == idx(f) && e.to == idx(t));
4739
        assert!(has("A", "C") && has("A", "D") && has("B", "C") && has("B", "D"));
4740
        let out = plain("graph TD\n A & B --> C & D");
4741
        assert_eq!(out.chars().filter(|&c| c == '▼').count(), 2, "{out}");
4742
    }
4743
4744
    #[test]
4745
    fn fan_out_in_chain() {
4746
        let g = parse_graph("graph LR\n A & B --> C --> D").unwrap();
4747
        assert_eq!(g.edges.len(), 3);
4748
    }
4749
4750
    #[test]
4751
    fn fan_out_with_reversed_arrow() {
4752
        let g = parse_graph("graph TD\n A & B <-- C").unwrap();
4753
        let idx = |id: &str| g.index[id];
4754
        assert_eq!(g.edges.len(), 2);
4755
        assert!(g.edges.iter().all(|e| e.from == idx("C")));
4756
        assert!(g.edges.iter().all(|e| e.head_to == Head::Arrow));
4757
    }
4758
4759
    #[test]
4760
    fn circle_and_cross_endings_create_no_phantom_nodes() {
4761
        let g = parse_graph("graph TD\n A --o B\n C --x D").unwrap();
4762
        assert_eq!(g.nodes.len(), 4, "no phantom o/x nodes");
4763
        assert!(!g.index.contains_key("o"));
4764
        assert!(!g.index.contains_key("x"));
4765
        assert_eq!(g.edges[0].head_to, Head::Circle);
4766
        assert_eq!(g.edges[1].head_to, Head::Cross);
4767
        let out = plain("graph TD\n A --o B");
4768
        assert!(out.contains('o'), "circle head rendered:\n{out}");
4769
    }
4770
4771
    #[test]
4772
    fn left_endings_decorate_without_reversing() {
4773
        let g = parse_graph("graph TD\n A o-- B\n C x-- D").unwrap();
4774
        let idx = |id: &str| g.index[id];
4775
        assert_eq!(g.edges[0].from, idx("A"));
4776
        assert_eq!(g.edges[0].to, idx("B"));
4777
        assert_eq!(g.edges[0].head_from, Head::Circle);
4778
        assert_eq!(g.edges[1].head_from, Head::Cross);
4779
        assert_eq!(g.edges[0].head_to, Head::None);
4780
    }
4781
4782
    #[test]
4783
    fn reversed_arrow_with_end_marker_swaps_direction() {
4784
        let g = parse_graph("graph TD\n A <--o B\n C <--x D").unwrap();
4785
        let idx = |id: &str| g.index[id];
4786
        assert_eq!(g.edges[0].from, idx("B"));
4787
        assert_eq!(g.edges[0].to, idx("A"));
4788
        assert_eq!(g.edges[0].head_to, Head::Arrow);
4789
        assert_eq!(g.edges[0].head_from, Head::Circle);
4790
        assert_eq!(g.edges[1].from, idx("D"));
4791
        assert_eq!(g.edges[1].to, idx("C"));
4792
        assert_eq!(g.edges[1].head_from, Head::Cross);
4793
        let plain_rev = plain("graph TD\n A <--o B");
4794
        let lines: Vec<&str> = plain_rev.lines().collect();
4795
        let row = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
4796
        assert!(
4797
            row("B") < row("A"),
4798
            "ranks match plain <-- reversal:\n{plain_rev}"
4799
        );
4800
    }
4801
4802
    #[test]
4803
    fn both_end_markers_parse() {
4804
        let g = parse_graph("graph TD\n A o--o B\n C x--x D").unwrap();
4805
        assert_eq!(g.edges[0].head_from, Head::Circle);
4806
        assert_eq!(g.edges[0].head_to, Head::Circle);
4807
        assert_eq!(g.edges[1].head_from, Head::Cross);
4808
        assert_eq!(g.edges[1].head_to, Head::Cross);
4809
        assert_eq!(g.nodes.len(), 4);
4810
    }
4811
4812
    #[test]
4813
    fn dotted_and_thick_lines_render_distinctly() {
4814
        let dotted = plain("graph TD\n A -.-> B");
4815
        assert!(dotted.contains('╎'), "dotted vertical:\n{dotted}");
4816
        let thick = plain("graph TD\n A ==> B");
4817
        assert!(thick.contains('┃'), "thick vertical:\n{thick}");
4818
        let solid = plain("graph TD\n A --> B");
4819
        assert!(
4820
            !solid.contains('╎') && !solid.contains('┃'),
4821
            "solid unchanged:\n{solid}"
4822
        );
4823
    }
4824
4825
    #[test]
4826
    fn dotted_label_form_renders_dashed() {
4827
        let out = plain("graph LR\n A -. maybe .-> B");
4828
        assert!(out.contains('╌'), "{out}");
4829
        assert!(out.contains("maybe"), "{out}");
4830
    }
4831
4832
    #[test]
4833
    fn thick_jog_uses_thick_corners() {
4834
        let out = plain("graph TD\n A[aaaaaaa] ==> B\n A ==> C[ccccccc]");
4835
        assert!(
4836
            out.contains('┏') || out.contains('┓') || out.contains('┳'),
4837
            "thick corners on jog:\n{out}"
4838
        );
4839
    }
4840
4841
    #[test]
4842
    fn state_diagram_renders_states_and_transitions() {
4843
        let out =
4844
            plain("stateDiagram-v2\n [*] --> Idle\n Idle --> Running: start\n Running --> [*]");
4845
        assert!(out.contains("Idle"), "{out}");
4846
        assert!(out.contains("Running"), "{out}");
4847
        assert!(out.contains("start"), "{out}");
4848
        assert!(out.contains('▼'), "{out}");
4849
        assert_eq!(
4850
            out.matches('●').count(),
4851
            2,
4852
            "distinct start and end markers:\n{out}"
4853
        );
4854
        let lines: Vec<&str> = out.lines().collect();
4855
        let first_dot = lines.iter().position(|l| l.contains('●')).unwrap();
4856
        let last_dot = lines.iter().rposition(|l| l.contains('●')).unwrap();
4857
        let idle = lines.iter().position(|l| l.contains("Idle")).unwrap();
4858
        assert!(first_dot < idle && idle < last_dot, "{out}");
4859
    }
4860
4861
    #[test]
4862
    fn state_v1_header_renders() {
4863
        let out = plain("stateDiagram\n A --> B");
4864
        assert!(out.contains('▼'), "{out}");
4865
    }
4866
4867
    #[test]
4868
    fn state_boxes_are_rounded() {
4869
        let out = plain("stateDiagram-v2\n A --> B");
4870
        assert!(out.contains('╭'), "{out}");
4871
        assert!(!out.contains('┌'), "states render rounded:\n{out}");
4872
    }
4873
4874
    #[test]
4875
    fn state_alias_label_renders() {
4876
        let out = plain("stateDiagram-v2\n state \"Waiting for input\" as W\n W --> Done");
4877
        assert!(out.contains("Waiting for input"), "{out}");
4878
    }
4879
4880
    #[test]
4881
    fn state_choice_parses_as_diamond() {
4882
        let g = parse_state(
4883
            "stateDiagram-v2\n state c <<choice>>\n A --> c\n c --> B: yes\n c --> D: no",
4884
        )
4885
        .unwrap();
4886
        assert!(g.nodes[g.index["c"]].shape == Shape::Diamond);
4887
        assert_eq!(g.edges.len(), 3);
4888
    }
4889
4890
    #[test]
4891
    fn state_description_sets_label() {
4892
        let out = plain("stateDiagram-v2\n s2 : waits patiently\n A --> s2");
4893
        assert!(out.contains("waits patiently"), "{out}");
4894
    }
4895
4896
    #[test]
4897
    fn state_direction_lr() {
4898
        let out = plain("stateDiagram-v2\n direction LR\n A --> B --> C");
4899
        let td = plain("stateDiagram-v2\n A --> B");
4900
        assert!(
4901
            out.lines().count() <= td.lines().count() + 2,
4902
            "LR stays flat:\n{out}"
4903
        );
4904
        let line = out.lines().find(|l| l.contains('A')).unwrap();
4905
        assert!(line.contains('B'), "A and B share a row in LR:\n{out}");
4906
    }
4907
4908
    #[test]
4909
    fn state_composite_contents_render_flat() {
4910
        let out = plain("stateDiagram-v2\n state Active {\n A --> B\n }\n Active --> Done");
4911
        assert!(out.contains("Active"), "{out}");
4912
        assert!(out.contains('A') && out.contains('B'), "{out}");
4913
        assert!(out.contains("Done"), "{out}");
4914
    }
4915
4916
    #[test]
4917
    fn state_notes_are_skipped() {
4918
        let out = plain(
4919
            "stateDiagram-v2\n A --> B\n note right of A: inline note\n note left of B\n block text\n end note",
4920
        );
4921
        assert!(out.contains('▼'), "{out}");
4922
        assert!(!out.contains("note"), "{out}");
4923
        assert!(!out.contains("block text"), "{out}");
4924
    }
4925
4926
    #[test]
4927
    fn state_back_transition_uses_lane() {
4928
        let out = plain("stateDiagram-v2\n A --> B\n B --> C\n C --> B: retry");
4929
        assert!(out.contains('◄'), "{out}");
4930
        assert!(out.contains("retry"), "{out}");
4931
    }
4932
4933
    #[test]
4934
    fn state_unknown_statement_falls_back() {
4935
        let out = plain("stateDiagram-v2\n A --> B\n some garbage line");
4936
        assert!(out.contains("mermaid: stateDiagram-v2"), "{out}");
4937
    }
4938
4939
    #[test]
4940
    fn state_over_cap_falls_back() {
4941
        let mut src = String::from("stateDiagram-v2\n");
4942
        for i in 0..600 {
4943
            src.push_str(&format!(" S{i} --> S{}\n", i + 1));
4944
        }
4945
        let out = plain(&src);
4946
        assert!(out.contains("mermaid: stateDiagram-v2"), "{out}");
4947
    }
4948
4949
    #[test]
4950
  

This page updates live while a promote is in flight · changelog