Skip to main content

hikari_ts/
lib.rs

1//! hikari (光) — the tree-sitter backend.
2//!
3//! The fleet's tree-sitter host, owned here (not borrowed from an application
4//! crate). It bundles the tree-sitter C runtime + grammars and exposes:
5//!
6//!   * [`GrammarRegistry`] / [`Grammar`] — language-name → grammar + highlight
7//!     config, shipped with tree-sitter-rust (more grammars land here).
8//!   * [`BufferParser`] — a per-buffer parser keeping a `tree_sitter::Tree`,
9//!     with a full [`reparse`](BufferParser::reparse) and an incremental
10//!     [`reparse_edit`](BufferParser::reparse_edit) (`Tree::edit` + subtree
11//!     reuse via the typed byte-based [`TsEdit`]).
12//!   * [`highlight`] — whole-document highlight → gappy [`Semantic`] spans.
13//!   * [`TreeSitterHost`] / [`TreeSitterHighlighter`] — the hikari-facing
14//!     wrapper: ONE generic highlighter for every grammar, implementing
15//!     [`hikari_core::Highlighter`] directly (tree-sitter carries its own tree,
16//!     so it does NOT go through `LanguageLexer`/`LineDriven`), lowering the
17//!     `Semantic` result to hikari's [`HlClass`] through the coverage-by-
18//!     construction [`SpanSink`] (gaps auto-fill `Plain`).
19//!
20//! Fallible at construct time ([`TreeSitterHost::builtin`] → `Result`),
21//! infallible at highlight time (a parse failure yields all-`Plain`, never a
22//! panic — preserving hikari's panic-free contract).
23//!
24//! `Semantic` is re-exported from `hikari-token` (the deduped fleet vocabulary);
25//! escriba-ts re-exports THIS crate's host in turn, so the tree-sitter host
26//! lives in exactly one place.
27
28#![forbid(unsafe_code)]
29
30use std::collections::HashMap;
31use std::sync::Arc;
32
33use hikari_core::{
34    HighlightSpan as HlSpan, Highlighter, HlClass, Language, LanguagePlugin, Selector, SpanSink,
35};
36use serde::{Deserialize, Serialize};
37use thiserror::Error;
38use tree_sitter::{
39    InputEdit, Language as TsLanguage, Parser, Point, Tree,
40};
41use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter as TsHighlighter};
42
43// The fleet semantic highlight vocabulary — owned by hikari-token, re-exported
44// so escriba-ts (which re-exports this crate) and every other consumer name one
45// `Semantic`. Carries the total `From<Semantic> for HlClass`.
46pub use hikari_token::Semantic;
47
48// ───────────────────────────── errors ───────────────────────────
49
50#[derive(Debug, Error)]
51pub enum TsError {
52    #[error("grammar not registered: {0}")]
53    Unknown(String),
54    #[error("tree-sitter: {0}")]
55    Ts(String),
56}
57
58pub type Result<T> = std::result::Result<T, TsError>;
59
60// ─────────────────────────── grammars ───────────────────────────
61
62/// A registered grammar — name, language, highlight config, claimed extensions.
63pub struct Grammar {
64    pub name: String,
65    pub language: TsLanguage,
66    pub config: HighlightConfiguration,
67    /// File extensions (no dot) this grammar claims. Mutable at runtime so a
68    /// `defmode :extensions (…)` declaration can broaden the mapping without
69    /// recompilation.
70    pub extensions: Vec<String>,
71}
72
73/// Registry — language-name → [`Grammar`].
74pub struct GrammarRegistry {
75    grammars: HashMap<String, Grammar>,
76    /// The highlight-name namespace — indices into this vector are what
77    /// `HighlightEvent::HighlightStart(…)` returns.
78    pub highlight_names: Vec<&'static str>,
79}
80
81impl GrammarRegistry {
82    /// Build the built-in registry — the go-wide grammar set. Adding a grammar
83    /// is one [`register`](Self::register) line + one `language_matrix` row.
84    ///
85    /// # Errors
86    /// Returns [`TsError::Ts`] if a grammar's highlight query fails to compile.
87    pub fn builtin() -> Result<Self> {
88        let highlight_names = canonical_highlight_names();
89        let mut reg = Self {
90            grammars: HashMap::new(),
91            highlight_names,
92        };
93        reg.register(
94            "rust",
95            &tree_sitter_rust::language(),
96            tree_sitter_rust::HIGHLIGHTS_QUERY,
97            tree_sitter_rust::INJECTIONS_QUERY,
98            &["rs"],
99        )?;
100        reg.register(
101            "python",
102            &tree_sitter_python::language(),
103            tree_sitter_python::HIGHLIGHTS_QUERY,
104            "",
105            &["py", "pyi"],
106        )?;
107        reg.register(
108            "json",
109            &tree_sitter_json::language(),
110            tree_sitter_json::HIGHLIGHTS_QUERY,
111            "",
112            &["json"],
113        )?;
114        reg.register(
115            "bash",
116            &tree_sitter_bash::language(),
117            tree_sitter_bash::HIGHLIGHT_QUERY,
118            "",
119            &["sh", "bash", "zsh"],
120        )?;
121        reg.register(
122            "go",
123            &tree_sitter_go::language(),
124            tree_sitter_go::HIGHLIGHTS_QUERY,
125            "",
126            &["go"],
127        )?;
128        reg.register(
129            "c",
130            &tree_sitter_c::language(),
131            tree_sitter_c::HIGHLIGHT_QUERY,
132            "",
133            &["c", "h"],
134        )?;
135        // C++ extends C: its own highlight query holds only the cpp-specific
136        // additions, so a `.cpp` file's plain-C syntax needs the base C query
137        // too. Concatenate them (tree-sitter-cpp is designed for this).
138        let cpp_hl = format!(
139            "{}\n{}",
140            tree_sitter_c::HIGHLIGHT_QUERY,
141            tree_sitter_cpp::HIGHLIGHT_QUERY,
142        );
143        reg.register(
144            "cpp",
145            &tree_sitter_cpp::language(),
146            &cpp_hl,
147            "",
148            &["cpp", "cc", "cxx", "hpp", "hh"],
149        )?;
150        reg.register(
151            "css",
152            &tree_sitter_css::language(),
153            tree_sitter_css::HIGHLIGHTS_QUERY,
154            "",
155            &["css", "scss"],
156        )?;
157        reg.register(
158            "html",
159            &tree_sitter_html::language(),
160            tree_sitter_html::HIGHLIGHTS_QUERY,
161            tree_sitter_html::INJECTIONS_QUERY,
162            &["html", "htm"],
163        )?;
164        reg.register(
165            "ruby",
166            &tree_sitter_ruby::language(),
167            tree_sitter_ruby::HIGHLIGHTS_QUERY,
168            "",
169            &["rb"],
170        )?;
171        Ok(reg)
172    }
173
174    /// Register one grammar: compile its highlight config against the canonical
175    /// name space and insert it under `name` claiming `extensions`. The one
176    /// repeated shape, factored out so adding a grammar is a single call.
177    ///
178    /// # Errors
179    /// Returns [`TsError::Ts`] if the highlight query fails to compile.
180    fn register(
181        &mut self,
182        name: &str,
183        language: &TsLanguage,
184        highlights: &str,
185        injections: &str,
186        extensions: &[&str],
187    ) -> Result<()> {
188        let mut cfg =
189            HighlightConfiguration::new(language.clone(), name, highlights, injections, "")
190                .map_err(|e| TsError::Ts(format!("{name}: {e}")))?;
191        cfg.configure(&self.highlight_names);
192        self.grammars.insert(
193            name.to_string(),
194            Grammar {
195                name: name.to_string(),
196                language: language.clone(),
197                config: cfg,
198                extensions: extensions.iter().map(|s| (*s).to_string()).collect(),
199            },
200        );
201        Ok(())
202    }
203
204    #[must_use]
205    pub fn get(&self, language: &str) -> Option<&Grammar> {
206        self.grammars.get(language)
207    }
208
209    /// Look up a language by file extension (e.g. `"rs"` → `"rust"`).
210    #[must_use]
211    pub fn from_extension(&self, ext: &str) -> Option<&Grammar> {
212        self.grammars
213            .values()
214            .find(|g| g.extensions.iter().any(|e| e == ext))
215    }
216
217    /// Broaden a grammar's extension list. Returns `true` iff the grammar was
218    /// registered; `false` means the caller referenced an unknown language.
219    pub fn add_extension(&mut self, language: &str, ext: impl Into<String>) -> bool {
220        if let Some(g) = self.grammars.get_mut(language) {
221            let ext = ext.into();
222            if !g.extensions.iter().any(|e| *e == ext) {
223                g.extensions.push(ext);
224            }
225            true
226        } else {
227            false
228        }
229    }
230
231    /// Iterate every registered language name.
232    pub fn languages(&self) -> impl Iterator<Item = &str> {
233        self.grammars.keys().map(String::as_str)
234    }
235}
236
237// ────────────────────────── per-buffer parse ────────────────────
238
239/// Per-buffer parser + last-parsed tree.
240pub struct BufferParser {
241    language: String,
242    parser: Parser,
243    tree: Option<Tree>,
244}
245
246impl BufferParser {
247    /// A parser for `language` (must be registered).
248    ///
249    /// # Errors
250    /// Returns [`TsError`] if the language is unknown or the parser rejects it.
251    pub fn new(language: &str, registry: &GrammarRegistry) -> Result<Self> {
252        let grammar = registry
253            .get(language)
254            .ok_or_else(|| TsError::Unknown(language.to_string()))?;
255        let mut parser = Parser::new();
256        parser
257            .set_language(&grammar.language)
258            .map_err(|e| TsError::Ts(e.to_string()))?;
259        Ok(Self {
260            language: language.to_string(),
261            parser,
262            tree: None,
263        })
264    }
265
266    #[must_use]
267    pub fn language(&self) -> &str {
268        &self.language
269    }
270
271    /// Re-parse `src` from scratch. Passes `None` as the old tree on purpose:
272    /// tree-sitter's incremental path requires the old tree to have been
273    /// `Tree::edit`-ed to reflect exactly what changed. Handing `parse()` an
274    /// *un-edited* old tree against changed source violates that contract and
275    /// can yield an incorrect tree — so the correct answer for an unknown delta
276    /// is a full parse. Callers that know the edit use
277    /// [`reparse_edit`](Self::reparse_edit).
278    ///
279    /// # Errors
280    /// Infallible today (tree-sitter returns `None` on failure, stored as-is);
281    /// the `Result` reserves fallibility for future timeout/cancel support.
282    pub fn reparse(&mut self, src: &str) -> Result<()> {
283        self.tree = self.parser.parse(src, None);
284        Ok(())
285    }
286
287    /// Incrementally re-parse after splicing `[start_byte, old_end_byte)` of
288    /// `old_src` to produce `new_src`. Edits the retained tree by the splice
289    /// ([`TsEdit`]) so tree-sitter reuses every unchanged subtree and reparses
290    /// only the affected span — `O(edit)`, not `O(document)`. With no prior tree
291    /// it falls back to a full parse. The result is identical to a full parse of
292    /// `new_src` (the differential-equivalence invariant, tested).
293    ///
294    /// # Errors
295    /// Same as [`reparse`](Self::reparse).
296    pub fn reparse_edit(
297        &mut self,
298        old_src: &str,
299        new_src: &str,
300        start_byte: usize,
301        old_end_byte: usize,
302    ) -> Result<()> {
303        if self.tree.is_some() {
304            let edit = TsEdit::from_splice(old_src, new_src, start_byte, old_end_byte);
305            if let Some(tree) = self.tree.as_mut() {
306                tree.edit(&edit.to_input_edit());
307            }
308            self.tree = self.parser.parse(new_src, self.tree.as_ref());
309        } else {
310            self.tree = self.parser.parse(new_src, None);
311        }
312        Ok(())
313    }
314
315    #[must_use]
316    pub fn tree(&self) -> Option<&Tree> {
317        self.tree.as_ref()
318    }
319}
320
321/// A typed, byte-based description of one contiguous splice, for incremental
322/// tree-sitter reparse. tree-sitter's native unit is the byte offset + a
323/// `(row, byte-column)` point, so this converts from a plain source splice —
324/// no tree-sitter type crosses the caller boundary.
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct TsEdit {
327    pub start_byte: usize,
328    pub old_end_byte: usize,
329    pub new_end_byte: usize,
330    /// `(row, byte-column)` of the splice start (identical in old + new).
331    pub start_point: (usize, usize),
332    pub old_end_point: (usize, usize),
333    pub new_end_point: (usize, usize),
334}
335
336impl TsEdit {
337    /// Compute the splice turning `old` into `new` by replacing
338    /// `old[start_byte..old_end_byte]`. The unchanged suffix has the same length
339    /// in `new`, so `new_end_byte = new.len() - (old.len() - old_end_byte)`.
340    #[must_use]
341    pub fn from_splice(old: &str, new: &str, start_byte: usize, old_end_byte: usize) -> Self {
342        let new_end_byte = new.len() - (old.len() - old_end_byte);
343        Self {
344            start_byte,
345            old_end_byte,
346            new_end_byte,
347            start_point: byte_to_point(old, start_byte),
348            old_end_point: byte_to_point(old, old_end_byte),
349            new_end_point: byte_to_point(new, new_end_byte),
350        }
351    }
352
353    fn to_input_edit(self) -> InputEdit {
354        let pt = |(row, column): (usize, usize)| Point { row, column };
355        InputEdit {
356            start_byte: self.start_byte,
357            old_end_byte: self.old_end_byte,
358            new_end_byte: self.new_end_byte,
359            start_position: pt(self.start_point),
360            old_end_position: pt(self.old_end_point),
361            new_end_position: pt(self.new_end_point),
362        }
363    }
364}
365
366/// `(row, byte-column)` of `byte` within `text`. tree-sitter point columns are
367/// byte offsets within the line, not char offsets.
368#[must_use]
369fn byte_to_point(text: &str, byte: usize) -> (usize, usize) {
370    let byte = byte.min(text.len());
371    let prefix = &text[..byte];
372    let row = prefix.bytes().filter(|&b| b == b'\n').count();
373    let col = prefix.len() - prefix.rfind('\n').map_or(0, |i| i + 1);
374    (row, col)
375}
376
377// ─────────────────────────── highlight ──────────────────────────
378
379/// A colored text span — byte range + canonical [`Semantic`] bucket.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct HighlightSpan {
382    pub start: usize,
383    pub end: usize,
384    pub semantic: Semantic,
385}
386
387/// Compute highlight spans over `src` using `grammar`.
388///
389/// # Errors
390/// Returns [`TsError::Ts`] if tree-sitter's highlighter errors.
391pub fn highlight(
392    src: &str,
393    grammar: &Grammar,
394    registry: &GrammarRegistry,
395) -> Result<Vec<HighlightSpan>> {
396    let mut highlighter = TsHighlighter::new();
397    let events = highlighter
398        .highlight(&grammar.config, src.as_bytes(), None, |_| None)
399        .map_err(|e| TsError::Ts(e.to_string()))?;
400
401    let mut stack: Vec<usize> = Vec::new();
402    let mut spans: Vec<HighlightSpan> = Vec::new();
403    let mut run_start: Option<(usize, usize)> = None;
404
405    for ev in events {
406        let ev = ev.map_err(|e| TsError::Ts(e.to_string()))?;
407        match ev {
408            HighlightEvent::HighlightStart(h) => stack.push(h.0),
409            HighlightEvent::HighlightEnd => {
410                stack.pop();
411                run_start = None;
412            }
413            HighlightEvent::Source { start, end } => {
414                if let Some(&top) = stack.last() {
415                    let sem = highlight_index_to_semantic(top, &registry.highlight_names);
416                    match run_start {
417                        Some((rs, _)) if rs == start => {}
418                        _ => {
419                            spans.push(HighlightSpan {
420                                start,
421                                end,
422                                semantic: sem,
423                            });
424                            run_start = Some((start, end));
425                        }
426                    }
427                }
428            }
429        }
430    }
431
432    Ok(spans)
433}
434
435/// The canonical highlight-name namespace every grammar is configured against.
436/// Indices into this vector map to [`Semantic`] buckets.
437fn canonical_highlight_names() -> Vec<&'static str> {
438    vec![
439        "keyword",
440        "function",
441        "function.call",
442        "function.method",
443        "type",
444        "type.builtin",
445        "constant",
446        "constant.builtin",
447        "string",
448        "string.special",
449        "number",
450        "boolean",
451        "comment",
452        "operator",
453        "punctuation",
454        "punctuation.bracket",
455        "punctuation.delimiter",
456        "variable",
457        "variable.parameter",
458        "variable.builtin",
459        "attribute",
460        "label",
461        "tag",
462    ]
463}
464
465fn highlight_index_to_semantic(index: usize, names: &[&'static str]) -> Semantic {
466    let name = names.get(index).copied().unwrap_or("");
467    match name {
468        n if n.starts_with("keyword") => Semantic::Keyword,
469        n if n.starts_with("function") => Semantic::Symbol,
470        n if n.starts_with("type") => Semantic::Accent,
471        n if n.starts_with("constant.builtin") || n == "boolean" => Semantic::Literal,
472        n if n.starts_with("constant") => Semantic::Literal,
473        n if n.starts_with("string") => Semantic::String,
474        n if n == "number" => Semantic::Number,
475        n if n.starts_with("comment") => Semantic::Comment,
476        n if n.starts_with("operator") => Semantic::Accent,
477        n if n.starts_with("punctuation") => Semantic::Muted,
478        n if n.starts_with("variable") => Semantic::Symbol,
479        n if n == "attribute" => Semantic::Hint,
480        n if n == "label" => Semantic::Hint,
481        n if n == "tag" => Semantic::Keyword,
482        _ => Semantic::Symbol,
483    }
484}
485
486// ─────────────────────── hikari Highlighter face ────────────────
487
488/// The tree-sitter host — builds the built-in [`GrammarRegistry`] once and hands
489/// out per-language [`TreeSitterHighlighter`]s + a [`LanguagePlugin`] per grammar
490/// for registration into a hikari `Ecosystem`.
491#[derive(Clone)]
492pub struct TreeSitterHost {
493    registry: Arc<GrammarRegistry>,
494}
495
496impl TreeSitterHost {
497    /// Build the built-in grammar registry (Rust today, more as grammars land).
498    ///
499    /// # Errors
500    /// Returns [`TsError`] if a grammar fails to construct.
501    pub fn builtin() -> Result<Self> {
502        Ok(Self {
503            registry: Arc::new(GrammarRegistry::builtin()?),
504        })
505    }
506
507    /// The languages this host can highlight.
508    pub fn languages(&self) -> impl Iterator<Item = &str> {
509        self.registry.languages()
510    }
511
512    /// A [`LanguagePlugin`] for each built-in grammar, ready to register into a
513    /// hikari `Ecosystem` (each claims its grammar's extensions).
514    #[must_use]
515    pub fn plugins(&self) -> Vec<Box<dyn LanguagePlugin>> {
516        let mut out: Vec<Box<dyn LanguagePlugin>> = Vec::new();
517        for name in self.registry.languages() {
518            // interned static name — the grammar set is fixed, so leaking once
519            // is bounded + gives the 'static `Language` newtype hikari expects.
520            let lang: &'static str = Box::leak(name.to_string().into_boxed_str());
521            let selectors: Vec<Selector> = self
522                .registry
523                .get(name)
524                .map(|g| {
525                    g.extensions
526                        .iter()
527                        .map(|e| Selector::Extension(Box::leak(e.clone().into_boxed_str())))
528                        .collect()
529                })
530                .unwrap_or_default();
531            out.push(Box::new(TreeSitterPlugin {
532                language: Language(lang),
533                selectors: selectors.leak(),
534                registry: self.registry.clone(),
535                grammar: lang,
536            }));
537        }
538        out
539    }
540
541    /// A highlighter for one grammar by name.
542    #[must_use]
543    pub fn highlighter(&self, grammar: &'static str) -> TreeSitterHighlighter {
544        TreeSitterHighlighter {
545            registry: self.registry.clone(),
546            grammar,
547        }
548    }
549}
550
551/// A hikari [`LanguagePlugin`] backed by a tree-sitter grammar.
552pub struct TreeSitterPlugin {
553    language: Language,
554    selectors: &'static [Selector],
555    registry: Arc<GrammarRegistry>,
556    grammar: &'static str,
557}
558
559impl LanguagePlugin for TreeSitterPlugin {
560    fn language(&self) -> Language {
561        self.language
562    }
563    fn selectors(&self) -> &'static [Selector] {
564        self.selectors
565    }
566    fn make_highlighter(&self) -> Box<dyn Highlighter> {
567        Box::new(TreeSitterHighlighter {
568            registry: self.registry.clone(),
569            grammar: self.grammar,
570        })
571    }
572}
573
574/// The generic tree-sitter [`Highlighter`]. Whole-document highlight; the result
575/// (gappy `Semantic` spans) is funneled through [`SpanSink`] so the output is a
576/// coverage-complete hikari partition (gaps become `Plain`).
577pub struct TreeSitterHighlighter {
578    registry: Arc<GrammarRegistry>,
579    grammar: &'static str,
580}
581
582impl Highlighter for TreeSitterHighlighter {
583    fn highlight(&self, text: &str) -> Vec<HlSpan> {
584        let len = u32::try_from(text.len()).unwrap_or(u32::MAX);
585        let mut sink = SpanSink::for_document(len);
586        if let Some(grammar) = self.registry.get(self.grammar)
587            && let Ok(spans) = highlight(text, grammar, &self.registry)
588        {
589            for s in spans {
590                // hikari_token::Semantic → HlClass via the total fleet conversion.
591                let class: HlClass = s.semantic.into();
592                sink.push(
593                    u32::try_from(s.start).unwrap_or(u32::MAX),
594                    u32::try_from(s.end).unwrap_or(u32::MAX),
595                    class,
596                );
597            }
598        }
599        sink.finish()
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::{BufferParser, GrammarRegistry, TreeSitterHost, TsEdit, byte_to_point};
606    use hikari_core::{Highlighter, HlClass};
607
608    #[test]
609    fn builtin_host_highlights_rust_with_coverage() {
610        let host = TreeSitterHost::builtin().expect("builtin grammars");
611        let hl = host.highlighter("rust");
612        let src = "fn main() {\n    let x = 42;\n}\n";
613        let spans = hl.highlight(src);
614        // coverage-complete + forward-only (SpanSink guarantees).
615        let mut cursor = 0u32;
616        for s in &spans {
617            assert_eq!(s.span.start, cursor, "gap/overlap at {cursor}");
618            cursor = s.span.end;
619        }
620        assert_eq!(cursor as usize, src.len(), "partition must cover the text");
621        assert!(
622            spans.iter().any(|s| s.class != HlClass::Plain),
623            "tree-sitter should classify something in real Rust",
624        );
625    }
626
627    #[test]
628    fn rust_grammar_is_registered() {
629        let host = TreeSitterHost::builtin().expect("builtin grammars");
630        assert!(host.languages().any(|l| l == "rust"));
631        assert!(!host.plugins().is_empty());
632    }
633
634    #[test]
635    fn byte_to_point_counts_rows_and_byte_columns() {
636        assert_eq!(byte_to_point("abc", 2), (0, 2));
637        assert_eq!(byte_to_point("ab\ncd", 3), (1, 0));
638        assert_eq!(byte_to_point("x\ny\nz", 4), (2, 0));
639    }
640
641    /// M5 seal: an incremental `Tree::edit` reparse equals a full parse.
642    #[test]
643    fn incremental_reparse_equals_full_parse() {
644        let r = GrammarRegistry::builtin().unwrap();
645        let old = "fn main() { let x = 1; }";
646        let new = "fn main() { let x = 42; }";
647        let start = old.find('1').unwrap();
648
649        let mut inc = BufferParser::new("rust", &r).unwrap();
650        inc.reparse(old).unwrap();
651        inc.reparse_edit(old, new, start, start + 1).unwrap();
652
653        let mut full = BufferParser::new("rust", &r).unwrap();
654        full.reparse(new).unwrap();
655
656        assert_eq!(
657            inc.tree().unwrap().root_node().to_sexp(),
658            full.tree().unwrap().root_node().to_sexp(),
659        );
660    }
661
662    #[test]
663    fn ts_edit_new_end_byte_accounts_for_length_delta() {
664        let old = "x = 1;";
665        let new = "x = 42;";
666        let start = old.find('1').unwrap();
667        let e = TsEdit::from_splice(old, new, start, start + 1);
668        assert_eq!(e.new_end_byte, start + 2);
669    }
670}