Skip to main content

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}