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    static LISP_KW: &[&str] = &[
941        "def", "defn", "defmacro", "defcaixa", "deflexer", "let", "lambda", "fn", "if", "cond",
942        "when", "unless", "do", "quote",
943    ];
944    static LISP_TABLE: LangTable = LangTable {
945        keywords: LISP_KW,
946        line_comments: &[";"],
947        block_comment: Some(("#|", "|#")),
948        string_delims: &['"'],
949        colon_keywords: true,
950    };
951    static LISP_SEL: &[Selector] = &[
952        Selector::Extension("lisp"),
953        Selector::Extension("lsp"),
954        Selector::Extension("el"),
955        Selector::Extension("scm"),
956    ];
957
958    static JSON_TABLE: LangTable = LangTable {
959        keywords: &["true", "false", "null"],
960        line_comments: &[],
961        block_comment: None,
962        string_delims: &['"'],
963        colon_keywords: false,
964    };
965    static JSON_SEL: &[Selector] = &[Selector::Extension("json")];
966
967    static TOML_TABLE: LangTable = LangTable {
968        keywords: &["true", "false"],
969        line_comments: &["#"],
970        block_comment: None,
971        string_delims: &['"', '\''],
972        colon_keywords: false,
973    };
974    static TOML_SEL: &[Selector] = &[
975        Selector::Extension("toml"),
976        Selector::Filename("Cargo.lock"),
977    ];
978
979    static MD_TABLE: LangTable = LangTable {
980        keywords: &[],
981        line_comments: &[],
982        block_comment: None,
983        string_delims: &['`'],
984        colon_keywords: false,
985    };
986    static MD_SEL: &[Selector] = &[Selector::Extension("md"), Selector::Extension("markdown")];
987
988    /// Every built-in language plugin (the batteries-included default set).
989    #[must_use]
990    pub fn builtins() -> Vec<Box<dyn LanguagePlugin>> {
991        vec![
992            Box::new(TablePlugin {
993                language: Language("rust"),
994                selectors: RUST_SEL,
995                table: &RUST_TABLE,
996            }),
997            Box::new(TablePlugin {
998                language: Language("python"),
999                selectors: PY_SEL,
1000                table: &PY_TABLE,
1001            }),
1002            Box::new(TablePlugin {
1003                language: Language("lisp"),
1004                selectors: LISP_SEL,
1005                table: &LISP_TABLE,
1006            }),
1007            Box::new(TablePlugin {
1008                language: Language("json"),
1009                selectors: JSON_SEL,
1010                table: &JSON_TABLE,
1011            }),
1012            Box::new(TablePlugin {
1013                language: Language("toml"),
1014                selectors: TOML_SEL,
1015                table: &TOML_TABLE,
1016            }),
1017            Box::new(TablePlugin {
1018                language: Language("markdown"),
1019                selectors: MD_SEL,
1020                table: &MD_TABLE,
1021            }),
1022        ]
1023    }
1024}
1025
1026// ───────────────────────────── tests ────────────────────────────
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    fn covers(text: &str, spans: &[HighlightSpan]) {
1033        // Coverage-complete + non-overlapping + forward-only by construction.
1034        let mut cursor = 0u32;
1035        for s in spans {
1036            assert_eq!(s.span.start, cursor, "gap/overlap at {cursor}");
1037            assert!(s.span.end > s.span.start);
1038            cursor = s.span.end;
1039        }
1040        assert_eq!(cursor as usize, text.len(), "partition does not cover text");
1041    }
1042
1043    #[test]
1044    fn partition_is_coverage_complete() {
1045        let eco = Ecosystem::with_builtins();
1046        for (path, src) in [
1047            ("a.rs", "fn main() {\n    let x = 42; // hi\n}\n"),
1048            ("b.py", "def f(x):\n    return \"s\"  # c\n"),
1049            ("c.lisp", "(defcaixa :name \"x\" 42) ; c\n"),
1050            ("d.txt", "no language here\n"),
1051        ] {
1052            let h = eco.highlighter_for_path(path);
1053            let spans = h.highlight(src);
1054            covers(src, &spans);
1055        }
1056    }
1057
1058    #[test]
1059    fn resolves_by_extension_not_always_rust() {
1060        let eco = Ecosystem::with_builtins();
1061        assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
1062        assert_eq!(eco.resolve("app.py"), Language("python"));
1063        assert_eq!(eco.resolve("x.lisp"), Language("lisp"));
1064        assert_eq!(eco.resolve("Cargo.lock"), Language("toml"));
1065        // The bug: a non-Rust file must NOT resolve to rust.
1066        assert_eq!(eco.resolve("notes.txt"), PLAIN_TEXT);
1067        assert_ne!(eco.resolve("app.py"), Language("rust"));
1068    }
1069
1070    #[test]
1071    fn rust_keyword_is_classified() {
1072        let eco = Ecosystem::with_builtins();
1073        let spans = eco.highlighter_for_path("a.rs").highlight("fn x");
1074        assert_eq!(spans[0].class, HlClass::Keyword); // `fn`
1075    }
1076
1077    #[test]
1078    fn multiline_string_and_block_comment_thread_state() {
1079        let eco = Ecosystem::with_builtins();
1080        let spans = eco.highlighter_for_path("a.rs").highlight("/* a\nb */ x\n");
1081        covers("/* a\nb */ x\n", &spans);
1082        assert!(matches!(
1083            spans[0].class,
1084            HlClass::Comment { multiline: true }
1085        ));
1086    }
1087
1088    #[test]
1089    fn plain_text_is_one_plain_span() {
1090        let h = PlainHighlighter;
1091        let spans = h.highlight("hello");
1092        assert_eq!(spans.len(), 1);
1093        assert_eq!(spans[0].class, HlClass::Plain);
1094    }
1095
1096    // ── incremental line cache (the LineState-fixpoint seal) ──
1097
1098    /// A tiny deterministic LCG so the differential fuzz is reproducible
1099    /// without a `rand` dependency (hikari-core is zero-dep).
1100    fn lcg(state: &mut u64) -> u64 {
1101        *state = state
1102            .wrapping_mul(6_364_136_223_846_793_005)
1103            .wrapping_add(1_442_695_040_888_963_407);
1104        *state >> 33
1105    }
1106
1107    /// S5 seal — the load-bearing invariant: an incremental re-lex is
1108    /// byte-identical to a one-shot re-lex, for every edit. Differential fuzz
1109    /// over random insert/delete edits against a Rust-ish corpus.
1110    #[test]
1111    fn incremental_is_byte_identical_to_one_shot() {
1112        let eco = Ecosystem::with_builtins();
1113        let one_shot = eco.highlighter_for_path("f.rs");
1114        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1115
1116        let alphabet: Vec<char> = "fn xy=42;{}\n/*/ \"ab\"//c".chars().collect();
1117        let mut text = String::from("fn main() {\n    let x = 1;\n}\n");
1118        let mut seed = 0x1234_5678_9abc_def0u64;
1119
1120        for _ in 0..400 {
1121            // Random edit: insert a char or delete one, at a random boundary.
1122            let len = text.chars().count();
1123            let at = if len == 0 {
1124                0
1125            } else {
1126                (lcg(&mut seed) as usize) % (len + 1)
1127            };
1128            let byte_at = text.char_indices().nth(at).map_or(text.len(), |(b, _)| b);
1129            if len > 4 && lcg(&mut seed) % 2 == 0 {
1130                // delete one char
1131                if let Some((b, c)) = text[byte_at..].char_indices().next() {
1132                    let start = byte_at + b;
1133                    text.replace_range(start..start + c.len_utf8(), "");
1134                }
1135            } else {
1136                let c = alphabet[(lcg(&mut seed) as usize) % alphabet.len()];
1137                text.insert(byte_at, c);
1138            }
1139
1140            let inc = cache.highlight(&text);
1141            let full = one_shot.highlight(&text);
1142            assert_eq!(inc, full, "incremental != one-shot for {text:?}");
1143            covers(&text, &inc);
1144        }
1145    }
1146
1147    /// S6 seal — an unchanged document re-lexes NOTHING on the second call.
1148    #[test]
1149    fn idle_rehighlight_relexes_zero_lines() {
1150        let eco = Ecosystem::with_builtins();
1151        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1152        let text = "fn a() {}\nfn b() {}\nfn c() {}\n";
1153        let _ = cache.highlight(text);
1154        let _ = cache.highlight(text); // idle re-render
1155        assert_eq!(cache.last_relexed(), 0, "idle re-render must re-lex nothing");
1156    }
1157
1158    /// The fixpoint: a one-line edit re-lexes only the lines up to where the
1159    /// carried `LineState` re-converges — here, a leaf edit re-lexes just its
1160    /// own line, not the whole 60-line document.
1161    #[test]
1162    fn single_line_edit_relexes_locally() {
1163        let eco = Ecosystem::with_builtins();
1164        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1165        let mut text = String::new();
1166        for i in 0..60 {
1167            text.push_str(&format!("let v{i} = {i};\n"));
1168        }
1169        let _ = cache.highlight(&text); // prime: 60 lines lexed
1170        // Edit line 30's value only (no block-comment/string state crosses).
1171        let edited = text.replacen("let v30 = 30;", "let v30 = 999;", 1);
1172        let _ = cache.highlight(&edited);
1173        assert_eq!(
1174            cache.last_relexed(),
1175            1,
1176            "a local edit must re-lex exactly its own line (state re-converges immediately)"
1177        );
1178    }
1179
1180    /// A block comment opened mid-document propagates: re-lex continues past
1181    /// the edited line until the carried state re-converges (here, the `*/`).
1182    #[test]
1183    fn cross_line_state_change_propagates_then_converges() {
1184        let eco = Ecosystem::with_builtins();
1185        let mut cache = eco.incremental_highlighter_for_path("f.rs");
1186        let text = "let a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n";
1187        let _ = cache.highlight(text);
1188        // Open a block comment on line 0 that closes on line 2.
1189        let edited = "let a = 1; /*\nstill comment\n*/ let c = 3;\nlet d = 4;\n";
1190        let inc = cache.highlight(edited);
1191        assert_eq!(inc, eco.highlighter_for_path("f.rs").highlight(edited));
1192        // Lines 0..=2 re-lexed (state in flight); line 3 reused (state reconverged).
1193        assert!(
1194            cache.last_relexed() <= 3,
1195            "re-lex must stop once the block comment closes and state reconverges"
1196        );
1197    }
1198}