Skip to main content

hikari_core/
lib.rs

1//! hikari (光) — the pluggable syntax-highlighting spine.
2//!
3//! The fleet-shared foundation for syntax highlighting across every pleme-io
4//! editor (AsterIDE · escriba) and tool. It owns the narrow interface every
5//! language backend lowers to; heavy backends (tree-sitter grammars, the
6//! tatara-lisp macro-generated `(deflexer …)` output) ship as separate
7//! `hikari-*` crates in this workspace so `hikari-core` stays zero-dependency
8//! and any consumer vendors it with no transitive weight.
9//!
10//! The design (a stable trait spine so backends are additive, never rewrites):
11//!
12//!   * ONE narrow-waist output — a coverage-complete, non-overlapping,
13//!     forward-only `Vec<HighlightSpan>` partition produced ONLY through
14//!     [`SpanSink`], which makes gaps / overlaps / reversals structurally
15//!     unrepresentable (the caller cannot fabricate the `Vec`).
16//!   * ONE authored backend trait [`LanguageLexer`] whose associated
17//!     `LineState: Copy + Eq` makes incremental line-restart re-lex a *type
18//!     property* (a consumer may cache per-line and re-lex only until the
19//!     entry state stops changing).
20//!   * ONE object-safe [`Highlighter`] the render layer holds as `Box<dyn>`,
21//!     bridged from any `LanguageLexer` by the blanket [`LineDriven`] adapter.
22//!   * ONE [`Ecosystem`] registry with total, panic-free resolution — an
23//!     unknown extension resolves to [`PLAIN_TEXT`], never a panic, never
24//!     "everything is one language".
25//!   * palette-independent [`HlClass`] + a [`Theme`] mapping class → [`Rgb`]
26//!     so classification and color never entangle (Nord default).
27
28#![forbid(unsafe_code)]
29// The workspace sets `clippy::pedantic = warn`. These arms are intentional in
30// a hand-rolled byte scanner + a fixed palette, so they are allowed with a
31// reason rather than contorted: single-char cursor vars (`i`/`c`/`n`/`s`/`e`)
32// are the idiom for a lexer; byte offsets provably fit `u32` (documents cap at
33// 4 GiB); proper nouns (AsterIDE, pleme-io) aren't code; the resume-match and
34// same-color palette arms read clearer as written.
35#![allow(
36    clippy::many_single_char_names,
37    clippy::cast_possible_truncation,
38    clippy::doc_markdown,
39    clippy::single_match_else,
40    clippy::collapsible_if,
41    clippy::match_same_arms
42)]
43
44// ───────────────────────────── span ─────────────────────────────
45
46/// A byte-offset span into the document. `start <= end` by construction, and
47/// (when produced through [`SpanSink`]) both ends land on UTF-8 char
48/// boundaries.
49#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
50pub struct ByteSpan {
51    pub start: u32,
52    pub end: u32,
53}
54
55impl ByteSpan {
56    /// The sole constructor.
57    ///
58    /// # Panics
59    /// Panics only on a caller bug (`start > end`). The lexer driver never
60    /// constructs a reversed span, so this is unreachable in normal use.
61    #[must_use]
62    pub fn new(start: u32, end: u32) -> Self {
63        assert!(start <= end, "ByteSpan::new: start {start} > end {end}");
64        Self { start, end }
65    }
66
67    #[must_use]
68    pub fn len(&self) -> u32 {
69        self.end - self.start
70    }
71
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.start == self.end
75    }
76
77    /// The span as a `usize` range, for slicing the source text.
78    #[must_use]
79    pub fn range(&self) -> std::ops::Range<usize> {
80        self.start as usize..self.end as usize
81    }
82}
83
84// ───────────────────────────── class ────────────────────────────
85
86/// A palette-independent semantic highlight class. A superset of the classes
87/// egui / tree-sitter / the fleet's `Semantic` enums produce, so every backend
88/// lowers to this one waist and the theme layer maps it to color exactly once.
89#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
90pub enum HlClass {
91    Comment {
92        multiline: bool,
93    },
94    Keyword,
95    /// A `:keyword`-style argument / symbol (lisp `:kw`, etc.).
96    KeywordArg,
97    Type,
98    Function,
99    Namespace,
100    Variable,
101    Constant,
102    Str,
103    Escape,
104    Numeric {
105        float: bool,
106    },
107    Boolean,
108    Punctuation,
109    Operator,
110    Attribute,
111    Special,
112    Hyperlink,
113    Whitespace,
114    Error,
115    /// A diagnostic/severity class (LSP + the fleet `Semantic` collapse).
116    Warning,
117    Info,
118    Hint,
119    /// A diff class (git signs, review UIs).
120    Added,
121    Removed,
122    /// Diff context / normal-fg text in a diff view.
123    Unchanged,
124    /// The default / unclassified class. Coverage gaps are filled with this.
125    Plain,
126}
127
128/// One classified region of the document.
129#[derive(Clone, Copy, PartialEq, Eq, Debug)]
130pub struct HighlightSpan {
131    pub span: ByteSpan,
132    pub class: HlClass,
133}
134
135// ───────────────────────────── sink ─────────────────────────────
136
137/// The ONLY way to produce highlight spans. A forward-only cursor over a
138/// single line: [`push`](SpanSink::push) fills any gap before the pushed
139/// region with [`HlClass::Plain`] and clamps backwards writes, so the emitted
140/// sequence is a coverage-complete, non-overlapping, monotonically-increasing
141/// partition **by construction** — a lexer cannot emit a gap, an overlap, or a
142/// reversal. The backing `Vec` is private; only the driver calls
143/// [`finish`](SpanSink::finish).
144pub struct SpanSink {
145    cursor: u32,
146    line_end: u32,
147    out: Vec<HighlightSpan>,
148}
149
150impl SpanSink {
151    /// A sink covering `[line_start, line_start + line_len)`. Public so a
152    /// direct [`Highlighter`] impl (e.g. a tree-sitter backend) can construct
153    /// one and keep coverage-by-construction, not only the [`LineDriven`]
154    /// bridge.
155    #[must_use]
156    pub fn new(line_start: u32, line_len: u32) -> Self {
157        Self {
158            cursor: line_start,
159            line_end: line_start + line_len,
160            out: Vec::new(),
161        }
162    }
163
164    /// A sink covering a whole document `[0, len)` — for a non-line backend
165    /// that pushes absolute offsets and wants gap-fill for free.
166    #[must_use]
167    pub fn for_document(len: u32) -> Self {
168        Self::new(0, len)
169    }
170
171    /// Classify `[start, end)` (absolute byte offsets) as `class`. A gap
172    /// `[cursor, start)` is filled with [`HlClass::Plain`]; a backwards
173    /// `start` is clamped to the cursor; an empty region is dropped.
174    pub fn push(&mut self, start: u32, end: u32, class: HlClass) {
175        let start = start.max(self.cursor);
176        let end = end.min(self.line_end);
177        if end <= start {
178            return;
179        }
180        if start > self.cursor {
181            self.out.push(HighlightSpan {
182                span: ByteSpan::new(self.cursor, start),
183                class: HlClass::Plain,
184            });
185        }
186        self.out.push(HighlightSpan {
187            span: ByteSpan::new(start, end),
188            class,
189        });
190        self.cursor = end;
191    }
192
193    /// Finish: fill any trailing gap with [`HlClass::Plain`] and return the
194    /// coverage-complete, non-overlapping, forward-only partition.
195    #[must_use]
196    pub fn finish(mut self) -> Vec<HighlightSpan> {
197        if self.cursor < self.line_end {
198            self.out.push(HighlightSpan {
199                span: ByteSpan::new(self.cursor, self.line_end),
200                class: HlClass::Plain,
201            });
202        }
203        self.out
204    }
205}
206
207// ─────────────────────── backend + render traits ────────────────
208
209/// The AUTHORED backend trait — implement this to add a language. One total
210/// method lexes a single line given the cross-line state carried out of the
211/// previous line; returning the state at the line's end. `LineState: Eq` is
212/// what makes incremental re-lex a type property (re-lex stops at the first
213/// line whose entry state is unchanged).
214pub trait LanguageLexer: Send + Sync {
215    type LineState: Copy + Eq + Default + Send + Sync;
216
217    fn lex_line(
218        &self,
219        line: &str,
220        line_start: u32,
221        entry: Self::LineState,
222        sink: &mut SpanSink,
223    ) -> Self::LineState;
224}
225
226/// The object-safe, render-facing trait the editor holds as `Box<dyn>`. The
227/// default [`LineDriven`] bridge re-lexes the whole document; a consumer may
228/// implement `Highlighter` directly for an incremental line cache.
229pub trait Highlighter: Send + Sync {
230    fn highlight(&self, text: &str) -> Vec<HighlightSpan>;
231}
232
233/// The blanket bridge: any [`LanguageLexer`] is a [`Highlighter`]. No backend
234/// author ever hand-writes `Highlighter`.
235pub struct LineDriven<L: LanguageLexer> {
236    pub lexer: L,
237}
238
239impl<L: LanguageLexer> LineDriven<L> {
240    #[must_use]
241    pub fn new(lexer: L) -> Self {
242        Self { lexer }
243    }
244}
245
246impl<L: LanguageLexer> Highlighter for LineDriven<L> {
247    fn highlight(&self, text: &str) -> Vec<HighlightSpan> {
248        let mut out = Vec::new();
249        let mut state = L::LineState::default();
250        let mut offset: u32 = 0;
251        // split_inclusive keeps the trailing '\n' on each line, so offsets are
252        // contiguous and the partition covers every byte of `text`.
253        for line in text.split_inclusive('\n') {
254            let line_len = u32::try_from(line.len()).unwrap_or(u32::MAX);
255            let mut sink = SpanSink::new(offset, line_len);
256            state = self.lexer.lex_line(line, offset, state, &mut sink);
257            out.extend(sink.finish());
258            offset = offset.saturating_add(line_len);
259        }
260        out
261    }
262}
263
264// ───────────────────── incremental line cache ───────────────────
265
266/// The object-safe incremental face the render layer holds as `Box<dyn>`. A
267/// stateful highlighter that reuses prior work: on each call it re-lexes only
268/// from the first content-changed line until the carried entry state
269/// re-converges (the `LineState` fixpoint), reusing every cached line before
270/// and after. Its output is **byte-identical** to the equivalent one-shot
271/// [`Highlighter::highlight`] — incrementality is an optimization, never a
272/// semantic change (the differential-equivalence invariant, fuzz-tested).
273pub trait IncrementalHighlighter: Send + Sync {
274    /// Re-highlight `text`, reusing cached per-line spans where the document
275    /// is unchanged. `&mut self` because the cache advances.
276    fn highlight(&mut self, text: &str) -> Vec<HighlightSpan>;
277
278    /// How many lines the most recent [`highlight`](Self::highlight) call
279    /// actually re-lexed — `0` on a fully-cached (idle re-render) call. The
280    /// seal's idle-work witness: an unchanged document re-lexes nothing.
281    fn last_relexed(&self) -> usize;
282}
283
284/// One cached line: the `LineState` carried *into* it, the state carried *out*
285/// of it, the exact line bytes (incl. trailing `\n`) for the content compare,
286/// and the line's spans stored **line-relative** (0-based) so a length change
287/// above never invalidates them — they are re-based to absolute on emit.
288struct CachedLine<S> {
289    entry: S,
290    exit: S,
291    text: Box<str>,
292    rel_spans: Vec<HighlightSpan>,
293}
294
295/// The incremental re-lex cache over any [`LanguageLexer`]. The `LineState`
296/// fixpoint made operational: re-lexing halts at the first line whose entry
297/// state *and* bytes both match the cache, so an edit costs
298/// `O(changed lines + lines until the carried state re-converges)`, not
299/// `O(document)`.
300///
301/// Zero-dependency by design (hikari-core's invariant): the content compare is
302/// std `str` equality and the cache stores the line bytes. A `hikari-*` sibling
303/// may swap the `Box<str>` for a BLAKE3 row hash to drop the O(document) memory
304/// — that is a strictly-internal change behind this same object-safe trait.
305pub struct LineCache<L: LanguageLexer> {
306    lexer: L,
307    lines: Vec<CachedLine<L::LineState>>,
308    last_relexed: usize,
309}
310
311impl<L: LanguageLexer> LineCache<L> {
312    #[must_use]
313    pub fn new(lexer: L) -> Self {
314        Self {
315            lexer,
316            lines: Vec::new(),
317            last_relexed: 0,
318        }
319    }
320
321    /// Lex one line at base offset 0 (line-relative spans) carrying `entry`.
322    fn lex_relative(&self, line: &str, entry: L::LineState) -> (L::LineState, Vec<HighlightSpan>) {
323        let line_len = u32::try_from(line.len()).unwrap_or(u32::MAX);
324        let mut sink = SpanSink::new(0, line_len);
325        let exit = self.lexer.lex_line(line, 0, entry, &mut sink);
326        (exit, sink.finish())
327    }
328}
329
330impl<L: LanguageLexer> IncrementalHighlighter for LineCache<L> {
331    fn highlight(&mut self, text: &str) -> Vec<HighlightSpan> {
332        let mut next: Vec<CachedLine<L::LineState>> = Vec::new();
333        let mut out: Vec<HighlightSpan> = Vec::new();
334        let mut entry = L::LineState::default();
335        let mut offset: u32 = 0;
336        let mut relexed = 0usize;
337
338        for (i, line) in text.split_inclusive('\n').enumerate() {
339            let line_len = u32::try_from(line.len()).unwrap_or(u32::MAX);
340            // Reuse iff the same-index cached line carried the same entry state
341            // AND holds the same bytes. Both must hold: same bytes with a
342            // different entry state (e.g. a block comment opened above) lexes
343            // differently, and the fixpoint is exactly "entry state matches".
344            let (exit, rel_spans) = match self.lines.get(i) {
345                Some(c) if c.entry == entry && &*c.text == line => (c.exit, c.rel_spans.clone()),
346                _ => {
347                    relexed += 1;
348                    self.lex_relative(line, entry)
349                }
350            };
351
352            // Re-base the line-relative spans to absolute document offsets.
353            for s in &rel_spans {
354                out.push(HighlightSpan {
355                    span: ByteSpan::new(offset + s.span.start, offset + s.span.end),
356                    class: s.class,
357                });
358            }
359            next.push(CachedLine {
360                entry,
361                exit,
362                text: line.into(),
363                rel_spans,
364            });
365            entry = exit;
366            offset = offset.saturating_add(line_len);
367        }
368
369        self.lines = next;
370        self.last_relexed = relexed;
371        out
372    }
373
374    fn last_relexed(&self) -> usize {
375        self.last_relexed
376    }
377}
378
379/// The total fallback: any [`Highlighter`] as an [`IncrementalHighlighter`] by
380/// re-lexing the whole document every call. Correct but not incremental — the
381/// default a backend gets when it does not carry a `LineState` cache (e.g. a
382/// tree-sitter backend that reparses via its own `Tree::edit`).
383pub struct WholeReHighlighter {
384    inner: Box<dyn Highlighter>,
385    last_relexed: usize,
386}
387
388impl WholeReHighlighter {
389    #[must_use]
390    pub fn new(inner: Box<dyn Highlighter>) -> Self {
391        Self {
392            inner,
393            last_relexed: 0,
394        }
395    }
396}
397
398impl IncrementalHighlighter for WholeReHighlighter {
399    fn highlight(&mut self, text: &str) -> Vec<HighlightSpan> {
400        self.last_relexed = text.split_inclusive('\n').count();
401        self.inner.highlight(text)
402    }
403    fn last_relexed(&self) -> usize {
404        self.last_relexed
405    }
406}
407
408// ───────────────────────── plugin + registry ────────────────────
409
410/// A language identity — an interned static name (closed by linkage, no open
411/// enum to keep exhaustive).
412#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
413pub struct Language(pub &'static str);
414
415/// The unclassified fallback language — its highlighter emits everything as
416/// [`HlClass::Plain`].
417pub const PLAIN_TEXT: Language = Language("plaintext");
418
419/// How a plugin claims a document.
420#[derive(Clone, Copy, PartialEq, Eq, Debug)]
421pub enum Selector {
422    /// A file extension without the dot, lowercase (e.g. `"rs"`).
423    Extension(&'static str),
424    /// An exact file name (e.g. `"Cargo.toml"`).
425    Filename(&'static str),
426}
427
428/// A pluggable language backend: identity + how it's selected + its factory.
429pub trait LanguagePlugin: Send + Sync {
430    fn language(&self) -> Language;
431    fn selectors(&self) -> &'static [Selector];
432    fn make_highlighter(&self) -> Box<dyn Highlighter>;
433
434    /// The incremental (line-cached) highlighter for this language. The default
435    /// wraps [`make_highlighter`](Self::make_highlighter) in a
436    /// [`WholeReHighlighter`] (correct, re-lexes the whole document); a
437    /// `LineState`-carrying backend overrides this to return a real
438    /// [`LineCache`] and gains the fixpoint re-lex for free.
439    fn make_incremental(&self) -> Box<dyn IncrementalHighlighter> {
440        Box::new(WholeReHighlighter::new(self.make_highlighter()))
441    }
442}
443
444/// The registry. Total resolution: an unmatched path resolves to
445/// [`PLAIN_TEXT`], never a panic.
446pub struct Ecosystem {
447    plugins: Vec<Box<dyn LanguagePlugin>>,
448}
449
450impl Default for Ecosystem {
451    fn default() -> Self {
452        Self::with_builtins()
453    }
454}
455
456impl Ecosystem {
457    /// An empty registry.
458    #[must_use]
459    pub fn new() -> Self {
460        Self {
461            plugins: Vec::new(),
462        }
463    }
464
465    /// The batteries-included default: every built-in language plugin.
466    #[must_use]
467    pub fn with_builtins() -> Self {
468        let mut eco = Self::new();
469        for p in langs::builtins() {
470            eco.plugins.push(p);
471        }
472        eco
473    }
474
475    pub fn register(&mut self, plugin: Box<dyn LanguagePlugin>) {
476        self.plugins.push(plugin);
477    }
478
479    /// All languages the registry can highlight.
480    #[must_use]
481    pub fn languages(&self) -> Vec<Language> {
482        self.plugins.iter().map(|p| p.language()).collect()
483    }
484
485    /// Resolve a file path to a language. Filename match wins over extension;
486    /// no match resolves to [`PLAIN_TEXT`].
487    #[must_use]
488    pub fn resolve(&self, path: &str) -> Language {
489        let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
490        for p in &self.plugins {
491            for sel in p.selectors() {
492                if let Selector::Filename(f) = sel {
493                    if name.eq_ignore_ascii_case(f) {
494                        return p.language();
495                    }
496                }
497            }
498        }
499        if let Some(ext) = name.rsplit_once('.').map(|(_, e)| e) {
500            for p in &self.plugins {
501                for sel in p.selectors() {
502                    if let Selector::Extension(e) = sel {
503                        if ext.eq_ignore_ascii_case(e) {
504                            return p.language();
505                        }
506                    }
507                }
508            }
509        }
510        PLAIN_TEXT
511    }
512
513    /// A highlighter for a language, or the plain-text highlighter if none is
514    /// registered for it.
515    #[must_use]
516    pub fn highlighter_for(&self, lang: Language) -> Box<dyn Highlighter> {
517        for p in &self.plugins {
518            if p.language() == lang {
519                return p.make_highlighter();
520            }
521        }
522        Box::new(PlainHighlighter)
523    }
524
525    /// The one call an editor needs: path → highlighter.
526    #[must_use]
527    pub fn highlighter_for_path(&self, path: &str) -> Box<dyn Highlighter> {
528        self.highlighter_for(self.resolve(path))
529    }
530
531    /// An **incremental** highlighter for a language, or the plain-text
532    /// fallback. This is the call an editor's render loop holds across frames
533    /// — it re-lexes only what changed.
534    #[must_use]
535    pub fn incremental_highlighter_for(&self, lang: Language) -> Box<dyn IncrementalHighlighter> {
536        for p in &self.plugins {
537            if p.language() == lang {
538                return p.make_incremental();
539            }
540        }
541        Box::new(WholeReHighlighter::new(Box::new(PlainHighlighter)))
542    }
543
544    /// Path → incremental highlighter. The render-loop entry point.
545    #[must_use]
546    pub fn incremental_highlighter_for_path(
547        &self,
548        path: &str,
549    ) -> Box<dyn IncrementalHighlighter> {
550        self.incremental_highlighter_for(self.resolve(path))
551    }
552}
553
554/// The plain-text highlighter: one `Plain` span over the whole text.
555pub struct PlainHighlighter;
556
557impl Highlighter for PlainHighlighter {
558    fn highlight(&self, text: &str) -> Vec<HighlightSpan> {
559        if text.is_empty() {
560            return Vec::new();
561        }
562        vec![HighlightSpan {
563            span: ByteSpan::new(0, u32::try_from(text.len()).unwrap_or(u32::MAX)),
564            class: HlClass::Plain,
565        }]
566    }
567}
568
569// ───────────────────────────── theme ────────────────────────────
570
571/// An 8-bit sRGB color. Palette-independent; the consumer maps it to its own
572/// color type (egui `Color32`, ratatui `Color`, …).
573#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
574pub struct Rgb {
575    pub r: u8,
576    pub g: u8,
577    pub b: u8,
578}
579
580impl Rgb {
581    #[must_use]
582    pub const fn new(r: u8, g: u8, b: u8) -> Self {
583        Self { r, g, b }
584    }
585}
586
587/// Maps a [`HlClass`] to a color. The default is Nord (the fleet look). A
588/// future `hikari-theme` crate will source this from `ishou_tokens`.
589pub trait Theme: Send + Sync {
590    fn color(&self, class: HlClass) -> Rgb;
591}
592
593/// The Nord Polar Night / Snow Storm / Aurora / Frost palette.
594pub struct NordTheme;
595
596impl Theme for NordTheme {
597    fn color(&self, class: HlClass) -> Rgb {
598        // Nord anchors: fg snow #D8DEE9, comment polar #616E88, keyword frost
599        // #81A1C1, string aurora-green #A3BE8C, number aurora-purple #B48EAD,
600        // type frost #8FBCBB, function frost #88C0D0, constant/bool orange
601        // #D08770, punctuation snow #ECEFF4, error red #BF616A.
602        match class {
603            HlClass::Comment { .. } => Rgb::new(0x61, 0x6E, 0x88),
604            HlClass::Keyword => Rgb::new(0x81, 0xA1, 0xC1),
605            HlClass::KeywordArg | HlClass::Attribute => Rgb::new(0xB4, 0x8E, 0xAD),
606            HlClass::Type | HlClass::Namespace => Rgb::new(0x8F, 0xBC, 0xBB),
607            HlClass::Function => Rgb::new(0x88, 0xC0, 0xD0),
608            HlClass::Str => Rgb::new(0xA3, 0xBE, 0x8C),
609            HlClass::Escape | HlClass::Special => Rgb::new(0xEB, 0xCB, 0x8B),
610            HlClass::Numeric { .. } => Rgb::new(0xB4, 0x8E, 0xAD),
611            HlClass::Boolean | HlClass::Constant => Rgb::new(0xD0, 0x87, 0x70),
612            HlClass::Operator => Rgb::new(0x81, 0xA1, 0xC1),
613            HlClass::Punctuation => Rgb::new(0xEC, 0xEF, 0xF4),
614            HlClass::Hyperlink => Rgb::new(0x5E, 0x81, 0xAC),
615            HlClass::Error | HlClass::Removed => Rgb::new(0xBF, 0x61, 0x6A),
616            HlClass::Warning => Rgb::new(0xEB, 0xCB, 0x8B),
617            HlClass::Info => Rgb::new(0x81, 0xA1, 0xC1),
618            HlClass::Hint => Rgb::new(0x5E, 0x81, 0xAC),
619            HlClass::Added => Rgb::new(0xA3, 0xBE, 0x8C),
620            HlClass::Variable | HlClass::Whitespace | HlClass::Unchanged | HlClass::Plain => {
621                Rgb::new(0xD8, 0xDE, 0xE9)
622            }
623        }
624    }
625}
626
627// ─────────────────────── the table-driven backend ───────────────
628
629/// The hand-rolled backend: one char-class scanner parameterized by a
630/// per-language [`LangTable`], covering the common shape of the built-in
631/// languages. Heavier backends (tree-sitter, macro-generated) live in sibling
632/// `hikari-*` crates and satisfy the same [`LanguageLexer`] / [`LanguagePlugin`]
633/// traits.
634pub mod langs {
635    use super::{HlClass, Language, LanguageLexer, LanguagePlugin, LineDriven, Selector, SpanSink};
636
637    /// Per-language lexing table.
638    pub struct LangTable {
639        pub keywords: &'static [&'static str],
640        pub line_comments: &'static [&'static str],
641        pub block_comment: Option<(&'static str, &'static str)>,
642        pub string_delims: &'static [char],
643        /// Treat `:name` tokens as [`HlClass::KeywordArg`] (lisp keywords).
644        pub colon_keywords: bool,
645    }
646
647    /// Cross-line state: continuing a block comment or a string literal.
648    #[derive(Clone, Copy, PartialEq, Eq, Default)]
649    pub enum LineMode {
650        #[default]
651        Normal,
652        InBlockComment,
653        /// Inside a string opened on a previous line; carries the delimiter.
654        InString(char),
655    }
656
657    /// The table lexer.
658    pub struct TableLexer {
659        pub table: &'static LangTable,
660    }
661
662    #[inline]
663    fn is_ident_start(c: char) -> bool {
664        c == '_' || c.is_alphabetic()
665    }
666    #[inline]
667    fn is_ident_continue(c: char) -> bool {
668        c == '_' || c.is_alphanumeric()
669    }
670
671    impl LanguageLexer for TableLexer {
672        type LineState = LineMode;
673
674        #[allow(clippy::too_many_lines)]
675        fn lex_line(
676            &self,
677            line: &str,
678            line_start: u32,
679            entry: LineMode,
680            sink: &mut SpanSink,
681        ) -> LineMode {
682            let t = self.table;
683            let n = line.len();
684            let base = line_start;
685            let push = |sink: &mut SpanSink, s: usize, e: usize, class: HlClass| {
686                sink.push(base + s as u32, base + e as u32, class);
687            };
688            let mut i = 0usize;
689            let mut mode = entry;
690
691            // Resume a continuation from the previous line.
692            match mode {
693                LineMode::InBlockComment => {
694                    if let Some((_, close)) = t.block_comment {
695                        if let Some(rel) = line.find(close) {
696                            let e = rel + close.len();
697                            push(sink, 0, e, HlClass::Comment { multiline: true });
698                            i = e;
699                            mode = LineMode::Normal;
700                        } else {
701                            push(sink, 0, n, HlClass::Comment { multiline: true });
702                            return LineMode::InBlockComment;
703                        }
704                    } else {
705                        mode = LineMode::Normal;
706                    }
707                }
708                LineMode::InString(delim) => {
709                    let e = scan_string_body(line, 0, delim);
710                    match e {
711                        Some(end) => {
712                            push(sink, 0, end, HlClass::Str);
713                            i = end;
714                            mode = LineMode::Normal;
715                        }
716                        None => {
717                            push(sink, 0, n, HlClass::Str);
718                            return LineMode::InString(delim);
719                        }
720                    }
721                }
722                LineMode::Normal => {}
723            }
724
725            let _ = mode;
726            'scan: while i < n {
727                let c = line[i..].chars().next().unwrap();
728                let cl = c.len_utf8();
729
730                // whitespace run
731                if c.is_whitespace() {
732                    let s = i;
733                    while i < n {
734                        let d = line[i..].chars().next().unwrap();
735                        if !d.is_whitespace() {
736                            break;
737                        }
738                        i += d.len_utf8();
739                    }
740                    push(sink, s, i, HlClass::Whitespace);
741                    continue 'scan;
742                }
743
744                // line comments
745                for lc in t.line_comments {
746                    if line[i..].starts_with(lc) {
747                        push(sink, i, n, HlClass::Comment { multiline: false });
748                        i = n;
749                        continue 'scan;
750                    }
751                }
752
753                // block comment open
754                if let Some((open, close)) = t.block_comment {
755                    if line[i..].starts_with(open) {
756                        if let Some(rel) = line[i + open.len()..].find(close) {
757                            let e = i + open.len() + rel + close.len();
758                            push(sink, i, e, HlClass::Comment { multiline: true });
759                            i = e;
760                            continue 'scan;
761                        }
762                        push(sink, i, n, HlClass::Comment { multiline: true });
763                        return LineMode::InBlockComment;
764                    }
765                }
766
767                // string literal
768                if t.string_delims.contains(&c) {
769                    match scan_string_body(line, i + cl, c) {
770                        Some(end) => {
771                            push(sink, i, end, HlClass::Str);
772                            i = end;
773                            continue 'scan;
774                        }
775                        None => {
776                            push(sink, i, n, HlClass::Str);
777                            return LineMode::InString(c);
778                        }
779                    }
780                }
781
782                // number
783                if c.is_ascii_digit() {
784                    let s = i;
785                    let mut is_float = false;
786                    i += cl;
787                    while i < n {
788                        let d = line[i..].chars().next().unwrap();
789                        if d.is_ascii_alphanumeric() || d == '_' {
790                            i += d.len_utf8();
791                        } else if d == '.' {
792                            is_float = true;
793                            i += 1;
794                        } else {
795                            break;
796                        }
797                    }
798                    push(sink, s, i, HlClass::Numeric { float: is_float });
799                    continue 'scan;
800                }
801
802                // colon keyword (lisp `:name`)
803                if t.colon_keywords && c == ':' && i + 1 < n {
804                    let next = line[i + 1..].chars().next().unwrap();
805                    if is_ident_start(next) {
806                        let s = i;
807                        i += 1;
808                        while i < n {
809                            let d = line[i..].chars().next().unwrap();
810                            if !is_ident_continue(d) {
811                                break;
812                            }
813                            i += d.len_utf8();
814                        }
815                        push(sink, s, i, HlClass::KeywordArg);
816                        continue 'scan;
817                    }
818                }
819
820                // identifier / keyword
821                if is_ident_start(c) {
822                    let s = i;
823                    i += cl;
824                    while i < n {
825                        let d = line[i..].chars().next().unwrap();
826                        if !is_ident_continue(d) {
827                            break;
828                        }
829                        i += d.len_utf8();
830                    }
831                    let word = &line[s..i];
832                    let class = if t.keywords.contains(&word) {
833                        HlClass::Keyword
834                    } else if matches!(
835                        word,
836                        "true" | "false" | "True" | "False" | "None" | "nil" | "null"
837                    ) {
838                        HlClass::Boolean
839                    } else if word.chars().next().is_some_and(char::is_uppercase) {
840                        HlClass::Type
841                    } else {
842                        HlClass::Variable
843                    };
844                    push(sink, s, i, class);
845                    continue 'scan;
846                }
847
848                // punctuation / operator (single char)
849                let class = if "+-*/%=<>!&|^~".contains(c) {
850                    HlClass::Operator
851                } else {
852                    HlClass::Punctuation
853                };
854                push(sink, i, i + cl, class);
855                i += cl;
856            }
857
858            LineMode::Normal
859        }
860    }
861
862    /// Scan a string body starting at `from` (just after the opening delim),
863    /// honoring `\` escapes. Returns the byte index just past the closing
864    /// delim, or `None` if the line ends first (string continues).
865    fn scan_string_body(line: &str, from: usize, delim: char) -> Option<usize> {
866        let n = line.len();
867        let mut i = from;
868        while i < n {
869            let c = line[i..].chars().next().unwrap();
870            let cl = c.len_utf8();
871            if c == '\\' && i + cl < n {
872                let e = line[i + cl..].chars().next().unwrap();
873                i += cl + e.len_utf8();
874                continue;
875            }
876            i += cl;
877            if c == delim {
878                return Some(i);
879            }
880        }
881        None
882    }
883
884    /// A [`LanguagePlugin`] backed by a static [`LangTable`].
885    pub struct TablePlugin {
886        pub language: Language,
887        pub selectors: &'static [Selector],
888        pub table: &'static LangTable,
889    }
890
891    impl LanguagePlugin for TablePlugin {
892        fn language(&self) -> Language {
893            self.language
894        }
895        fn selectors(&self) -> &'static [Selector] {
896            self.selectors
897        }
898        fn make_highlighter(&self) -> Box<dyn super::Highlighter> {
899            Box::new(LineDriven::new(TableLexer { table: self.table }))
900        }
901        fn make_incremental(&self) -> Box<dyn super::IncrementalHighlighter> {
902            // A real LineState-carrying cache — the table backend gains the
903            // fixpoint re-lex, not the whole-document fallback.
904            Box::new(super::LineCache::new(TableLexer { table: self.table }))
905        }
906    }
907
908    // ── the built-in language tables ──
909
910    static RUST_KW: &[&str] = &[
911        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
912        "extern", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut",
913        "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "type",
914        "unsafe", "use", "where", "while",
915    ];
916    static RUST_TABLE: LangTable = LangTable {
917        keywords: RUST_KW,
918        line_comments: &["//"],
919        block_comment: Some(("/*", "*/")),
920        string_delims: &['"'],
921        colon_keywords: false,
922    };
923    static RUST_SEL: &[Selector] = &[Selector::Extension("rs")];
924
925    static PY_KW: &[&str] = &[
926        "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
927        "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
928        "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with",
929        "yield",
930    ];
931    static PY_TABLE: LangTable = LangTable {
932        keywords: PY_KW,
933        line_comments: &["#"],
934        block_comment: None,
935        string_delims: &['"', '\''],
936        colon_keywords: false,
937    };
938    static PY_SEL: &[Selector] = &[Selector::Extension("py")];
939
940    // tatara-lisp is the fleet's own dialect, so its high-frequency heads earn
941    // their place here alongside the generic Lisp ones. Counts are corpus
942    // occurrences over the 1121 .tlisp/.lisp files under ~/code/github/pleme-io
943    // (measured 2026-07-29): `define` 2395, `defsubcmd` 671, `defalias` 583,
944    // `defcaixa` 274. The evaluator's special forms (`define`, `lambda`, `let`,
945    // `if`, `cond`, `quote`, `set!`, `begin`) can never come from the domain
946    // registry — they are a closed hardcoded match in
947    // tatara-lisp-eval/src/special.rs — so a table backend is exactly the right
948    // home for them.
949    static LISP_KW: &[&str] = &[
950        "def", "defn", "defmacro", "defcaixa", "deflexer", "define", "defun", "defmodule",
951        "defsuite", "deftest", "let", "let*", "letrec", "lambda", "fn", "if", "cond", "case",
952        "when", "unless", "do", "begin", "quote", "quasiquote", "unquote", "set!", "and", "or",
953        "not", "import", "importar",
954    ];
955    static LISP_TABLE: LangTable = LangTable {
956        keywords: LISP_KW,
957        line_comments: &[";"],
958        block_comment: Some(("#|", "|#")),
959        string_delims: &['"'],
960        colon_keywords: true,
961    };
962    static LISP_SEL: &[Selector] = &[
963        Selector::Extension("lisp"),
964        // `.tlisp` is tatara-lisp's script extension — 571 files in the fleet,
965        // every one of which resolved to NO language before this line, so the
966        // editor painted them as plain text. The omission was invisible
967        // because `.lisp` (550 files) did resolve.
968        Selector::Extension("tlisp"),
969        Selector::Extension("lsp"),
970        Selector::Extension("el"),
971        Selector::Extension("scm"),
972    ];
973
974    static JSON_TABLE: LangTable = LangTable {
975        keywords: &["true", "false", "null"],
976        line_comments: &[],
977        block_comment: None,
978        string_delims: &['"'],
979        colon_keywords: false,
980    };
981    static JSON_SEL: &[Selector] = &[Selector::Extension("json")];
982
983    static TOML_TABLE: LangTable = LangTable {
984        keywords: &["true", "false"],
985        line_comments: &["#"],
986        block_comment: None,
987        string_delims: &['"', '\''],
988        colon_keywords: false,
989    };
990    static TOML_SEL: &[Selector] = &[
991        Selector::Extension("toml"),
992        Selector::Filename("Cargo.lock"),
993    ];
994
995    static MD_TABLE: LangTable = LangTable {
996        keywords: &[],
997        line_comments: &[],
998        block_comment: None,
999        string_delims: &['`'],
1000        colon_keywords: false,
1001    };
1002    static MD_SEL: &[Selector] = &[Selector::Extension("md"), Selector::Extension("markdown")];
1003
1004    /// Every built-in language plugin (the batteries-included default set).
1005    #[must_use]
1006    pub fn builtins() -> Vec<Box<dyn LanguagePlugin>> {
1007        vec![
1008            Box::new(TablePlugin {
1009                language: Language("rust"),
1010                selectors: RUST_SEL,
1011                table: &RUST_TABLE,
1012            }),
1013            Box::new(TablePlugin {
1014                language: Language("python"),
1015                selectors: PY_SEL,
1016                table: &PY_TABLE,
1017            }),
1018            Box::new(TablePlugin {
1019                language: Language("lisp"),
1020                selectors: LISP_SEL,
1021                table: &LISP_TABLE,
1022            }),
1023            Box::new(TablePlugin {
1024                language: Language("json"),
1025                selectors: JSON_SEL,
1026                table: &JSON_TABLE,
1027            }),
1028            Box::new(TablePlugin {
1029                language: Language("toml"),
1030                selectors: TOML_SEL,
1031                table: &TOML_TABLE,
1032            }),
1033            Box::new(TablePlugin {
1034                language: Language("markdown"),
1035                selectors: MD_SEL,
1036                table: &MD_TABLE,
1037            }),
1038        ]
1039    }
1040}
1041
1042// ───────────────────────────── tests ────────────────────────────
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    fn covers(text: &str, spans: &[HighlightSpan]) {
1049        // Coverage-complete + non-overlapping + forward-only by construction.
1050        let mut cursor = 0u32;
1051        for s in spans {
1052            assert_eq!(s.span.start, cursor, "gap/overlap at {cursor}");
1053            assert!(s.span.end > s.span.start);
1054            cursor = s.span.end;
1055        }
1056        assert_eq!(cursor as usize, text.len(), "partition does not cover text");
1057    }
1058
1059    #[test]
1060    fn partition_is_coverage_complete() {
1061        let eco = Ecosystem::with_builtins();
1062        for (path, src) in [
1063            ("a.rs", "fn main() {\n    let x = 42; // hi\n}\n"),
1064            ("b.py", "def f(x):\n    return \"s\"  # c\n"),
1065            ("c.lisp", "(defcaixa :name \"x\" 42) ; c\n"),
1066            ("c.tlisp", "(define f (lambda (x) \"s\")) ; c\n"),
1067            ("d.txt", "no language here\n"),
1068        ] {
1069            let h = eco.highlighter_for_path(path);
1070            let spans = h.highlight(src);
1071            covers(src, &spans);
1072        }
1073    }
1074
1075    #[test]
1076    fn resolves_by_extension_not_always_rust() {
1077        let eco = Ecosystem::with_builtins();
1078        assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
1079        assert_eq!(eco.resolve("app.py"), Language("python"));
1080        assert_eq!(eco.resolve("x.lisp"), Language("lisp"));
1081        // tatara-lisp's script extension. 571 fleet files resolved to no
1082        // language at all before this was added; pin it so it cannot regress.
1083        assert_eq!(eco.resolve("x.tlisp"), Language("lisp"));
1084        assert_eq!(eco.resolve("tools/check.tlisp"), Language("lisp"));
1085        assert_eq!(eco.resolve("Cargo.lock"), Language("toml"));
1086        // The bug: a non-Rust file must NOT resolve to rust.
1087        assert_eq!(eco.resolve("notes.txt"), PLAIN_TEXT);
1088        assert_ne!(eco.resolve("app.py"), Language("rust"));
1089    }
1090
1091    #[test]
1092    fn rust_keyword_is_classified() {
1093        let eco = Ecosystem::with_builtins();
1094        let spans = eco.highlighter_for_path("a.rs").highlight("fn x");
1095        assert_eq!(spans[0].class, HlClass::Keyword); // `fn`
1096    }
1097
1098    #[test]
1099    fn multiline_string_and_block_comment_thread_state() {
1100        let eco = Ecosystem::with_builtins();
1101        let spans = eco.highlighter_for_path("a.rs").highlight("/* a\nb */ x\n");
1102        covers("/* a\nb */ x\n", &spans);
1103        assert!(matches!(
1104            spans[0].class,
1105            HlClass::Comment { multiline: true }
1106        ));
1107    }
1108
1109    #[test]
1110    fn plain_text_is_one_plain_span() {
1111        let h = PlainHighlighter;
1112        let spans = h.highlight("hello");
1113        assert_eq!(spans.len(), 1);
1114        assert_eq!(spans[0].class, HlClass::Plain);
1115    }
1116
1117    // ── incremental line cache (the LineState-fixpoint seal) ──
1118
1119    /// A tiny deterministic LCG so the differential fuzz is reproducible
1120    /// without a `rand` dependency (hikari-core is zero-dep).
1121    fn lcg(state: &mut u64) -> u64 {
1122        *state = state
1123            .wrapping_mul(6_364_136_223_846_793_005)
1124            .wrapping_add(1_442_695_040_888_963_407);
1125        *state >> 33
1126    }
1127
1128    /// S5 seal — the load-bearing invariant: an incremental re-lex is
1129    /// byte-identical to a one-shot re-lex, for every edit. Differential fuzz
1130    /// over random insert/delete edits against a Rust-ish corpus.
1131    #[test]
1132    fn incremental_is_byte_identical_to_one_shot() {
1133        let eco = Ecosystem::with_builtins();
1134        let one_shot = eco.highlighter_for_path("f.rs");
1135        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1136
1137        let alphabet: Vec<char> = "fn xy=42;{}\n/*/ \"ab\"//c".chars().collect();
1138        let mut text = String::from("fn main() {\n    let x = 1;\n}\n");
1139        let mut seed = 0x1234_5678_9abc_def0u64;
1140
1141        for _ in 0..400 {
1142            // Random edit: insert a char or delete one, at a random boundary.
1143            let len = text.chars().count();
1144            let at = if len == 0 {
1145                0
1146            } else {
1147                (lcg(&mut seed) as usize) % (len + 1)
1148            };
1149            let byte_at = text.char_indices().nth(at).map_or(text.len(), |(b, _)| b);
1150            if len > 4 && lcg(&mut seed) % 2 == 0 {
1151                // delete one char
1152                if let Some((b, c)) = text[byte_at..].char_indices().next() {
1153                    let start = byte_at + b;
1154                    text.replace_range(start..start + c.len_utf8(), "");
1155                }
1156            } else {
1157                let c = alphabet[(lcg(&mut seed) as usize) % alphabet.len()];
1158                text.insert(byte_at, c);
1159            }
1160
1161            let inc = cache.highlight(&text);
1162            let full = one_shot.highlight(&text);
1163            assert_eq!(inc, full, "incremental != one-shot for {text:?}");
1164            covers(&text, &inc);
1165        }
1166    }
1167
1168    /// S6 seal — an unchanged document re-lexes NOTHING on the second call.
1169    #[test]
1170    fn idle_rehighlight_relexes_zero_lines() {
1171        let eco = Ecosystem::with_builtins();
1172        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1173        let text = "fn a() {}\nfn b() {}\nfn c() {}\n";
1174        let _ = cache.highlight(text);
1175        let _ = cache.highlight(text); // idle re-render
1176        assert_eq!(cache.last_relexed(), 0, "idle re-render must re-lex nothing");
1177    }
1178
1179    /// The fixpoint: a one-line edit re-lexes only the lines up to where the
1180    /// carried `LineState` re-converges — here, a leaf edit re-lexes just its
1181    /// own line, not the whole 60-line document.
1182    #[test]
1183    fn single_line_edit_relexes_locally() {
1184        let eco = Ecosystem::with_builtins();
1185        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1186        let mut text = String::new();
1187        for i in 0..60 {
1188            text.push_str(&format!("let v{i} = {i};\n"));
1189        }
1190        let _ = cache.highlight(&text); // prime: 60 lines lexed
1191        // Edit line 30's value only (no block-comment/string state crosses).
1192        let edited = text.replacen("let v30 = 30;", "let v30 = 999;", 1);
1193        let _ = cache.highlight(&edited);
1194        assert_eq!(
1195            cache.last_relexed(),
1196            1,
1197            "a local edit must re-lex exactly its own line (state re-converges immediately)"
1198        );
1199    }
1200
1201    /// A block comment opened mid-document propagates: re-lex continues past
1202    /// the edited line until the carried state re-converges (here, the `*/`).
1203    #[test]
1204    fn cross_line_state_change_propagates_then_converges() {
1205        let eco = Ecosystem::with_builtins();
1206        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1207        let text = "let a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n";
1208        let _ = cache.highlight(text);
1209        // Open a block comment on line 0 that closes on line 2.
1210        let edited = "let a = 1; /*\nstill comment\n*/ let c = 3;\nlet d = 4;\n";
1211        let inc = cache.highlight(edited);
1212        assert_eq!(inc, eco.highlighter_for_path("f.rs").highlight(edited));
1213        // Lines 0..=2 re-lexed (state in flight); line 3 reused (state reconverged).
1214        assert!(
1215            cache.last_relexed() <= 3,
1216            "re-lex must stop once the block comment closes and state reconverges"
1217        );
1218    }
1219}