devup_editor_syntax_highlight/lib.rs
1//! Tree-sitter-based syntax highlighting for code blocks.
2//!
3//! # Why this crate exists
4//!
5//! `devup-editor` is a Notion-style block editor. A `code` block
6//! carries a `language` prop (e.g. `"rust"`, `"ts"`). When the block
7//! renders, we need to turn its flat text content into a sequence of
8//! styled tokens so the view layer can paint each slice with a colour.
9//!
10//! Doing this in Rust is deliberate. The WASM boundary for this
11//! module is:
12//!
13//! ```text
14//! (code: &str, language: &str) → Vec<Token { start_utf16, end_utf16, kind: &str }>
15//! ```
16//!
17//! Both sides of that boundary are primitives, which is exactly the
18//! case where porting to Rust pays for itself (see the
19//! `primitive-boundary rule` in `AGENTS.md`). A full 200-line code
20//! block serialised as a token list is a few KB of JSON — the parse
21//! cost dominates the JSON round-trip cost by orders of magnitude.
22//!
23//! # Engine choice: `arborium`
24//!
25//! We use [`arborium`](https://docs.rs/arborium) rather than
26//! `tree-sitter` + individual grammar crates for one reason only:
27//! **`tree-sitter-*` grammar crates on crates.io as of 2026-01 still
28//! fail `cargo build --target wasm32-unknown-unknown` with
29//! `stdlib.h` not found.** The core `tree-sitter` crate was patched
30//! (PR #4820, merged Sep 2025) but the fix has not propagated to
31//! grammar crates. `arborium` ships its own `arborium-sysroot` and
32//! re-generates every grammar against it, and its CI verifies the
33//! output `.wasm` has no unsatisfied imports.
34//!
35//! See the librarian research report in the session history for the
36//! full picture.
37//!
38//! # Supported language set
39//!
40//! We ship with the following grammars, selected to cover the
41//! majority of code blocks a Notion-style editor encounters in the
42//! wild:
43//!
44//! - `rust` — project's primary language
45//! - `typescript` / `javascript` — React ecosystem consumers
46//! - `python` — broad user base
47//! - `json` — config-file ubiquity
48//! - `bash` — README examples
49//! - `html` / `css` — frontend context
50//! - `markdown` — meta case (code blocks inside code blocks)
51//! - `sql` — query pastes
52//!
53//! The measured WASM bundle cost is ~1.5 MB gzipped across all of
54//! these grammars. See `.sisyphus/plans/tier-a-benchmark-decisions.md`
55//! for the budget discussion. Adding another grammar is a one-line
56//! change to `Cargo.toml` features, a one-line `normalize_language`
57//! entry, and a one-arm addition to [`compile_grammar`]. Do it
58//! deliberately — each new grammar adds roughly 50–300 KB gzipped.
59
60use std::collections::HashMap;
61use std::sync::{Arc, Mutex, OnceLock};
62
63use arborium::advanced::{CompiledGrammar, GrammarConfig, ParseContext, Span};
64
65/// A coarse token kind. The view layer maps each kind to a colour via
66/// theme tokens. We deliberately flatten arborium's capture-name
67/// taxonomy (which has ~50 distinct strings like `"keyword.function"`,
68/// `"string.special.symbol"`, …) into a small enum because:
69///
70/// 1. A Notion-style code block doesn't need surgical precision. It
71/// needs to be readable at a glance.
72/// 2. Mapping every arborium capture to its own CSS variable would
73/// explode `devup.json` with dozens of colour slots that nobody
74/// tunes.
75/// 3. Colour themes in the wild (One Dark, Dracula, GitHub) group
76/// captures into ~8 slots anyway.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum TokenKind {
79 /// `fn`, `let`, `if`, `return`, language keywords.
80 Keyword,
81 /// Function names at definition or call sites.
82 Function,
83 /// Type names, struct names, trait names.
84 Type,
85 /// `42`, `0xff`, `3.14`.
86 Number,
87 /// `"hello"`, `'x'`, template literal bodies.
88 String,
89 /// `// line comments`, `/* block comments */`.
90 Comment,
91 /// User-facing identifiers that don't fit another slot
92 /// (variables, parameters).
93 Variable,
94 /// Built-in constants like `true`, `false`, `null`, `None`.
95 Constant,
96 /// `+`, `=>`, `::`, punctuation that carries meaning.
97 Operator,
98}
99
100impl TokenKind {
101 /// The string handed across the WASM boundary. Kept short and
102 /// lowercase so the TS side can use them as-is for CSS class
103 /// suffixes (`syntax-keyword`, `syntax-string`, …).
104 #[must_use]
105 pub const fn as_str(self) -> &'static str {
106 match self {
107 Self::Keyword => "keyword",
108 Self::Function => "function",
109 Self::Type => "type",
110 Self::Number => "number",
111 Self::String => "string",
112 Self::Comment => "comment",
113 Self::Variable => "variable",
114 Self::Constant => "constant",
115 Self::Operator => "operator",
116 }
117 }
118
119 /// Map a raw arborium capture name (e.g. `"keyword.function"`,
120 /// `"string.special.symbol"`) to our coarse enum. Unknown
121 /// captures return `None` — the caller should skip them so we
122 /// don't paint random tokens.
123 ///
124 /// The matching is prefix-based (`"keyword.function"` →
125 /// `Keyword`) because arborium's capture names are dotted
126 /// namespaces where the first segment is the most important.
127 #[must_use]
128 pub fn from_capture(capture: &str) -> Option<Self> {
129 // Strip the leaf segment; match on the root. This handles both
130 // bare captures like `"keyword"` and dotted ones like
131 // `"keyword.function.macro"` uniformly.
132 let root = capture.split('.').next().unwrap_or(capture);
133 Some(match root {
134 "keyword" | "conditional" | "repeat" | "include" | "storageclass" => Self::Keyword,
135 "function" | "method" => Self::Function,
136 "type" | "class" | "constructor" => Self::Type,
137 "number" | "float" => Self::Number,
138 "string" | "character" => Self::String,
139 "comment" => Self::Comment,
140 "variable" | "parameter" | "property" | "field" => Self::Variable,
141 "constant" | "boolean" => Self::Constant,
142 "operator" | "punctuation" => Self::Operator,
143 _ => return None,
144 })
145 }
146}
147
148/// A highlighted range within the source, ready to cross the WASM
149/// boundary. Offsets are **UTF-16 code units** to match the
150/// convention used everywhere else in this codebase
151/// (`TextSpan`, selection, `utf16_len`) — the JS side does
152/// `string.slice(token.start, token.end)` directly with these
153/// numbers.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Token {
156 /// UTF-16 code-unit offset where the token starts (inclusive).
157 pub start_utf16: u32,
158 /// UTF-16 code-unit offset where the token ends (exclusive).
159 pub end_utf16: u32,
160 /// Short kebab-case kind, see [`TokenKind::as_str`].
161 pub kind: &'static str,
162}
163
164/// Names we accept from the editor's `language` prop.
165///
166/// The matching is permissive and case-insensitive. Canonical
167/// targets are in `normalize_language`. In particular:
168/// - `rust` / `rs` → Rust
169/// - `typescript` / `ts` → TypeScript
170/// - `javascript` / `js` → JavaScript
171/// - `jsx` / `tsx` → JavaScript (bundle-size fallback; the dedicated
172/// tree-sitter-tsx grammar would add ~1 MB uncompressed, so we
173/// accept losing JSX tag/attribute granularity and TypeScript
174/// type syntax for `.tsx` snippets)
175/// - `python` / `py`, `json`, `bash` / `sh` / `shell` / `zsh`,
176/// `html` / `htm`, `css`, `markdown` / `md`, `sql` + dialect
177/// aliases → their respective grammars
178///
179/// Returns `false` for unsupported languages — the caller should
180/// fall back to rendering the code unstyled.
181#[must_use]
182pub fn is_supported_language(language: &str) -> bool {
183 normalize_language(language).is_some()
184}
185
186/// Canonicalise a user-supplied language name to an arborium-backed
187/// grammar, or `None` if we don't bundle a grammar for it.
188fn normalize_language(language: &str) -> Option<&'static str> {
189 let lang = language.trim().to_ascii_lowercase();
190 match lang.as_str() {
191 "rust" | "rs" => Some("rust"),
192 "typescript" | "ts" => Some("typescript"),
193 // `jsx` and `tsx` both map to the JavaScript grammar. The
194 // dedicated TSX/JSX tree-sitter parsers would give slightly
195 // better coverage of the `<Component />` fragment syntax but
196 // they ship their own full parse tables (they do NOT inherit
197 // the JS/TS parser), which would double the bundle size. The
198 // tradeoff: `.jsx`/`.tsx` code blocks get solid
199 // keyword/string/comment/identifier colouring, minus
200 // JSX-specific element/attribute granularity. Revisit if
201 // users ask for JSX tag-name colouring.
202 "javascript" | "js" | "jsx" | "tsx" => Some("javascript"),
203 "python" | "py" => Some("python"),
204 "json" => Some("json"),
205 // Aliases favoured by common Markdown fences / editor
206 // mode-lines. `shell` and `sh` map to Bash because the
207 // grammar handles both POSIX sh and Bash-specific syntax
208 // gracefully, and dedicated sh grammars are rarely shipped.
209 "bash" | "sh" | "shell" | "zsh" => Some("bash"),
210 "html" | "htm" => Some("html"),
211 "css" => Some("css"),
212 "markdown" | "md" => Some("markdown"),
213 // Various SQL dialect aliases collapse to the generic grammar.
214 // The tree-sitter-sql grammar covers standard SQL well; dialect
215 // specialisations (PostgreSQL-only features, T-SQL, etc.) would
216 // need separate grammars which we deliberately don't ship.
217 "sql" | "postgres" | "postgresql" | "psql" | "mysql" | "sqlite" => Some("sql"),
218 _ => None,
219 }
220}
221
222/// Highlight `code` as `language` and return a de-duplicated,
223/// left-to-right sorted list of tokens with UTF-16 offsets.
224///
225/// # Errors
226///
227/// Returns an empty vector (not an error) when:
228/// - the language is not supported — callers render plain text
229/// - the code is empty — no tokens to produce
230///
231/// Grammar-compilation or parse failures are recoverable: we return
232/// an empty vector and the caller paints plain text. We never panic.
233#[must_use]
234pub fn highlight(code: &str, language: &str) -> Vec<Token> {
235 if code.is_empty() {
236 return Vec::new();
237 }
238 if code.len() > MAX_HIGHLIGHT_BYTES {
239 // Paste-of-huge-file protection: tree-sitter happily parses
240 // megabytes of code, but doing so synchronously on the main
241 // thread (we are called from `useMemo` via WASM) would freeze
242 // the editor for hundreds of ms. Callers render plain text.
243 return Vec::new();
244 }
245 let Some(lang_name) = normalize_language(language) else {
246 return Vec::new();
247 };
248
249 let Ok(grammar) = get_or_compile_grammar(lang_name) else {
250 return Vec::new();
251 };
252 let Ok(mut ctx) = ParseContext::for_grammar(&grammar) else {
253 return Vec::new();
254 };
255
256 let result = grammar.parse(&mut ctx, code);
257 byte_spans_to_utf16_tokens(code, &result.spans)
258}
259
260/// Upper bound (in bytes) on the source we will attempt to highlight.
261/// Above this size we fall back to plain text so pasting a large file
262/// cannot freeze the UI thread.
263///
264/// 128 KiB is ~4,000 lines of typical code — comfortably above any
265/// sensible code block you'd embed in a note, and well below the
266/// range where tree-sitter latency becomes user-visible on mid-range
267/// hardware.
268///
269/// The React integration enforces a tighter UTF-16 limit (64 Ki code
270/// units — see `MAX_HIGHLIGHT_CHARS` in
271/// `packages/react/src/plugins/code.tsx`) that always short-circuits
272/// before this one for any realistic input: ASCII at the JS limit is
273/// 64 KiB (half of this) and multi-byte text hits the JS limit even
274/// sooner. This Rust-side bound is therefore a safety net for
275/// non-React consumers (future CLI tools, server-side renderers) that
276/// might call `highlight` directly with no upstream guard.
277const MAX_HIGHLIGHT_BYTES: usize = 128 * 1024;
278
279/// Global cache of compiled grammars keyed by canonical language
280/// name. `CompiledGrammar::new` compiles the highlight query against
281/// the parser tables, which is the expensive part; once compiled the
282/// grammar is immutable and can be reused across every `highlight()`
283/// call for that language.
284///
285/// `Mutex` over the map itself is enough — we only hold it briefly
286/// (map lookup / insert), and the values are `Arc` so we hand out
287/// cheap clones to callers that then drop the lock before parsing.
288/// WASM is single-threaded so the lock is effectively uncontended,
289/// but we still use `Mutex` rather than `RefCell` so native tests
290/// (which are multi-threaded) don't panic on re-entry.
291fn grammar_cache() -> &'static Mutex<HashMap<&'static str, Arc<CompiledGrammar>>> {
292 static CACHE: OnceLock<Mutex<HashMap<&'static str, Arc<CompiledGrammar>>>> = OnceLock::new();
293 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
294}
295
296/// Return a cached `CompiledGrammar` for the language, compiling on
297/// first use. Subsequent calls for the same language are O(1) map
298/// lookups.
299fn get_or_compile_grammar(
300 lang_name: &'static str,
301) -> Result<Arc<CompiledGrammar>, arborium::advanced::GrammarError> {
302 // Fast path: grammar already compiled.
303 {
304 let guard = grammar_cache().lock().expect("grammar cache poisoned");
305 if let Some(existing) = guard.get(lang_name) {
306 return Ok(Arc::clone(existing));
307 }
308 }
309 // Slow path: compile, then insert. A parallel call for the same
310 // language could race to compile twice on native; both results
311 // are equivalent so we accept the occasional double-compile in
312 // exchange for not holding the lock across compilation (which
313 // could take tens of ms for a complex grammar).
314 let compiled = compile_grammar(lang_name)?;
315 let mut guard = grammar_cache().lock().expect("grammar cache poisoned");
316 Ok(Arc::clone(guard.entry(lang_name).or_insert(compiled)))
317}
318
319/// Build a [`CompiledGrammar`] for the given canonical language name.
320/// Callers should use [`get_or_compile_grammar`] instead — this
321/// variant is the uncached path, kept as a named helper so the big
322/// language `match` stays readable.
323fn compile_grammar(
324 lang_name: &'static str,
325) -> Result<Arc<CompiledGrammar>, arborium::advanced::GrammarError> {
326 // arborium ships `HIGHLIGHTS_QUERY` as a `LazyLock<String>` for
327 // the languages whose highlights build on another language's
328 // (TypeScript inherits JavaScript, TSX inherits TypeScript). We
329 // dereference + borrow to satisfy `GrammarConfig`'s `&str`
330 // contract. For languages without inheritance (Rust, JavaScript)
331 // it is a plain `&'static str`.
332 let (language, highlights_query, injections_query, locals_query): (
333 arborium::tree_sitter::Language,
334 &str,
335 &str,
336 &str,
337 ) = match lang_name {
338 "rust" => (
339 arborium::lang_rust::language().into(),
340 arborium::lang_rust::HIGHLIGHTS_QUERY,
341 arborium::lang_rust::INJECTIONS_QUERY,
342 arborium::lang_rust::LOCALS_QUERY,
343 ),
344 "typescript" => (
345 arborium::lang_typescript::language().into(),
346 &arborium::lang_typescript::HIGHLIGHTS_QUERY,
347 arborium::lang_typescript::INJECTIONS_QUERY,
348 arborium::lang_typescript::LOCALS_QUERY,
349 ),
350 "javascript" => (
351 arborium::lang_javascript::language().into(),
352 arborium::lang_javascript::HIGHLIGHTS_QUERY,
353 arborium::lang_javascript::INJECTIONS_QUERY,
354 arborium::lang_javascript::LOCALS_QUERY,
355 ),
356 "python" => (
357 arborium::lang_python::language().into(),
358 arborium::lang_python::HIGHLIGHTS_QUERY,
359 arborium::lang_python::INJECTIONS_QUERY,
360 arborium::lang_python::LOCALS_QUERY,
361 ),
362 "json" => (
363 arborium::lang_json::language().into(),
364 arborium::lang_json::HIGHLIGHTS_QUERY,
365 arborium::lang_json::INJECTIONS_QUERY,
366 arborium::lang_json::LOCALS_QUERY,
367 ),
368 "bash" => (
369 arborium::lang_bash::language().into(),
370 arborium::lang_bash::HIGHLIGHTS_QUERY,
371 arborium::lang_bash::INJECTIONS_QUERY,
372 arborium::lang_bash::LOCALS_QUERY,
373 ),
374 "html" => (
375 arborium::lang_html::language().into(),
376 arborium::lang_html::HIGHLIGHTS_QUERY,
377 arborium::lang_html::INJECTIONS_QUERY,
378 arborium::lang_html::LOCALS_QUERY,
379 ),
380 "css" => (
381 arborium::lang_css::language().into(),
382 arborium::lang_css::HIGHLIGHTS_QUERY,
383 arborium::lang_css::INJECTIONS_QUERY,
384 arborium::lang_css::LOCALS_QUERY,
385 ),
386 "markdown" => (
387 arborium::lang_markdown::language().into(),
388 arborium::lang_markdown::HIGHLIGHTS_QUERY,
389 arborium::lang_markdown::INJECTIONS_QUERY,
390 arborium::lang_markdown::LOCALS_QUERY,
391 ),
392 "sql" => (
393 arborium::lang_sql::language().into(),
394 arborium::lang_sql::HIGHLIGHTS_QUERY,
395 arborium::lang_sql::INJECTIONS_QUERY,
396 arborium::lang_sql::LOCALS_QUERY,
397 ),
398 _ => {
399 // normalize_language filters this out upstream, so this
400 // arm is unreachable at runtime. A debug_assert keeps the
401 // invariant honest in tests; in release we degrade to a
402 // LanguageError rather than panicking.
403 debug_assert!(false, "unreachable lang_name: {lang_name}");
404 return Err(arborium::advanced::GrammarError::LanguageError);
405 }
406 };
407 let config = GrammarConfig {
408 language,
409 highlights_query,
410 injections_query,
411 locals_query,
412 };
413 CompiledGrammar::new(config).map(Arc::new)
414}
415
416/// Convert arborium's byte-indexed spans into UTF-16-indexed tokens,
417/// de-duplicating overlaps and rejecting unknown capture names.
418///
419/// # Why the conversion is non-trivial
420///
421/// 1. Arborium emits spans in byte offsets; we need UTF-16 to match
422/// the JS side's `string.length`-based offsets. One emoji (U+1F600)
423/// is 4 bytes UTF-8 but 2 UTF-16 code units.
424/// 2. Tree-sitter queries often produce **overlapping spans** at the
425/// same range — e.g. `keyword` and `keyword.function` can both
426/// match `fn`. We keep the highest-priority span per byte range
427/// and drop duplicates, matching arborium's own HTML renderer's
428/// behaviour.
429/// 3. Many captures (like `punctuation.bracket` for `(` `)`) are
430/// noise for a Notion-style renderer. We filter them out so the
431/// output is visually calm.
432fn byte_spans_to_utf16_tokens(code: &str, spans: &[Span]) -> Vec<Token> {
433 // Deduplicate: for spans with the same (start, end) byte range,
434 // keep the one with the highest `pattern_index`. This matches
435 // arborium's `spans_to_html` convention where later patterns in
436 // `highlights.scm` win.
437 let mut best: Vec<&Span> = Vec::with_capacity(spans.len());
438 for span in spans {
439 // Skip zero-width spans arborium occasionally emits for
440 // anchor-only captures.
441 if span.end <= span.start {
442 continue;
443 }
444 match best
445 .iter()
446 .position(|s| s.start == span.start && s.end == span.end)
447 {
448 Some(idx) => {
449 if span.pattern_index > best[idx].pattern_index {
450 best[idx] = span;
451 }
452 }
453 None => best.push(span),
454 }
455 }
456 // Sort left-to-right. Arborium usually emits sorted, but we don't
457 // want to rely on an internal-layout promise.
458 best.sort_by_key(|s| (s.start, s.end));
459
460 // Build a byte→UTF-16 offset map for the source once. For a
461 // typical 200-line code block (say 6-8 KB) this is a cheap linear
462 // scan; much cheaper than repeatedly calling `[..byte].encode_utf16()`
463 // per span.
464 let byte_to_utf16 = build_byte_to_utf16_map(code);
465
466 let mut out: Vec<Token> = Vec::with_capacity(best.len());
467 // Track the last range we accepted, so we can flatten overlaps
468 // into a non-overlapping sequence. Tree-sitter commonly emits
469 // two kinds of overlap:
470 // (a) **nested** — a child span fully contained in a parent
471 // span (`function.call` inside `call_expression`). The
472 // parent was emitted first; we drop the nested child.
473 // (b) **partial** — a later span starts inside the previous
474 // span but extends beyond it. This is rare but happens
475 // when grammar captures straddle a token boundary. Instead
476 // of dropping the whole span (which silently loses colour
477 // on the tail) we clamp its start to `prev_end_byte` and
478 // emit the remaining suffix.
479 // The output is guaranteed sorted + non-overlapping — callers
480 // (the React renderer) depend on this invariant.
481 let mut prev_end_byte: u32 = 0;
482 for span in best {
483 let Some(kind) = TokenKind::from_capture(&span.capture) else {
484 continue;
485 };
486 // Fully covered by a previous span → nested, drop it.
487 if span.end <= prev_end_byte {
488 continue;
489 }
490 // Clamp start forward on partial overlap so we don't emit
491 // a range that collides with the previous token.
492 let start_byte = span.start.max(prev_end_byte);
493 let start_utf16 = byte_offset_to_utf16(&byte_to_utf16, start_byte);
494 let end_utf16 = byte_offset_to_utf16(&byte_to_utf16, span.end);
495 if end_utf16 <= start_utf16 {
496 continue;
497 }
498 out.push(Token {
499 start_utf16,
500 end_utf16,
501 kind: kind.as_str(),
502 });
503 prev_end_byte = span.end;
504 }
505 out
506}
507
508/// Build a vector such that `map[b]` is the UTF-16 offset of byte
509/// `b`. `map` has length `code.len() + 1` so we can look up the end
510/// boundary.
511///
512/// This is O(n) over the source; doing the same work inline per-span
513/// was measured as the hot path when benchmarking on large Korean
514/// text code blocks during the Phase-3 WASM benchmarks.
515fn build_byte_to_utf16_map(code: &str) -> Vec<u32> {
516 let mut map = Vec::with_capacity(code.len() + 1);
517 let mut utf16 = 0u32;
518 let mut last_byte = 0usize;
519 for (byte_idx, ch) in code.char_indices() {
520 // Fill the gap for continuation bytes of the previous char —
521 // they all resolve to the same UTF-16 offset as the char's
522 // starting byte.
523 while map.len() < byte_idx {
524 map.push(utf16);
525 }
526 map.push(utf16);
527 utf16 += u32::try_from(ch.len_utf16()).unwrap_or(0);
528 last_byte = byte_idx + ch.len_utf8();
529 }
530 // Tail: pad out to code.len() + 1 with the final UTF-16 value so
531 // `byte_offset_to_utf16(code.len())` returns the total length.
532 while map.len() <= code.len() {
533 map.push(utf16);
534 }
535 // Sanity: last_byte should equal code.len() for valid UTF-8.
536 debug_assert!(last_byte <= code.len());
537 map
538}
539
540fn byte_offset_to_utf16(map: &[u32], byte_offset: u32) -> u32 {
541 let idx = (byte_offset as usize).min(map.len().saturating_sub(1));
542 map.get(idx).copied().unwrap_or(0)
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn is_supported_language_accepts_aliases() {
551 // Core MVP languages
552 assert!(is_supported_language("rust"));
553 assert!(is_supported_language("rs"));
554 assert!(is_supported_language("RUST")); // case-insensitive
555 assert!(is_supported_language("ts"));
556 assert!(is_supported_language("typescript"));
557 assert!(is_supported_language("js"));
558 assert!(is_supported_language("javascript"));
559 assert!(is_supported_language("tsx"));
560 assert!(is_supported_language("jsx"));
561 // Extended language set
562 assert!(is_supported_language("python"));
563 assert!(is_supported_language("py"));
564 assert!(is_supported_language("json"));
565 assert!(is_supported_language("bash"));
566 assert!(is_supported_language("sh"));
567 assert!(is_supported_language("shell"));
568 assert!(is_supported_language("zsh"));
569 assert!(is_supported_language("html"));
570 assert!(is_supported_language("htm"));
571 assert!(is_supported_language("css"));
572 assert!(is_supported_language("markdown"));
573 assert!(is_supported_language("md"));
574 assert!(is_supported_language("sql"));
575 assert!(is_supported_language("postgres"));
576 assert!(is_supported_language("mysql"));
577 assert!(is_supported_language("sqlite"));
578 // Still unsupported
579 assert!(!is_supported_language("go"));
580 assert!(!is_supported_language("c"));
581 assert!(!is_supported_language(""));
582 }
583
584 #[test]
585 fn highlight_empty_code_returns_empty() {
586 assert_eq!(highlight("", "rust"), Vec::<Token>::new());
587 }
588
589 #[test]
590 fn highlight_unsupported_language_returns_empty() {
591 // `go` is NOT in our bundled grammar set; the Rust side must
592 // return an empty vec so the React view falls back to plain
593 // text.
594 assert_eq!(
595 highlight("package main\nfunc main() {}", "go"),
596 Vec::<Token>::new()
597 );
598 }
599
600 #[test]
601 fn highlight_rust_fn_declaration_includes_keyword_and_function() {
602 let tokens = highlight("fn main() {}", "rust");
603 // We should have at least a `keyword` token for `fn` and a
604 // `function` token for `main`. We don't assert exact count or
605 // ordering of operators/punctuation because arborium's grammar
606 // emits implementation-dependent extras.
607 assert!(
608 tokens.iter().any(|t| t.kind == "keyword"),
609 "expected a keyword token, got {tokens:?}"
610 );
611 assert!(
612 tokens.iter().any(|t| t.kind == "function"),
613 "expected a function token, got {tokens:?}"
614 );
615 }
616
617 #[test]
618 fn highlight_rust_string_literal_tagged_as_string() {
619 let tokens = highlight(r#"let s = "hello";"#, "rust");
620 assert!(
621 tokens.iter().any(|t| t.kind == "string"),
622 "expected a string token, got {tokens:?}"
623 );
624 assert!(
625 tokens.iter().any(|t| t.kind == "keyword"),
626 "expected a keyword token for `let`, got {tokens:?}"
627 );
628 }
629
630 #[test]
631 fn highlight_rust_comment_tagged_as_comment() {
632 let tokens = highlight("// hello\nfn a() {}", "rust");
633 assert!(
634 tokens.iter().any(|t| t.kind == "comment"),
635 "expected a comment token, got {tokens:?}"
636 );
637 }
638
639 #[test]
640 fn highlight_typescript_async_function() {
641 let tokens = highlight("async function fetch() { return 1; }", "ts");
642 assert!(
643 tokens.iter().any(|t| t.kind == "keyword"),
644 "expected a keyword token, got {tokens:?}"
645 );
646 assert!(
647 tokens.iter().any(|t| t.kind == "function"),
648 "expected a function token, got {tokens:?}"
649 );
650 }
651
652 #[test]
653 fn highlight_javascript_const_and_number() {
654 let tokens = highlight("const x = 42;", "js");
655 assert!(tokens.iter().any(|t| t.kind == "keyword"));
656 assert!(tokens.iter().any(|t| t.kind == "number"));
657 }
658
659 #[test]
660 fn partial_overlap_clamps_start_instead_of_dropping() {
661 // Synthesize a span list that mimics tree-sitter emitting a
662 // child whose end exceeds its parent's end (partial overlap,
663 // not full nesting). `byte_spans_to_utf16_tokens` should NOT
664 // drop the suffix — it should emit a clamped token for the
665 // tail region.
666 let code = "abcdefghij"; // 10 ASCII bytes, same UTF-16 offsets
667 let spans = vec![
668 Span {
669 start: 0,
670 end: 4,
671 capture: "keyword".to_string(),
672 pattern_index: 0,
673 },
674 // Partially overlaps the previous span: starts at 2,
675 // ends at 7. Previously the whole span was skipped; now
676 // we emit a `string` token for [4, 7).
677 Span {
678 start: 2,
679 end: 7,
680 capture: "string".to_string(),
681 pattern_index: 1,
682 },
683 ];
684 let out = byte_spans_to_utf16_tokens(code, &spans);
685 assert_eq!(
686 out,
687 vec![
688 Token {
689 start_utf16: 0,
690 end_utf16: 4,
691 kind: "keyword",
692 },
693 Token {
694 start_utf16: 4,
695 end_utf16: 7,
696 kind: "string",
697 },
698 ],
699 );
700 }
701
702 #[test]
703 fn full_nesting_still_drops_inner_span() {
704 // Invariant: a span fully contained in a previous one (nested
705 // capture like `function.call` inside `call_expression`)
706 // stays dropped — we do not emit duplicate overlapping tokens.
707 let code = "abcdefghij";
708 let spans = vec![
709 Span {
710 start: 0,
711 end: 10,
712 capture: "function".to_string(),
713 pattern_index: 0,
714 },
715 Span {
716 start: 3,
717 end: 6,
718 capture: "keyword".to_string(),
719 pattern_index: 1,
720 },
721 ];
722 let out = byte_spans_to_utf16_tokens(code, &spans);
723 assert_eq!(out.len(), 1, "nested child should be dropped: {out:?}");
724 assert_eq!(out[0].kind, "function");
725 }
726
727 #[test]
728 fn highlight_tokens_are_sorted_and_non_overlapping() {
729 let tokens = highlight("fn a() { let x = 42; }", "rust");
730 let mut prev_end = 0u32;
731 for t in &tokens {
732 assert!(
733 t.start_utf16 >= prev_end,
734 "tokens overlap: prev_end={prev_end} start={} in {tokens:?}",
735 t.start_utf16
736 );
737 assert!(t.end_utf16 > t.start_utf16, "zero-width token: {t:?}");
738 prev_end = t.end_utf16;
739 }
740 }
741
742 #[test]
743 fn byte_offsets_are_converted_to_utf16_for_non_ascii() {
744 // "한글" is 2 UTF-16 units, "🚀" is a surrogate pair (2 UTF-16 units).
745 // The code below has a Korean identifier comment + an emoji
746 // comment. We just verify that when arborium reports a byte
747 // offset past the multi-byte chars, the utf16 offset makes
748 // sense (strictly less than the byte offset).
749 let source = "// 한글 🚀\nfn main() {}";
750 let tokens = highlight(source, "rust");
751 let total_utf16 =
752 u32::try_from(source.encode_utf16().count()).expect("test source fits in u32");
753 for t in &tokens {
754 assert!(
755 t.end_utf16 <= total_utf16,
756 "token end {} exceeds total utf16 {total_utf16}: {t:?}",
757 t.end_utf16
758 );
759 }
760 }
761
762 #[test]
763 fn token_kind_from_capture_maps_dotted_captures() {
764 assert_eq!(
765 TokenKind::from_capture("keyword.function"),
766 Some(TokenKind::Keyword)
767 );
768 assert_eq!(
769 TokenKind::from_capture("string.special.symbol"),
770 Some(TokenKind::String)
771 );
772 assert_eq!(
773 TokenKind::from_capture("function.call"),
774 Some(TokenKind::Function)
775 );
776 assert_eq!(TokenKind::from_capture("bogus.capture"), None);
777 }
778
779 // ---------------------------------------------------------------
780 // Per-language smoke tests for the extended set
781 //
782 // Each test checks a single strong signal unique to the language
783 // (e.g. `def` keyword in Python, `SELECT` keyword in SQL). We do
784 // not assert exhaustively — tree-sitter grammars evolve, and a
785 // single robust signal is enough to prove the wiring is correct.
786 // ---------------------------------------------------------------
787
788 #[test]
789 fn highlight_python_def_and_string() {
790 let tokens = highlight("def greet(name):\n return \"hi\"", "python");
791 assert!(
792 tokens.iter().any(|t| t.kind == "keyword"),
793 "expected a keyword token for `def`/`return`, got {tokens:?}"
794 );
795 assert!(
796 tokens.iter().any(|t| t.kind == "string"),
797 "expected a string token, got {tokens:?}"
798 );
799 }
800
801 #[test]
802 fn highlight_json_basic_shape() {
803 // JSON grammar emits property-name captures as strings; we
804 // assert both the "key" strings and the value string are
805 // tagged. JSON has no keywords in the classic sense (true /
806 // false are booleans → constant).
807 let tokens = highlight(r#"{"name": "alice", "age": 30}"#, "json");
808 assert!(
809 tokens.iter().any(|t| t.kind == "string"),
810 "expected string tokens, got {tokens:?}"
811 );
812 assert!(
813 tokens.iter().any(|t| t.kind == "number"),
814 "expected a number token for `30`, got {tokens:?}"
815 );
816 }
817
818 #[test]
819 fn highlight_bash_comment_and_keyword() {
820 let tokens = highlight("#!/usr/bin/env bash\nif true; then echo hi; fi", "bash");
821 assert!(
822 tokens.iter().any(|t| t.kind == "comment"),
823 "expected a comment token for shebang, got {tokens:?}"
824 );
825 assert!(
826 tokens.iter().any(|t| t.kind == "keyword"),
827 "expected a keyword token for `if`/`then`/`fi`, got {tokens:?}"
828 );
829 }
830
831 #[test]
832 fn highlight_bash_accepts_sh_alias() {
833 // The `sh` / `shell` / `zsh` aliases should all land on the
834 // bash grammar and produce tokens. This guards the
835 // normalize_language routing.
836 let tokens = highlight("echo hello", "sh");
837 assert!(
838 !tokens.is_empty(),
839 "expected `sh` alias to route to bash grammar and produce tokens"
840 );
841 }
842
843 #[test]
844 fn highlight_html_tag_and_attribute() {
845 // The HTML grammar's highlights emit tag names and attribute
846 // names as captures. We only assert "some token came out" —
847 // the exact capture names vary across tree-sitter-html
848 // versions. The cross-cutting `function` / `keyword` / `type`
849 // bucket our coarse enum uses absorbs those variations.
850 let tokens = highlight(r#"<div class="box">hello</div>"#, "html");
851 assert!(!tokens.is_empty(), "expected html tokens, got {tokens:?}");
852 }
853
854 #[test]
855 fn highlight_css_selector_and_property() {
856 let tokens = highlight(".card { color: red; font-size: 14px; }", "css");
857 assert!(!tokens.is_empty(), "expected css tokens, got {tokens:?}");
858 // A number token is the most stable signal in CSS: 14 from
859 // `14px`. Colour keywords vary (some grammars tag `red` as
860 // constant, others as variable), so we don't pin that.
861 assert!(
862 tokens.iter().any(|t| t.kind == "number"),
863 "expected a number token, got {tokens:?}"
864 );
865 }
866
867 #[test]
868 fn highlight_markdown_produces_tokens() {
869 // Markdown's "highlighting" is structural (heading markers,
870 // list bullets, emphasis). The exact captures differ across
871 // tree-sitter-markdown versions, so we only assert that
872 // *something* came out — empty result would mean routing
873 // broke or the grammar failed to parse.
874 let tokens = highlight("# Title\n\nSome **bold** text.\n", "markdown");
875 assert!(
876 !tokens.is_empty(),
877 "expected markdown tokens, got {tokens:?}"
878 );
879 }
880
881 #[test]
882 fn highlight_sql_select_keyword() {
883 let tokens = highlight("SELECT id, name FROM users WHERE age > 18;", "sql");
884 // The SQL grammar's highlights.scm is unusual: it tags
885 // numeric literals under the `string` capture root rather
886 // than `number`. This is a grammar-authoring choice we can't
887 // override without rewriting queries. The important assertion
888 // is that SELECT/FROM/WHERE come out as keywords.
889 assert!(
890 tokens.iter().any(|t| t.kind == "keyword"),
891 "expected a keyword token for `SELECT`/`FROM`/`WHERE`, got {tokens:?}"
892 );
893 assert!(
894 tokens.iter().any(|t| t.kind == "type"),
895 "expected a type token for the table name `users`, got {tokens:?}"
896 );
897 }
898
899 #[test]
900 fn highlight_sql_alias_routes_to_sql_grammar() {
901 // `postgres` / `mysql` / `sqlite` all collapse to the generic
902 // SQL grammar. A SELECT statement is common to all dialects
903 // and must tokenise.
904 for alias in ["postgres", "postgresql", "mysql", "sqlite"] {
905 let tokens = highlight("SELECT 1;", alias);
906 assert!(
907 !tokens.is_empty(),
908 "expected `{alias}` alias to produce tokens"
909 );
910 }
911 }
912}