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(&self, path: &str) -> Box<dyn IncrementalHighlighter> {
547        self.incremental_highlighter_for(self.resolve(path))
548    }
549}
550
551/// The plain-text highlighter: one `Plain` span over the whole text.
552pub struct PlainHighlighter;
553
554impl Highlighter for PlainHighlighter {
555    fn highlight(&self, text: &str) -> Vec<HighlightSpan> {
556        if text.is_empty() {
557            return Vec::new();
558        }
559        vec![HighlightSpan {
560            span: ByteSpan::new(0, u32::try_from(text.len()).unwrap_or(u32::MAX)),
561            class: HlClass::Plain,
562        }]
563    }
564}
565
566// ───────────────────────────── theme ────────────────────────────
567
568/// An 8-bit sRGB color. Palette-independent; the consumer maps it to its own
569/// color type (egui `Color32`, ratatui `Color`, …).
570#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
571pub struct Rgb {
572    pub r: u8,
573    pub g: u8,
574    pub b: u8,
575}
576
577impl Rgb {
578    #[must_use]
579    pub const fn new(r: u8, g: u8, b: u8) -> Self {
580        Self { r, g, b }
581    }
582}
583
584/// Maps a [`HlClass`] to a color. The default is Nord (the fleet look). A
585/// future `hikari-theme` crate will source this from `ishou_tokens`.
586pub trait Theme: Send + Sync {
587    fn color(&self, class: HlClass) -> Rgb;
588}
589
590/// The Nord Polar Night / Snow Storm / Aurora / Frost palette.
591pub struct NordTheme;
592
593impl Theme for NordTheme {
594    fn color(&self, class: HlClass) -> Rgb {
595        // Nord anchors: fg snow #D8DEE9, comment polar #616E88, keyword frost
596        // #81A1C1, string aurora-green #A3BE8C, number aurora-purple #B48EAD,
597        // type frost #8FBCBB, function frost #88C0D0, constant/bool orange
598        // #D08770, punctuation snow #ECEFF4, error red #BF616A.
599        match class {
600            HlClass::Comment { .. } => Rgb::new(0x61, 0x6E, 0x88),
601            HlClass::Keyword => Rgb::new(0x81, 0xA1, 0xC1),
602            HlClass::KeywordArg | HlClass::Attribute => Rgb::new(0xB4, 0x8E, 0xAD),
603            HlClass::Type | HlClass::Namespace => Rgb::new(0x8F, 0xBC, 0xBB),
604            HlClass::Function => Rgb::new(0x88, 0xC0, 0xD0),
605            HlClass::Str => Rgb::new(0xA3, 0xBE, 0x8C),
606            HlClass::Escape | HlClass::Special => Rgb::new(0xEB, 0xCB, 0x8B),
607            HlClass::Numeric { .. } => Rgb::new(0xB4, 0x8E, 0xAD),
608            HlClass::Boolean | HlClass::Constant => Rgb::new(0xD0, 0x87, 0x70),
609            HlClass::Operator => Rgb::new(0x81, 0xA1, 0xC1),
610            HlClass::Punctuation => Rgb::new(0xEC, 0xEF, 0xF4),
611            HlClass::Hyperlink => Rgb::new(0x5E, 0x81, 0xAC),
612            HlClass::Error | HlClass::Removed => Rgb::new(0xBF, 0x61, 0x6A),
613            HlClass::Warning => Rgb::new(0xEB, 0xCB, 0x8B),
614            HlClass::Info => Rgb::new(0x81, 0xA1, 0xC1),
615            HlClass::Hint => Rgb::new(0x5E, 0x81, 0xAC),
616            HlClass::Added => Rgb::new(0xA3, 0xBE, 0x8C),
617            HlClass::Variable | HlClass::Whitespace | HlClass::Unchanged | HlClass::Plain => {
618                Rgb::new(0xD8, 0xDE, 0xE9)
619            }
620        }
621    }
622}
623
624// ─────────────────────── the table-driven backend ───────────────
625
626/// The hand-rolled backend: one char-class scanner parameterized by a
627/// per-language [`LangTable`], covering the common shape of the built-in
628/// languages. Heavier backends (tree-sitter, macro-generated) live in sibling
629/// `hikari-*` crates and satisfy the same [`LanguageLexer`] / [`LanguagePlugin`]
630/// traits.
631pub mod langs {
632    use super::{HlClass, Language, LanguageLexer, LanguagePlugin, LineDriven, Selector, SpanSink};
633
634    /// Per-language lexing table.
635    pub struct LangTable {
636        pub keywords: &'static [&'static str],
637        pub line_comments: &'static [&'static str],
638        pub block_comment: Option<(&'static str, &'static str)>,
639        pub string_delims: &'static [char],
640        /// Treat `:name` tokens as [`HlClass::KeywordArg`] (lisp keywords).
641        pub colon_keywords: bool,
642    }
643
644    /// Cross-line state: continuing a block comment or a string literal.
645    #[derive(Clone, Copy, PartialEq, Eq, Default)]
646    pub enum LineMode {
647        #[default]
648        Normal,
649        InBlockComment,
650        /// Inside a string opened on a previous line; carries the delimiter.
651        InString(char),
652    }
653
654    /// The table lexer.
655    pub struct TableLexer {
656        pub table: &'static LangTable,
657    }
658
659    #[inline]
660    fn is_ident_start(c: char) -> bool {
661        c == '_' || c.is_alphabetic()
662    }
663    #[inline]
664    fn is_ident_continue(c: char) -> bool {
665        c == '_' || c.is_alphanumeric()
666    }
667
668    impl LanguageLexer for TableLexer {
669        type LineState = LineMode;
670
671        #[allow(clippy::too_many_lines)]
672        fn lex_line(
673            &self,
674            line: &str,
675            line_start: u32,
676            entry: LineMode,
677            sink: &mut SpanSink,
678        ) -> LineMode {
679            let t = self.table;
680            let n = line.len();
681            let base = line_start;
682            let push = |sink: &mut SpanSink, s: usize, e: usize, class: HlClass| {
683                sink.push(base + s as u32, base + e as u32, class);
684            };
685            let mut i = 0usize;
686            let mut mode = entry;
687
688            // Resume a continuation from the previous line.
689            match mode {
690                LineMode::InBlockComment => {
691                    if let Some((_, close)) = t.block_comment {
692                        if let Some(rel) = line.find(close) {
693                            let e = rel + close.len();
694                            push(sink, 0, e, HlClass::Comment { multiline: true });
695                            i = e;
696                            mode = LineMode::Normal;
697                        } else {
698                            push(sink, 0, n, HlClass::Comment { multiline: true });
699                            return LineMode::InBlockComment;
700                        }
701                    } else {
702                        mode = LineMode::Normal;
703                    }
704                }
705                LineMode::InString(delim) => {
706                    let e = scan_string_body(line, 0, delim);
707                    match e {
708                        Some(end) => {
709                            push(sink, 0, end, HlClass::Str);
710                            i = end;
711                            mode = LineMode::Normal;
712                        }
713                        None => {
714                            push(sink, 0, n, HlClass::Str);
715                            return LineMode::InString(delim);
716                        }
717                    }
718                }
719                LineMode::Normal => {}
720            }
721
722            let _ = mode;
723            'scan: while i < n {
724                let c = line[i..].chars().next().unwrap();
725                let cl = c.len_utf8();
726
727                // whitespace run
728                if c.is_whitespace() {
729                    let s = i;
730                    while i < n {
731                        let d = line[i..].chars().next().unwrap();
732                        if !d.is_whitespace() {
733                            break;
734                        }
735                        i += d.len_utf8();
736                    }
737                    push(sink, s, i, HlClass::Whitespace);
738                    continue 'scan;
739                }
740
741                // line comments
742                for lc in t.line_comments {
743                    if line[i..].starts_with(lc) {
744                        push(sink, i, n, HlClass::Comment { multiline: false });
745                        i = n;
746                        continue 'scan;
747                    }
748                }
749
750                // block comment open
751                if let Some((open, close)) = t.block_comment {
752                    if line[i..].starts_with(open) {
753                        if let Some(rel) = line[i + open.len()..].find(close) {
754                            let e = i + open.len() + rel + close.len();
755                            push(sink, i, e, HlClass::Comment { multiline: true });
756                            i = e;
757                            continue 'scan;
758                        }
759                        push(sink, i, n, HlClass::Comment { multiline: true });
760                        return LineMode::InBlockComment;
761                    }
762                }
763
764                // string literal
765                if t.string_delims.contains(&c) {
766                    match scan_string_body(line, i + cl, c) {
767                        Some(end) => {
768                            push(sink, i, end, HlClass::Str);
769                            i = end;
770                            continue 'scan;
771                        }
772                        None => {
773                            push(sink, i, n, HlClass::Str);
774                            return LineMode::InString(c);
775                        }
776                    }
777                }
778
779                // number
780                if c.is_ascii_digit() {
781                    let s = i;
782                    let mut is_float = false;
783                    i += cl;
784                    while i < n {
785                        let d = line[i..].chars().next().unwrap();
786                        if d.is_ascii_alphanumeric() || d == '_' {
787                            i += d.len_utf8();
788                        } else if d == '.' {
789                            is_float = true;
790                            i += 1;
791                        } else {
792                            break;
793                        }
794                    }
795                    push(sink, s, i, HlClass::Numeric { float: is_float });
796                    continue 'scan;
797                }
798
799                // colon keyword (lisp `:name`)
800                if t.colon_keywords && c == ':' && i + 1 < n {
801                    let next = line[i + 1..].chars().next().unwrap();
802                    if is_ident_start(next) {
803                        let s = i;
804                        i += 1;
805                        while i < n {
806                            let d = line[i..].chars().next().unwrap();
807                            if !is_ident_continue(d) {
808                                break;
809                            }
810                            i += d.len_utf8();
811                        }
812                        push(sink, s, i, HlClass::KeywordArg);
813                        continue 'scan;
814                    }
815                }
816
817                // identifier / keyword
818                if is_ident_start(c) {
819                    let s = i;
820                    i += cl;
821                    while i < n {
822                        let d = line[i..].chars().next().unwrap();
823                        if !is_ident_continue(d) {
824                            break;
825                        }
826                        i += d.len_utf8();
827                    }
828                    let word = &line[s..i];
829                    let class = if t.keywords.contains(&word) {
830                        HlClass::Keyword
831                    } else if matches!(
832                        word,
833                        "true" | "false" | "True" | "False" | "None" | "nil" | "null"
834                    ) {
835                        HlClass::Boolean
836                    } else if word.chars().next().is_some_and(char::is_uppercase) {
837                        HlClass::Type
838                    } else {
839                        HlClass::Variable
840                    };
841                    push(sink, s, i, class);
842                    continue 'scan;
843                }
844
845                // punctuation / operator (single char)
846                let class = if "+-*/%=<>!&|^~".contains(c) {
847                    HlClass::Operator
848                } else {
849                    HlClass::Punctuation
850                };
851                push(sink, i, i + cl, class);
852                i += cl;
853            }
854
855            LineMode::Normal
856        }
857    }
858
859    /// Scan a string body starting at `from` (just after the opening delim),
860    /// honoring `\` escapes. Returns the byte index just past the closing
861    /// delim, or `None` if the line ends first (string continues).
862    fn scan_string_body(line: &str, from: usize, delim: char) -> Option<usize> {
863        let n = line.len();
864        let mut i = from;
865        while i < n {
866            let c = line[i..].chars().next().unwrap();
867            let cl = c.len_utf8();
868            if c == '\\' && i + cl < n {
869                let e = line[i + cl..].chars().next().unwrap();
870                i += cl + e.len_utf8();
871                continue;
872            }
873            i += cl;
874            if c == delim {
875                return Some(i);
876            }
877        }
878        None
879    }
880
881    /// A [`LanguagePlugin`] backed by a static [`LangTable`].
882    pub struct TablePlugin {
883        pub language: Language,
884        pub selectors: &'static [Selector],
885        pub table: &'static LangTable,
886    }
887
888    impl LanguagePlugin for TablePlugin {
889        fn language(&self) -> Language {
890            self.language
891        }
892        fn selectors(&self) -> &'static [Selector] {
893            self.selectors
894        }
895        fn make_highlighter(&self) -> Box<dyn super::Highlighter> {
896            Box::new(LineDriven::new(TableLexer { table: self.table }))
897        }
898        fn make_incremental(&self) -> Box<dyn super::IncrementalHighlighter> {
899            // A real LineState-carrying cache — the table backend gains the
900            // fixpoint re-lex, not the whole-document fallback.
901            Box::new(super::LineCache::new(TableLexer { table: self.table }))
902        }
903    }
904
905    // ── the built-in language tables ──
906
907    static RUST_KW: &[&str] = &[
908        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
909        "extern", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut",
910        "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "type",
911        "unsafe", "use", "where", "while",
912    ];
913    static RUST_TABLE: LangTable = LangTable {
914        keywords: RUST_KW,
915        line_comments: &["//"],
916        block_comment: Some(("/*", "*/")),
917        string_delims: &['"'],
918        colon_keywords: false,
919    };
920    static RUST_SEL: &[Selector] = &[Selector::Extension("rs")];
921
922    static PY_KW: &[&str] = &[
923        "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
924        "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
925        "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with",
926        "yield",
927    ];
928    static PY_TABLE: LangTable = LangTable {
929        keywords: PY_KW,
930        line_comments: &["#"],
931        block_comment: None,
932        string_delims: &['"', '\''],
933        colon_keywords: false,
934    };
935    static PY_SEL: &[Selector] = &[Selector::Extension("py")];
936
937    // tatara-lisp is the fleet's own dialect, so its high-frequency heads earn
938    // their place here alongside the generic Lisp ones. Counts are corpus
939    // occurrences over the 1121 .tlisp/.lisp files under ~/code/github/pleme-io
940    // (measured 2026-07-29): `define` 2395, `defsubcmd` 671, `defalias` 583,
941    // `defcaixa` 274. The evaluator's special forms (`define`, `lambda`, `let`,
942    // `if`, `cond`, `quote`, `set!`, `begin`) can never come from the domain
943    // registry — they are a closed hardcoded match in
944    // tatara-lisp-eval/src/special.rs — so a table backend is exactly the right
945    // home for them.
946    static LISP_KW: &[&str] = &[
947        "def",
948        "defn",
949        "defmacro",
950        "defcaixa",
951        "deflexer",
952        "define",
953        "defun",
954        "defmodule",
955        "defsuite",
956        "deftest",
957        "let",
958        "let*",
959        "letrec",
960        "lambda",
961        "fn",
962        "if",
963        "cond",
964        "case",
965        "when",
966        "unless",
967        "do",
968        "begin",
969        "quote",
970        "quasiquote",
971        "unquote",
972        "set!",
973        "and",
974        "or",
975        "not",
976        "import",
977        "importar",
978    ];
979    static LISP_TABLE: LangTable = LangTable {
980        keywords: LISP_KW,
981        line_comments: &[";"],
982        block_comment: Some(("#|", "|#")),
983        string_delims: &['"'],
984        colon_keywords: true,
985    };
986    static LISP_SEL: &[Selector] = &[
987        Selector::Extension("lisp"),
988        // `.tlisp` is tatara-lisp's script extension — 571 files in the fleet,
989        // every one of which resolved to NO language before this line, so the
990        // editor painted them as plain text. The omission was invisible
991        // because `.lisp` (550 files) did resolve.
992        Selector::Extension("tlisp"),
993        Selector::Extension("lsp"),
994        Selector::Extension("el"),
995        Selector::Extension("scm"),
996    ];
997
998    static JSON_TABLE: LangTable = LangTable {
999        keywords: &["true", "false", "null"],
1000        line_comments: &[],
1001        block_comment: None,
1002        string_delims: &['"'],
1003        colon_keywords: false,
1004    };
1005    static JSON_SEL: &[Selector] = &[Selector::Extension("json")];
1006
1007    static TOML_TABLE: LangTable = LangTable {
1008        keywords: &["true", "false"],
1009        line_comments: &["#"],
1010        block_comment: None,
1011        string_delims: &['"', '\''],
1012        colon_keywords: false,
1013    };
1014    static TOML_SEL: &[Selector] = &[
1015        Selector::Extension("toml"),
1016        Selector::Filename("Cargo.lock"),
1017    ];
1018
1019    static MD_TABLE: LangTable = LangTable {
1020        keywords: &[],
1021        line_comments: &[],
1022        block_comment: None,
1023        string_delims: &['`'],
1024        colon_keywords: false,
1025    };
1026    static MD_SEL: &[Selector] = &[Selector::Extension("md"), Selector::Extension("markdown")];
1027
1028    /// Every built-in language plugin (the batteries-included default set).
1029    #[must_use]
1030    pub fn builtins() -> Vec<Box<dyn LanguagePlugin>> {
1031        vec![
1032            Box::new(TablePlugin {
1033                language: Language("rust"),
1034                selectors: RUST_SEL,
1035                table: &RUST_TABLE,
1036            }),
1037            Box::new(TablePlugin {
1038                language: Language("python"),
1039                selectors: PY_SEL,
1040                table: &PY_TABLE,
1041            }),
1042            Box::new(TablePlugin {
1043                language: Language("lisp"),
1044                selectors: LISP_SEL,
1045                table: &LISP_TABLE,
1046            }),
1047            Box::new(TablePlugin {
1048                language: Language("json"),
1049                selectors: JSON_SEL,
1050                table: &JSON_TABLE,
1051            }),
1052            Box::new(TablePlugin {
1053                language: Language("toml"),
1054                selectors: TOML_SEL,
1055                table: &TOML_TABLE,
1056            }),
1057            Box::new(TablePlugin {
1058                language: Language("markdown"),
1059                selectors: MD_SEL,
1060                table: &MD_TABLE,
1061            }),
1062        ]
1063    }
1064}
1065
1066// ───────────────────────────── tests ────────────────────────────
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    fn covers(text: &str, spans: &[HighlightSpan]) {
1073        // Coverage-complete + non-overlapping + forward-only by construction.
1074        let mut cursor = 0u32;
1075        for s in spans {
1076            assert_eq!(s.span.start, cursor, "gap/overlap at {cursor}");
1077            assert!(s.span.end > s.span.start);
1078            cursor = s.span.end;
1079        }
1080        assert_eq!(cursor as usize, text.len(), "partition does not cover text");
1081    }
1082
1083    #[test]
1084    fn partition_is_coverage_complete() {
1085        let eco = Ecosystem::with_builtins();
1086        for (path, src) in [
1087            ("a.rs", "fn main() {\n    let x = 42; // hi\n}\n"),
1088            ("b.py", "def f(x):\n    return \"s\"  # c\n"),
1089            ("c.lisp", "(defcaixa :name \"x\" 42) ; c\n"),
1090            ("c.tlisp", "(define f (lambda (x) \"s\")) ; c\n"),
1091            ("d.txt", "no language here\n"),
1092        ] {
1093            let h = eco.highlighter_for_path(path);
1094            let spans = h.highlight(src);
1095            covers(src, &spans);
1096        }
1097    }
1098
1099    #[test]
1100    fn resolves_by_extension_not_always_rust() {
1101        let eco = Ecosystem::with_builtins();
1102        assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
1103        assert_eq!(eco.resolve("app.py"), Language("python"));
1104        assert_eq!(eco.resolve("x.lisp"), Language("lisp"));
1105        // tatara-lisp's script extension. 571 fleet files resolved to no
1106        // language at all before this was added; pin it so it cannot regress.
1107        assert_eq!(eco.resolve("x.tlisp"), Language("lisp"));
1108        assert_eq!(eco.resolve("tools/check.tlisp"), Language("lisp"));
1109        assert_eq!(eco.resolve("Cargo.lock"), Language("toml"));
1110        // The bug: a non-Rust file must NOT resolve to rust.
1111        assert_eq!(eco.resolve("notes.txt"), PLAIN_TEXT);
1112        assert_ne!(eco.resolve("app.py"), Language("rust"));
1113    }
1114
1115    #[test]
1116    fn rust_keyword_is_classified() {
1117        let eco = Ecosystem::with_builtins();
1118        let spans = eco.highlighter_for_path("a.rs").highlight("fn x");
1119        assert_eq!(spans[0].class, HlClass::Keyword); // `fn`
1120    }
1121
1122    #[test]
1123    fn multiline_string_and_block_comment_thread_state() {
1124        let eco = Ecosystem::with_builtins();
1125        let spans = eco.highlighter_for_path("a.rs").highlight("/* a\nb */ x\n");
1126        covers("/* a\nb */ x\n", &spans);
1127        assert!(matches!(
1128            spans[0].class,
1129            HlClass::Comment { multiline: true }
1130        ));
1131    }
1132
1133    #[test]
1134    fn plain_text_is_one_plain_span() {
1135        let h = PlainHighlighter;
1136        let spans = h.highlight("hello");
1137        assert_eq!(spans.len(), 1);
1138        assert_eq!(spans[0].class, HlClass::Plain);
1139    }
1140
1141    // ── incremental line cache (the LineState-fixpoint seal) ──
1142
1143    /// A tiny deterministic LCG so the differential fuzz is reproducible
1144    /// without a `rand` dependency (hikari-core is zero-dep).
1145    fn lcg(state: &mut u64) -> u64 {
1146        *state = state
1147            .wrapping_mul(6_364_136_223_846_793_005)
1148            .wrapping_add(1_442_695_040_888_963_407);
1149        *state >> 33
1150    }
1151
1152    /// S5 seal — the load-bearing invariant: an incremental re-lex is
1153    /// byte-identical to a one-shot re-lex, for every edit. Differential fuzz
1154    /// over random insert/delete edits against a Rust-ish corpus.
1155    #[test]
1156    fn incremental_is_byte_identical_to_one_shot() {
1157        let eco = Ecosystem::with_builtins();
1158        let one_shot = eco.highlighter_for_path("f.rs");
1159        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1160
1161        let alphabet: Vec<char> = "fn xy=42;{}\n/*/ \"ab\"//c".chars().collect();
1162        let mut text = String::from("fn main() {\n    let x = 1;\n}\n");
1163        let mut seed = 0x1234_5678_9abc_def0u64;
1164
1165        for _ in 0..400 {
1166            // Random edit: insert a char or delete one, at a random boundary.
1167            let len = text.chars().count();
1168            let at = if len == 0 {
1169                0
1170            } else {
1171                (lcg(&mut seed) as usize) % (len + 1)
1172            };
1173            let byte_at = text.char_indices().nth(at).map_or(text.len(), |(b, _)| b);
1174            if len > 4 && lcg(&mut seed) % 2 == 0 {
1175                // delete one char
1176                if let Some((b, c)) = text[byte_at..].char_indices().next() {
1177                    let start = byte_at + b;
1178                    text.replace_range(start..start + c.len_utf8(), "");
1179                }
1180            } else {
1181                let c = alphabet[(lcg(&mut seed) as usize) % alphabet.len()];
1182                text.insert(byte_at, c);
1183            }
1184
1185            let inc = cache.highlight(&text);
1186            let full = one_shot.highlight(&text);
1187            assert_eq!(inc, full, "incremental != one-shot for {text:?}");
1188            covers(&text, &inc);
1189        }
1190    }
1191
1192    /// S6 seal — an unchanged document re-lexes NOTHING on the second call.
1193    #[test]
1194    fn idle_rehighlight_relexes_zero_lines() {
1195        let eco = Ecosystem::with_builtins();
1196        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1197        let text = "fn a() {}\nfn b() {}\nfn c() {}\n";
1198        let _ = cache.highlight(text);
1199        let _ = cache.highlight(text); // idle re-render
1200        assert_eq!(
1201            cache.last_relexed(),
1202            0,
1203            "idle re-render must re-lex nothing"
1204        );
1205    }
1206
1207    /// The fixpoint: a one-line edit re-lexes only the lines up to where the
1208    /// carried `LineState` re-converges — here, a leaf edit re-lexes just its
1209    /// own line, not the whole 60-line document.
1210    #[test]
1211    fn single_line_edit_relexes_locally() {
1212        let eco = Ecosystem::with_builtins();
1213        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1214        let mut text = String::new();
1215        for i in 0..60 {
1216            text.push_str(&format!("let v{i} = {i};\n"));
1217        }
1218        let _ = cache.highlight(&text); // prime: 60 lines lexed
1219        // Edit line 30's value only (no block-comment/string state crosses).
1220        let edited = text.replacen("let v30 = 30;", "let v30 = 999;", 1);
1221        let _ = cache.highlight(&edited);
1222        assert_eq!(
1223            cache.last_relexed(),
1224            1,
1225            "a local edit must re-lex exactly its own line (state re-converges immediately)"
1226        );
1227    }
1228
1229    /// A block comment opened mid-document propagates: re-lex continues past
1230    /// the edited line until the carried state re-converges (here, the `*/`).
1231    #[test]
1232    fn cross_line_state_change_propagates_then_converges() {
1233        let eco = Ecosystem::with_builtins();
1234        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1235        let text = "let a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n";
1236        let _ = cache.highlight(text);
1237        // Open a block comment on line 0 that closes on line 2.
1238        let edited = "let a = 1; /*\nstill comment\n*/ let c = 3;\nlet d = 4;\n";
1239        let inc = cache.highlight(edited);
1240        assert_eq!(inc, eco.highlighter_for_path("f.rs").highlight(edited));
1241        // Lines 0..=2 re-lexed (state in flight); line 3 reused (state reconverged).
1242        assert!(
1243            cache.last_relexed() <= 3,
1244            "re-lex must stop once the block comment closes and state reconverges"
1245        );
1246    }
1247}