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        Ok(reg)
122    }
123
124    /// Register one grammar: compile its highlight config against the canonical
125    /// name space and insert it under `name` claiming `extensions`. The one
126    /// repeated shape, factored out so adding a grammar is a single call.
127    ///
128    /// # Errors
129    /// Returns [`TsError::Ts`] if the highlight query fails to compile.
130    fn register(
131        &mut self,
132        name: &str,
133        language: &TsLanguage,
134        highlights: &str,
135        injections: &str,
136        extensions: &[&str],
137    ) -> Result<()> {
138        let mut cfg =
139            HighlightConfiguration::new(language.clone(), name, highlights, injections, "")
140                .map_err(|e| TsError::Ts(format!("{name}: {e}")))?;
141        cfg.configure(&self.highlight_names);
142        self.grammars.insert(
143            name.to_string(),
144            Grammar {
145                name: name.to_string(),
146                language: language.clone(),
147                config: cfg,
148                extensions: extensions.iter().map(|s| (*s).to_string()).collect(),
149            },
150        );
151        Ok(())
152    }
153
154    #[must_use]
155    pub fn get(&self, language: &str) -> Option<&Grammar> {
156        self.grammars.get(language)
157    }
158
159    /// Look up a language by file extension (e.g. `"rs"` → `"rust"`).
160    #[must_use]
161    pub fn from_extension(&self, ext: &str) -> Option<&Grammar> {
162        self.grammars
163            .values()
164            .find(|g| g.extensions.iter().any(|e| e == ext))
165    }
166
167    /// Broaden a grammar's extension list. Returns `true` iff the grammar was
168    /// registered; `false` means the caller referenced an unknown language.
169    pub fn add_extension(&mut self, language: &str, ext: impl Into<String>) -> bool {
170        if let Some(g) = self.grammars.get_mut(language) {
171            let ext = ext.into();
172            if !g.extensions.iter().any(|e| *e == ext) {
173                g.extensions.push(ext);
174            }
175            true
176        } else {
177            false
178        }
179    }
180
181    /// Iterate every registered language name.
182    pub fn languages(&self) -> impl Iterator<Item = &str> {
183        self.grammars.keys().map(String::as_str)
184    }
185}
186
187// ────────────────────────── per-buffer parse ────────────────────
188
189/// Per-buffer parser + last-parsed tree.
190pub struct BufferParser {
191    language: String,
192    parser: Parser,
193    tree: Option<Tree>,
194}
195
196impl BufferParser {
197    /// A parser for `language` (must be registered).
198    ///
199    /// # Errors
200    /// Returns [`TsError`] if the language is unknown or the parser rejects it.
201    pub fn new(language: &str, registry: &GrammarRegistry) -> Result<Self> {
202        let grammar = registry
203            .get(language)
204            .ok_or_else(|| TsError::Unknown(language.to_string()))?;
205        let mut parser = Parser::new();
206        parser
207            .set_language(&grammar.language)
208            .map_err(|e| TsError::Ts(e.to_string()))?;
209        Ok(Self {
210            language: language.to_string(),
211            parser,
212            tree: None,
213        })
214    }
215
216    #[must_use]
217    pub fn language(&self) -> &str {
218        &self.language
219    }
220
221    /// Re-parse `src` from scratch. Passes `None` as the old tree on purpose:
222    /// tree-sitter's incremental path requires the old tree to have been
223    /// `Tree::edit`-ed to reflect exactly what changed. Handing `parse()` an
224    /// *un-edited* old tree against changed source violates that contract and
225    /// can yield an incorrect tree — so the correct answer for an unknown delta
226    /// is a full parse. Callers that know the edit use
227    /// [`reparse_edit`](Self::reparse_edit).
228    ///
229    /// # Errors
230    /// Infallible today (tree-sitter returns `None` on failure, stored as-is);
231    /// the `Result` reserves fallibility for future timeout/cancel support.
232    pub fn reparse(&mut self, src: &str) -> Result<()> {
233        self.tree = self.parser.parse(src, None);
234        Ok(())
235    }
236
237    /// Incrementally re-parse after splicing `[start_byte, old_end_byte)` of
238    /// `old_src` to produce `new_src`. Edits the retained tree by the splice
239    /// ([`TsEdit`]) so tree-sitter reuses every unchanged subtree and reparses
240    /// only the affected span — `O(edit)`, not `O(document)`. With no prior tree
241    /// it falls back to a full parse. The result is identical to a full parse of
242    /// `new_src` (the differential-equivalence invariant, tested).
243    ///
244    /// # Errors
245    /// Same as [`reparse`](Self::reparse).
246    pub fn reparse_edit(
247        &mut self,
248        old_src: &str,
249        new_src: &str,
250        start_byte: usize,
251        old_end_byte: usize,
252    ) -> Result<()> {
253        if self.tree.is_some() {
254            let edit = TsEdit::from_splice(old_src, new_src, start_byte, old_end_byte);
255            if let Some(tree) = self.tree.as_mut() {
256                tree.edit(&edit.to_input_edit());
257            }
258            self.tree = self.parser.parse(new_src, self.tree.as_ref());
259        } else {
260            self.tree = self.parser.parse(new_src, None);
261        }
262        Ok(())
263    }
264
265    #[must_use]
266    pub fn tree(&self) -> Option<&Tree> {
267        self.tree.as_ref()
268    }
269}
270
271/// A typed, byte-based description of one contiguous splice, for incremental
272/// tree-sitter reparse. tree-sitter's native unit is the byte offset + a
273/// `(row, byte-column)` point, so this converts from a plain source splice —
274/// no tree-sitter type crosses the caller boundary.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub struct TsEdit {
277    pub start_byte: usize,
278    pub old_end_byte: usize,
279    pub new_end_byte: usize,
280    /// `(row, byte-column)` of the splice start (identical in old + new).
281    pub start_point: (usize, usize),
282    pub old_end_point: (usize, usize),
283    pub new_end_point: (usize, usize),
284}
285
286impl TsEdit {
287    /// Compute the splice turning `old` into `new` by replacing
288    /// `old[start_byte..old_end_byte]`. The unchanged suffix has the same length
289    /// in `new`, so `new_end_byte = new.len() - (old.len() - old_end_byte)`.
290    #[must_use]
291    pub fn from_splice(old: &str, new: &str, start_byte: usize, old_end_byte: usize) -> Self {
292        let new_end_byte = new.len() - (old.len() - old_end_byte);
293        Self {
294            start_byte,
295            old_end_byte,
296            new_end_byte,
297            start_point: byte_to_point(old, start_byte),
298            old_end_point: byte_to_point(old, old_end_byte),
299            new_end_point: byte_to_point(new, new_end_byte),
300        }
301    }
302
303    fn to_input_edit(self) -> InputEdit {
304        let pt = |(row, column): (usize, usize)| Point { row, column };
305        InputEdit {
306            start_byte: self.start_byte,
307            old_end_byte: self.old_end_byte,
308            new_end_byte: self.new_end_byte,
309            start_position: pt(self.start_point),
310            old_end_position: pt(self.old_end_point),
311            new_end_position: pt(self.new_end_point),
312        }
313    }
314}
315
316/// `(row, byte-column)` of `byte` within `text`. tree-sitter point columns are
317/// byte offsets within the line, not char offsets.
318#[must_use]
319fn byte_to_point(text: &str, byte: usize) -> (usize, usize) {
320    let byte = byte.min(text.len());
321    let prefix = &text[..byte];
322    let row = prefix.bytes().filter(|&b| b == b'\n').count();
323    let col = prefix.len() - prefix.rfind('\n').map_or(0, |i| i + 1);
324    (row, col)
325}
326
327// ─────────────────────────── highlight ──────────────────────────
328
329/// A colored text span — byte range + canonical [`Semantic`] bucket.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct HighlightSpan {
332    pub start: usize,
333    pub end: usize,
334    pub semantic: Semantic,
335}
336
337/// Compute highlight spans over `src` using `grammar`.
338///
339/// # Errors
340/// Returns [`TsError::Ts`] if tree-sitter's highlighter errors.
341pub fn highlight(
342    src: &str,
343    grammar: &Grammar,
344    registry: &GrammarRegistry,
345) -> Result<Vec<HighlightSpan>> {
346    let mut highlighter = TsHighlighter::new();
347    let events = highlighter
348        .highlight(&grammar.config, src.as_bytes(), None, |_| None)
349        .map_err(|e| TsError::Ts(e.to_string()))?;
350
351    let mut stack: Vec<usize> = Vec::new();
352    let mut spans: Vec<HighlightSpan> = Vec::new();
353    let mut run_start: Option<(usize, usize)> = None;
354
355    for ev in events {
356        let ev = ev.map_err(|e| TsError::Ts(e.to_string()))?;
357        match ev {
358            HighlightEvent::HighlightStart(h) => stack.push(h.0),
359            HighlightEvent::HighlightEnd => {
360                stack.pop();
361                run_start = None;
362            }
363            HighlightEvent::Source { start, end } => {
364                if let Some(&top) = stack.last() {
365                    let sem = highlight_index_to_semantic(top, &registry.highlight_names);
366                    match run_start {
367                        Some((rs, _)) if rs == start => {}
368                        _ => {
369                            spans.push(HighlightSpan {
370                                start,
371                                end,
372                                semantic: sem,
373                            });
374                            run_start = Some((start, end));
375                        }
376                    }
377                }
378            }
379        }
380    }
381
382    Ok(spans)
383}
384
385/// The canonical highlight-name namespace every grammar is configured against.
386/// Indices into this vector map to [`Semantic`] buckets.
387fn canonical_highlight_names() -> Vec<&'static str> {
388    vec![
389        "keyword",
390        "function",
391        "function.call",
392        "function.method",
393        "type",
394        "type.builtin",
395        "constant",
396        "constant.builtin",
397        "string",
398        "string.special",
399        "number",
400        "boolean",
401        "comment",
402        "operator",
403        "punctuation",
404        "punctuation.bracket",
405        "punctuation.delimiter",
406        "variable",
407        "variable.parameter",
408        "variable.builtin",
409        "attribute",
410        "label",
411        "tag",
412    ]
413}
414
415fn highlight_index_to_semantic(index: usize, names: &[&'static str]) -> Semantic {
416    let name = names.get(index).copied().unwrap_or("");
417    match name {
418        n if n.starts_with("keyword") => Semantic::Keyword,
419        n if n.starts_with("function") => Semantic::Symbol,
420        n if n.starts_with("type") => Semantic::Accent,
421        n if n.starts_with("constant.builtin") || n == "boolean" => Semantic::Literal,
422        n if n.starts_with("constant") => Semantic::Literal,
423        n if n.starts_with("string") => Semantic::String,
424        n if n == "number" => Semantic::Number,
425        n if n.starts_with("comment") => Semantic::Comment,
426        n if n.starts_with("operator") => Semantic::Accent,
427        n if n.starts_with("punctuation") => Semantic::Muted,
428        n if n.starts_with("variable") => Semantic::Symbol,
429        n if n == "attribute" => Semantic::Hint,
430        n if n == "label" => Semantic::Hint,
431        n if n == "tag" => Semantic::Keyword,
432        _ => Semantic::Symbol,
433    }
434}
435
436// ─────────────────────── hikari Highlighter face ────────────────
437
438/// The tree-sitter host — builds the built-in [`GrammarRegistry`] once and hands
439/// out per-language [`TreeSitterHighlighter`]s + a [`LanguagePlugin`] per grammar
440/// for registration into a hikari `Ecosystem`.
441#[derive(Clone)]
442pub struct TreeSitterHost {
443    registry: Arc<GrammarRegistry>,
444}
445
446impl TreeSitterHost {
447    /// Build the built-in grammar registry (Rust today, more as grammars land).
448    ///
449    /// # Errors
450    /// Returns [`TsError`] if a grammar fails to construct.
451    pub fn builtin() -> Result<Self> {
452        Ok(Self {
453            registry: Arc::new(GrammarRegistry::builtin()?),
454        })
455    }
456
457    /// The languages this host can highlight.
458    pub fn languages(&self) -> impl Iterator<Item = &str> {
459        self.registry.languages()
460    }
461
462    /// A [`LanguagePlugin`] for each built-in grammar, ready to register into a
463    /// hikari `Ecosystem` (each claims its grammar's extensions).
464    #[must_use]
465    pub fn plugins(&self) -> Vec<Box<dyn LanguagePlugin>> {
466        let mut out: Vec<Box<dyn LanguagePlugin>> = Vec::new();
467        for name in self.registry.languages() {
468            // interned static name — the grammar set is fixed, so leaking once
469            // is bounded + gives the 'static `Language` newtype hikari expects.
470            let lang: &'static str = Box::leak(name.to_string().into_boxed_str());
471            let selectors: Vec<Selector> = self
472                .registry
473                .get(name)
474                .map(|g| {
475                    g.extensions
476                        .iter()
477                        .map(|e| Selector::Extension(Box::leak(e.clone().into_boxed_str())))
478                        .collect()
479                })
480                .unwrap_or_default();
481            out.push(Box::new(TreeSitterPlugin {
482                language: Language(lang),
483                selectors: selectors.leak(),
484                registry: self.registry.clone(),
485                grammar: lang,
486            }));
487        }
488        out
489    }
490
491    /// A highlighter for one grammar by name.
492    #[must_use]
493    pub fn highlighter(&self, grammar: &'static str) -> TreeSitterHighlighter {
494        TreeSitterHighlighter {
495            registry: self.registry.clone(),
496            grammar,
497        }
498    }
499}
500
501/// A hikari [`LanguagePlugin`] backed by a tree-sitter grammar.
502pub struct TreeSitterPlugin {
503    language: Language,
504    selectors: &'static [Selector],
505    registry: Arc<GrammarRegistry>,
506    grammar: &'static str,
507}
508
509impl LanguagePlugin for TreeSitterPlugin {
510    fn language(&self) -> Language {
511        self.language
512    }
513    fn selectors(&self) -> &'static [Selector] {
514        self.selectors
515    }
516    fn make_highlighter(&self) -> Box<dyn Highlighter> {
517        Box::new(TreeSitterHighlighter {
518            registry: self.registry.clone(),
519            grammar: self.grammar,
520        })
521    }
522}
523
524/// The generic tree-sitter [`Highlighter`]. Whole-document highlight; the result
525/// (gappy `Semantic` spans) is funneled through [`SpanSink`] so the output is a
526/// coverage-complete hikari partition (gaps become `Plain`).
527pub struct TreeSitterHighlighter {
528    registry: Arc<GrammarRegistry>,
529    grammar: &'static str,
530}
531
532impl Highlighter for TreeSitterHighlighter {
533    fn highlight(&self, text: &str) -> Vec<HlSpan> {
534        let len = u32::try_from(text.len()).unwrap_or(u32::MAX);
535        let mut sink = SpanSink::for_document(len);
536        if let Some(grammar) = self.registry.get(self.grammar)
537            && let Ok(spans) = highlight(text, grammar, &self.registry)
538        {
539            for s in spans {
540                // hikari_token::Semantic → HlClass via the total fleet conversion.
541                let class: HlClass = s.semantic.into();
542                sink.push(
543                    u32::try_from(s.start).unwrap_or(u32::MAX),
544                    u32::try_from(s.end).unwrap_or(u32::MAX),
545                    class,
546                );
547            }
548        }
549        sink.finish()
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::{BufferParser, GrammarRegistry, TreeSitterHost, TsEdit, byte_to_point};
556    use hikari_core::{Highlighter, HlClass};
557
558    #[test]
559    fn builtin_host_highlights_rust_with_coverage() {
560        let host = TreeSitterHost::builtin().expect("builtin grammars");
561        let hl = host.highlighter("rust");
562        let src = "fn main() {\n    let x = 42;\n}\n";
563        let spans = hl.highlight(src);
564        // coverage-complete + forward-only (SpanSink guarantees).
565        let mut cursor = 0u32;
566        for s in &spans {
567            assert_eq!(s.span.start, cursor, "gap/overlap at {cursor}");
568            cursor = s.span.end;
569        }
570        assert_eq!(cursor as usize, src.len(), "partition must cover the text");
571        assert!(
572            spans.iter().any(|s| s.class != HlClass::Plain),
573            "tree-sitter should classify something in real Rust",
574        );
575    }
576
577    #[test]
578    fn rust_grammar_is_registered() {
579        let host = TreeSitterHost::builtin().expect("builtin grammars");
580        assert!(host.languages().any(|l| l == "rust"));
581        assert!(!host.plugins().is_empty());
582    }
583
584    #[test]
585    fn byte_to_point_counts_rows_and_byte_columns() {
586        assert_eq!(byte_to_point("abc", 2), (0, 2));
587        assert_eq!(byte_to_point("ab\ncd", 3), (1, 0));
588        assert_eq!(byte_to_point("x\ny\nz", 4), (2, 0));
589    }
590
591    /// M5 seal: an incremental `Tree::edit` reparse equals a full parse.
592    #[test]
593    fn incremental_reparse_equals_full_parse() {
594        let r = GrammarRegistry::builtin().unwrap();
595        let old = "fn main() { let x = 1; }";
596        let new = "fn main() { let x = 42; }";
597        let start = old.find('1').unwrap();
598
599        let mut inc = BufferParser::new("rust", &r).unwrap();
600        inc.reparse(old).unwrap();
601        inc.reparse_edit(old, new, start, start + 1).unwrap();
602
603        let mut full = BufferParser::new("rust", &r).unwrap();
604        full.reparse(new).unwrap();
605
606        assert_eq!(
607            inc.tree().unwrap().root_node().to_sexp(),
608            full.tree().unwrap().root_node().to_sexp(),
609        );
610    }
611
612    #[test]
613    fn ts_edit_new_end_byte_accounts_for_length_delta() {
614        let old = "x = 1;";
615        let new = "x = 42;";
616        let start = old.find('1').unwrap();
617        let e = TsEdit::from_splice(old, new, start, start + 1);
618        assert_eq!(e.new_end_byte, start + 2);
619    }
620}