Skip to main content

tree_space/
highlight.rs

1//! Lightweight, dependency-free syntax highlighting for inline previews.
2//!
3//! The preview already caps what it reads (see [`crate::preview`]), so
4//! highlighting is a single linear pass over a small buffer. The scanner is
5//! deliberately simple — an approximation, not a full parser — and runs once
6//! when a document is loaded, caching the resulting spans with it. Rebuilds
7//! only replay the cached spans, so there is no per-frame cost.
8//!
9//! Colors come from a process-wide [`Palette`] set at startup from the active
10//! theme (see [`crate::theme`]); the tree turns each [`TokenClass`] into a
11//! `GtkTextTag`.
12
13use std::path::Path;
14use std::sync::OnceLock;
15
16/// A semantic token category. Each maps to one color in [`Palette`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum TokenClass {
19    Keyword,
20    Type,
21    Function,
22    String,
23    Comment,
24    Number,
25    Constant,
26    Preprocessor,
27    Tag,
28    Attribute,
29    Heading,
30    Link,
31}
32
33/// A highlighted range of the preview text, in character offsets (`end` is
34/// exclusive). Spans never overlap.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Span {
37    pub start: usize,
38    pub end: usize,
39    pub class: TokenClass,
40}
41
42/// The language a preview is highlighted as.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Language {
45    Rust,
46    CLike,
47    JavaScript,
48    TypeScript,
49    Python,
50    Shell,
51    Go,
52    Json,
53    KeyValue,
54    Yaml,
55    Markup,
56    Css,
57    Markdown,
58    Plain,
59}
60
61/// The colors used for each token class. Values are GTK color strings
62/// (`#rrggbb`), so they can be handed straight to a `GtkTextTag`.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Palette {
65    pub keyword: String,
66    pub type_: String,
67    pub function: String,
68    pub string: String,
69    pub comment: String,
70    pub number: String,
71    pub constant: String,
72    pub preprocessor: String,
73    pub tag: String,
74    pub attribute: String,
75    pub heading: String,
76    pub link: String,
77}
78
79impl Palette {
80    /// The Tokyo Night palette used when no theme is available (and in custom
81    /// `main.css` mode, which is dark by default).
82    pub fn dark() -> Self {
83        Self {
84            keyword: "#7aa2f7".to_owned(),
85            type_: "#2ac3de".to_owned(),
86            function: "#7dcfff".to_owned(),
87            string: "#9ece6a".to_owned(),
88            comment: "#565f89".to_owned(),
89            number: "#ff9e64".to_owned(),
90            constant: "#bb9af7".to_owned(),
91            preprocessor: "#e0af68".to_owned(),
92            tag: "#f7768e".to_owned(),
93            attribute: "#bb9af7".to_owned(),
94            heading: "#7aa2f7".to_owned(),
95            link: "#73daca".to_owned(),
96        }
97    }
98
99    /// The color for `class`, or `None` when it should use the default text
100    /// color.
101    pub fn color(&self, class: TokenClass) -> &str {
102        match class {
103            TokenClass::Keyword => &self.keyword,
104            TokenClass::Type => &self.type_,
105            TokenClass::Function => &self.function,
106            TokenClass::String => &self.string,
107            TokenClass::Comment => &self.comment,
108            TokenClass::Number => &self.number,
109            TokenClass::Constant => &self.constant,
110            TokenClass::Preprocessor => &self.preprocessor,
111            TokenClass::Tag => &self.tag,
112            TokenClass::Attribute => &self.attribute,
113            TokenClass::Heading => &self.heading,
114            TokenClass::Link => &self.link,
115        }
116    }
117}
118
119/// The palette applied to every preview. Set once during startup via
120/// [`set_palette`]; falls back to the dark default if nothing set it.
121static PALETTE: OnceLock<Palette> = OnceLock::new();
122
123/// Install the process-wide syntax palette. Only the first call wins.
124pub fn set_palette(palette: Palette) {
125    let _ = PALETTE.set(palette);
126}
127
128/// The active syntax palette.
129pub fn palette() -> &'static Palette {
130    PALETTE.get_or_init(Palette::dark)
131}
132
133/// Pick the highlighting language from a file's extension.
134pub fn language_for(path: &Path) -> Language {
135    let ext = path
136        .extension()
137        .and_then(|ext| ext.to_str())
138        .map(str::to_ascii_lowercase);
139    match ext.as_deref() {
140        Some("rs") => Language::Rust,
141        Some("c" | "h" | "cc" | "hh" | "cpp" | "hpp" | "cxx" | "hxx") => Language::CLike,
142        Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
143        Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
144        Some("py" | "pyw") => Language::Python,
145        Some("sh" | "bash" | "zsh" | "fish" | "ksh") => Language::Shell,
146        Some("go") => Language::Go,
147        Some("json") => Language::Json,
148        Some("toml" | "ini" | "conf" | "cfg" | "env" | "properties") => Language::KeyValue,
149        Some("yaml" | "yml") => Language::Yaml,
150        Some("html" | "htm" | "xhtml" | "xml" | "svg") => Language::Markup,
151        Some("css") => Language::Css,
152        Some("md" | "markdown") => Language::Markdown,
153        _ => Language::Plain,
154    }
155}
156
157/// Highlight `text` as `language`. Character offsets in the returned spans
158/// index the same text; the text itself is never altered.
159pub fn highlight(language: Language, text: &str) -> Vec<Span> {
160    let chars: Vec<char> = text.chars().collect();
161    match language {
162        Language::Plain => Vec::new(),
163        Language::Rust => scan_code(&chars, &RUST),
164        Language::CLike => scan_code(&chars, &C_LIKE),
165        Language::JavaScript => scan_code(&chars, &JAVASCRIPT),
166        Language::TypeScript => scan_code(&chars, &TYPESCRIPT),
167        Language::Python => scan_code(&chars, &PYTHON),
168        Language::Shell => scan_code(&chars, &SHELL),
169        Language::Go => scan_code(&chars, &GO),
170        Language::Json => scan_json(&chars),
171        Language::KeyValue => scan_key_value(&chars),
172        Language::Yaml => scan_yaml(&chars),
173        Language::Markup => scan_markup(&chars),
174        Language::Css => scan_css(&chars),
175        Language::Markdown => scan_markdown(&chars),
176    }
177}
178
179// ---------------------------------------------------------------------------
180// generic code scanner
181// ---------------------------------------------------------------------------
182
183/// Syntax knobs for the generic scanner.
184#[derive(Clone, Copy)]
185struct Syntax {
186    line_comments: &'static [&'static str],
187    block_comment: Option<(&'static str, &'static str)>,
188    /// Single-character quote delimiters.
189    quotes: &'static [char],
190    keywords: &'static [&'static str],
191    types: &'static [&'static str],
192    constants: &'static [&'static str],
193    /// `#` at the start of a line begins a preprocessor directive.
194    hash_line: bool,
195    /// `#[...]` / `#![...]` (Rust attributes) are tagged as one span.
196    hash_bracket: bool,
197    /// `@name` at the start of a line (Python decorators, shell attributes).
198    at_line: bool,
199    /// Rust-style raw strings (`r"..."`, `r#"..."#`) and byte strings.
200    rust_strings: bool,
201    /// Python `"""..."""` / `'''...'''`, and string prefixes.
202    python_strings: bool,
203}
204
205fn scan_code(chars: &[char], syntax: &Syntax) -> Vec<Span> {
206    let mut spans = Vec::new();
207    let mut i = 0;
208    while i < chars.len() {
209        let c = chars[i];
210
211        // line comments
212        if syntax.line_comments.iter().any(|p| matches_at(chars, i, p)) {
213            let end = line_end(chars, i);
214            spans.push(Span { start: i, end, class: TokenClass::Comment });
215            i = end;
216            continue;
217        }
218        // block comments
219        if let Some((open, close)) = syntax.block_comment
220            && matches_at(chars, i, open)
221        {
222            let start = i;
223            i += char_len(open);
224            while i < chars.len() && !matches_at(chars, i, close) {
225                i += 1;
226            }
227            i = (i + char_len(close)).min(chars.len());
228            spans.push(Span { start, end: i, class: TokenClass::Comment });
229            continue;
230        }
231        // Rust raw / byte strings
232        if syntax.rust_strings {
233            if let Some((start, end)) = rust_string_span(chars, i) {
234                spans.push(Span { start, end, class: TokenClass::String });
235                i = end;
236                continue;
237            }
238            // Rust lifetimes: 'a — not a char literal.
239            if c == '\''
240                && chars.get(i + 1).is_some_and(|n| is_ident_start(*n))
241                && chars.get(i + 2) != Some(&'\'')
242            {
243                let end = ident_end(chars, i + 1);
244                spans.push(Span { start: i, end, class: TokenClass::Type });
245                i = end;
246                continue;
247            }
248        }
249        // Python triple quotes (also with an optional prefix letter)
250        if syntax.python_strings
251            && let Some((start, end)) = python_string_span(chars, i)
252        {
253            spans.push(Span { start, end, class: TokenClass::String });
254            i = end;
255            continue;
256        }
257        // plain strings
258        if syntax.quotes.contains(&c) {
259            let end = string_end(chars, i, c);
260            spans.push(Span { start: i, end, class: TokenClass::String });
261            i = end;
262            continue;
263        }
264        // Rust attributes
265        if syntax.hash_bracket && c == '#' && chars.get(i + 1) == Some(&'[') {
266            let end = bracketed_end(chars, i + 1);
267            spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
268            i = end;
269            continue;
270        }
271        // C preprocessor
272        if syntax.hash_line && c == '#' && only_space_before(chars, i) {
273            let end = line_end(chars, i);
274            spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
275            i = end;
276            continue;
277        }
278        // decorators / attributes at line start
279        if syntax.at_line && c == '@' && only_space_before(chars, i) {
280            let end = ident_end(chars, i + 1);
281            spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
282            i = end;
283            continue;
284        }
285        // numbers
286        if c.is_ascii_digit() && !chars.get(i.wrapping_sub(1)).is_some_and(|p| is_ident_char(*p)) {
287            let end = number_end(chars, i);
288            spans.push(Span { start: i, end, class: TokenClass::Number });
289            i = end;
290            continue;
291        }
292        // identifiers / keywords
293        if is_ident_start(c) {
294            let end = ident_end(chars, i);
295            let word: String = chars[i..end].iter().collect();
296            let class = if syntax.constants.contains(&word.as_str()) {
297                Some(TokenClass::Constant)
298            } else if syntax.keywords.contains(&word.as_str()) {
299                Some(TokenClass::Keyword)
300            } else if syntax.types.contains(&word.as_str()) {
301                Some(TokenClass::Type)
302            } else if chars.get(end) == Some(&'(') {
303                Some(TokenClass::Function)
304            } else {
305                None
306            };
307            if let Some(class) = class {
308                spans.push(Span { start: i, end, class });
309            }
310            i = end;
311            continue;
312        }
313        i += 1;
314    }
315    spans
316}
317
318fn rust_string_span(chars: &[char], i: usize) -> Option<(usize, usize)> {
319    let (prefix, quote) = match chars.get(i) {
320        Some('r') => (1, true),
321        Some('b') if chars.get(i + 1) == Some(&'r') => (2, true),
322        Some('b') => (1, false),
323        _ => return None,
324    };
325    if !quote {
326        // Byte string b"..."
327        if chars.get(i + prefix) == Some(&'"') {
328            let start = i;
329            let end = string_end(chars, i + prefix, '"');
330            return Some((start, end));
331        }
332        return None;
333    }
334    let mut j = i + prefix;
335    let mut hashes = 0;
336    while chars.get(j) == Some(&'#') {
337        hashes += 1;
338        j += 1;
339    }
340    if chars.get(j) != Some(&'"') {
341        return None;
342    }
343    j += 1;
344    let closing = format!("\"{}", "#".repeat(hashes));
345    while j < chars.len() && !matches_at(chars, j, &closing) {
346        j += 1;
347    }
348    let end = (j + char_len(&closing)).min(chars.len());
349    Some((i, end))
350}
351
352fn python_string_span(chars: &[char], i: usize) -> Option<(usize, usize)> {
353    // Optional prefix: r, b, f, u (in any combination), then a quote.
354    let mut j = i;
355    while chars.get(j).is_some_and(|c| matches!(c, 'r' | 'b' | 'f' | 'u' | 'R' | 'B' | 'F' | 'U')) {
356        j += 1;
357    }
358    let quote = *chars.get(j)?;
359    if quote != '"' && quote != '\'' {
360        return None;
361    }
362    let triple = chars.get(j + 1) == Some(&quote) && chars.get(j + 2) == Some(&quote);
363    let delim = if triple {
364        quote.to_string().repeat(3)
365    } else {
366        quote.to_string()
367    };
368    let mut k = j + char_len(&delim);
369    while k < chars.len() {
370        if chars[k] == '\\' {
371            k += 2;
372            continue;
373        }
374        if matches_at(chars, k, &delim) {
375            return Some((i, k + char_len(&delim)));
376        }
377        if !triple && chars[k] == '\n' {
378            return None;
379        }
380        k += 1;
381    }
382    Some((i, chars.len()))
383}
384
385/// The end of the string starting at the opening `quote` (inclusive of the
386/// closing quote). A backslash escapes the next character.
387fn string_end(chars: &[char], start: usize, quote: char) -> usize {
388    let mut i = start + 1;
389    while i < chars.len() {
390        match chars[i] {
391            '\\' => i += 2,
392            c if c == quote => return i + 1,
393            '\n' => return i,
394            _ => i += 1,
395        }
396    }
397    chars.len()
398}
399
400fn number_end(chars: &[char], start: usize) -> usize {
401    let mut i = start;
402    let mut seen_dot = false;
403    while i < chars.len() {
404        let c = chars[i];
405        if c == '.' {
406            // A range operator (`1..2`) is not part of the number.
407            if chars.get(i + 1) == Some(&'.') || seen_dot {
408                break;
409            }
410            seen_dot = true;
411            i += 1;
412        } else if c.is_alphanumeric() || c == '_' {
413            i += 1;
414        } else {
415            break;
416        }
417    }
418    i
419}
420
421fn bracketed_end(chars: &[char], open: usize) -> usize {
422    let mut depth = 0;
423    let mut i = open;
424    while i < chars.len() {
425        match chars[i] {
426            '[' => depth += 1,
427            ']' => {
428                depth -= 1;
429                if depth == 0 {
430                    return i + 1;
431                }
432            }
433            _ => {}
434        }
435        i += 1;
436    }
437    chars.len()
438}
439
440// ---------------------------------------------------------------------------
441// key/value languages
442// ---------------------------------------------------------------------------
443
444fn scan_json(chars: &[char]) -> Vec<Span> {
445    let mut spans = Vec::new();
446    let mut i = 0;
447    while i < chars.len() {
448        let c = chars[i];
449        if c == '"' {
450            let end = string_end(chars, i, '"');
451            let after = skip_space(chars, end);
452            let class = if chars.get(after) == Some(&':') {
453                TokenClass::Attribute
454            } else {
455                TokenClass::String
456            };
457            spans.push(Span { start: i, end, class });
458            i = end;
459            continue;
460        }
461        if c.is_ascii_digit()
462            || (c == '-' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit()))
463        {
464            let end = number_end(chars, i + usize::from(c == '-'));
465            spans.push(Span { start: i, end, class: TokenClass::Number });
466            i = end;
467            continue;
468        }
469        if is_ident_start(c) {
470            let end = ident_end(chars, i);
471            let word: String = chars[i..end].iter().collect();
472            if matches!(word.as_str(), "true" | "false" | "null") {
473                spans.push(Span { start: i, end, class: TokenClass::Constant });
474            }
475            i = end;
476            continue;
477        }
478        i += 1;
479    }
480    spans
481}
482
483/// TOML / INI / generic `.conf`: `#`/`;` comments, `[section]`, `key = value`.
484fn scan_key_value(chars: &[char]) -> Vec<Span> {
485    let mut spans = Vec::new();
486    let mut i = 0;
487    while i < chars.len() {
488        let c = chars[i];
489        if c == '#' || c == ';' {
490            let end = line_end(chars, i);
491            spans.push(Span { start: i, end, class: TokenClass::Comment });
492            i = end;
493            continue;
494        }
495        if c == '[' && only_space_before(chars, i) {
496            let end = chars[i..]
497                .iter()
498                .position(|&c| c == ']')
499                .map(|p| i + p + 1)
500                .unwrap_or(chars.len());
501            spans.push(Span { start: i, end, class: TokenClass::Tag });
502            i = end;
503            continue;
504        }
505        if c == '"' || c == '\'' {
506            let end = string_end(chars, i, c);
507            // A quoted key (start of line) is an attribute.
508            let class = if key_position(chars, i) {
509                TokenClass::Attribute
510            } else {
511                TokenClass::String
512            };
513            spans.push(Span { start: i, end, class });
514            i = end;
515            continue;
516        }
517        if is_ident_start(c) {
518            let end = ident_end(chars, i);
519            let after = skip_space(chars, end);
520            if chars.get(after) == Some(&'=') && key_position(chars, i) {
521                spans.push(Span { start: i, end, class: TokenClass::Attribute });
522            } else {
523                let word: String = chars[i..end].iter().collect();
524                if matches!(word.as_str(), "true" | "false") {
525                    spans.push(Span { start: i, end, class: TokenClass::Constant });
526                }
527            }
528            i = end;
529            continue;
530        }
531        if c.is_ascii_digit() {
532            let end = number_end(chars, i);
533            spans.push(Span { start: i, end, class: TokenClass::Number });
534            i = end;
535            continue;
536        }
537        i += 1;
538    }
539    spans
540}
541
542/// YAML: `#` comments, `key:` at the start of a line, quoted strings.
543fn scan_yaml(chars: &[char]) -> Vec<Span> {
544    let mut spans = Vec::new();
545    let mut i = 0;
546    while i < chars.len() {
547        let c = chars[i];
548        if c == '#' {
549            let end = line_end(chars, i);
550            spans.push(Span { start: i, end, class: TokenClass::Comment });
551            i = end;
552            continue;
553        }
554        if c == '"' || c == '\'' {
555            let end = string_end(chars, i, c);
556            spans.push(Span { start: i, end, class: TokenClass::String });
557            i = end;
558            continue;
559        }
560        if is_ident_start(c) && key_position(chars, i) {
561            let end = ident_end(chars, i);
562            let after = skip_space(chars, end);
563            if chars.get(after) == Some(&':') {
564                spans.push(Span { start: i, end, class: TokenClass::Attribute });
565            }
566            i = end;
567            continue;
568        }
569        if c.is_ascii_digit() {
570            let end = number_end(chars, i);
571            spans.push(Span { start: i, end, class: TokenClass::Number });
572            i = end;
573            continue;
574        }
575        i += 1;
576    }
577    spans
578}
579
580/// True when only whitespace (and YAML list dashes) precede `i` on its line, so
581/// the token at `i` is a key rather than a value.
582fn key_position(chars: &[char], i: usize) -> bool {
583    let mut j = i;
584    while j > 0 {
585        let c = chars[j - 1];
586        if c == '\n' {
587            break;
588        }
589        if !c.is_whitespace() && c != '-' {
590            return false;
591        }
592        j -= 1;
593    }
594    true
595}
596
597// ---------------------------------------------------------------------------
598// markup
599// ---------------------------------------------------------------------------
600
601fn scan_markup(chars: &[char]) -> Vec<Span> {
602    let mut spans = Vec::new();
603    let mut i = 0;
604    while i < chars.len() {
605        if matches_at(chars, i, "<!--") {
606            let start = i;
607            i += 4;
608            while i < chars.len() && !matches_at(chars, i, "-->") {
609                i += 1;
610            }
611            i = (i + 3).min(chars.len());
612            spans.push(Span { start, end: i, class: TokenClass::Comment });
613            continue;
614        }
615        if chars[i] == '<' {
616            i = scan_tag(chars, i, &mut spans);
617            continue;
618        }
619        if chars[i] == '&'
620            && let Some(end) = chars[i..].iter().position(|&c| c == ';').map(|p| i + p + 1)
621            && end - i <= 12
622        {
623            spans.push(Span { start: i, end, class: TokenClass::Constant });
624            i = end;
625            continue;
626        }
627        i += 1;
628    }
629    spans
630}
631
632/// Scan one `<...>` tag starting at `open`, pushing a [`TokenClass::Tag`] span
633/// for the name and [`TokenClass::Attribute`] spans for the attributes. Returns
634/// the index just past the tag.
635fn scan_tag(chars: &[char], open: usize, spans: &mut Vec<Span>) -> usize {
636    let mut i = open + 1;
637    if chars.get(i) == Some(&'/') {
638        i += 1;
639    }
640    if chars.get(i) == Some(&'!') {
641        // doctype: highlight to the closing '>'
642        let mut k = i + 1;
643        while k < chars.len() && chars[k] != '>' {
644            k += 1;
645        }
646        k = (k + 1).min(chars.len());
647        spans.push(Span { start: open, end: k, class: TokenClass::Preprocessor });
648        return k;
649    }
650    let name_start = i;
651    while i < chars.len() && is_tag_name_char(chars[i]) {
652        i += 1;
653    }
654    if i > name_start {
655        spans.push(Span { start: name_start, end: i, class: TokenClass::Tag });
656    }
657    while i < chars.len() && chars[i] != '>' {
658        if matches_at(chars, i, "/>") {
659            return i + 2;
660        }
661        if chars[i].is_whitespace() || chars[i] == '/' {
662            i += 1;
663            continue;
664        }
665        if chars[i] == '"' || chars[i] == '\'' {
666            let end = string_end(chars, i, chars[i]);
667            spans.push(Span { start: i, end, class: TokenClass::String });
668            i = end;
669            continue;
670        }
671        let attr_start = i;
672        while i < chars.len() && is_tag_name_char(chars[i]) {
673            i += 1;
674        }
675        if i > attr_start {
676            spans.push(Span { start: attr_start, end: i, class: TokenClass::Attribute });
677        } else {
678            i += 1;
679        }
680    }
681    (i + 1).min(chars.len())
682}
683
684fn is_tag_name_char(c: char) -> bool {
685    c.is_alphanumeric() || matches!(c, '-' | '_' | ':' | '.')
686}
687
688// ---------------------------------------------------------------------------
689// CSS
690// ---------------------------------------------------------------------------
691
692fn scan_css(chars: &[char]) -> Vec<Span> {
693    let mut spans = Vec::new();
694    let mut i = 0;
695    let mut in_block = false;
696    while i < chars.len() {
697        let c = chars[i];
698        if matches_at(chars, i, "/*") {
699            let start = i;
700            i += 2;
701            while i < chars.len() && !matches_at(chars, i, "*/") {
702                i += 1;
703            }
704            i = (i + 2).min(chars.len());
705            spans.push(Span { start, end: i, class: TokenClass::Comment });
706            continue;
707        }
708        if c == '"' || c == '\'' {
709            let end = string_end(chars, i, c);
710            spans.push(Span { start: i, end, class: TokenClass::String });
711            i = end;
712            continue;
713        }
714        if c == '{' {
715            in_block = true;
716            i += 1;
717            continue;
718        }
719        if c == '}' {
720            in_block = false;
721            i += 1;
722            continue;
723        }
724        if c == '@' {
725            let end = ident_end(chars, i + 1);
726            spans.push(Span { start: i, end, class: TokenClass::Keyword });
727            i = end;
728            continue;
729        }
730        if c == '#' && chars.get(i + 1).is_some_and(|d| d.is_ascii_hexdigit()) {
731            let mut end = i + 1;
732            while end < chars.len() && chars[end].is_ascii_hexdigit() {
733                end += 1;
734            }
735            spans.push(Span { start: i, end, class: TokenClass::Number });
736            i = end;
737            continue;
738        }
739        if !in_block {
740            if is_ident_start(c) || c == '.' || c == '#' || c == ':' {
741                let start = i;
742                while i < chars.len()
743                    && !matches!(chars[i], '{' | '}' | '\n' | ',' | '(' | ')')
744                    && !chars[i].is_whitespace()
745                    && !matches_at(chars, i, "/*")
746                {
747                    i += 1;
748                }
749                if i > start {
750                    spans.push(Span { start, end: i, class: TokenClass::Tag });
751                }
752                continue;
753            }
754        } else if is_ident_start(c) {
755            let end = ident_end(chars, i);
756            let after = skip_space(chars, end);
757            if chars.get(after) == Some(&':') {
758                spans.push(Span { start: i, end, class: TokenClass::Attribute });
759            }
760            i = end;
761            continue;
762        }
763        i += 1;
764    }
765    spans
766}
767
768// ---------------------------------------------------------------------------
769// Markdown
770// ---------------------------------------------------------------------------
771
772fn scan_markdown(chars: &[char]) -> Vec<Span> {
773    let mut spans = Vec::new();
774    let mut line_start = 0;
775    let mut in_fence = false;
776    while line_start < chars.len() {
777        let end = line_end(chars, line_start);
778        let line = &chars[line_start..end];
779        if is_fence(line) {
780            spans.push(Span { start: line_start, end, class: TokenClass::Keyword });
781            in_fence = !in_fence;
782            line_start = end + 1;
783            continue;
784        }
785        if in_fence {
786            spans.push(Span { start: line_start, end, class: TokenClass::String });
787            line_start = end + 1;
788            continue;
789        }
790        // ATX heading: up to 3 spaces then 1-6 '#'
791        let indent = line.iter().take_while(|c| **c == ' ').count();
792        if indent <= 3 {
793            let hashes = line[indent..].iter().take_while(|c| **c == '#').count();
794            if (1..=6).contains(&hashes) && line.get(indent + hashes).is_none_or(|c| *c == ' ') {
795                spans.push(Span { start: line_start, end, class: TokenClass::Heading });
796                line_start = end + 1;
797                continue;
798            }
799            if line.get(indent) == Some(&'>') {
800                spans.push(Span { start: line_start, end, class: TokenClass::Comment });
801                line_start = end + 1;
802                continue;
803            }
804        }
805        scan_markdown_inline(chars, line_start, end, &mut spans);
806        line_start = end + 1;
807    }
808    spans
809}
810
811fn is_fence(line: &[char]) -> bool {
812    matches_at(line, 0, "```") || matches_at(line, 0, "~~~")
813}
814
815fn scan_markdown_inline(chars: &[char], start: usize, end: usize, spans: &mut Vec<Span>) {
816    let mut i = start;
817    while i < end {
818        if chars[i] == '`'
819            && let Some(close) = (i + 1..end).find(|&k| chars[k] == '`')
820        {
821            spans.push(Span { start: i, end: close + 1, class: TokenClass::String });
822            i = close + 1;
823            continue;
824        }
825        if chars[i] == '['
826            && let Some(close) = (i + 1..end).find(|&k| chars[k] == ']')
827            && chars.get(close + 1) == Some(&'(')
828            && let Some(paren) = (close + 2..end).find(|&k| chars[k] == ')')
829        {
830            spans.push(Span { start: i, end: paren + 1, class: TokenClass::Link });
831            i = paren + 1;
832            continue;
833        }
834        i += 1;
835    }
836}
837
838// ---------------------------------------------------------------------------
839// helpers
840// ---------------------------------------------------------------------------
841
842/// Char length of a `&str` (used for delimiters, which are ASCII).
843fn char_len(s: &str) -> usize {
844    s.chars().count()
845}
846
847fn line_end(chars: &[char], from: usize) -> usize {
848    let mut i = from;
849    while i < chars.len() && chars[i] != '\n' {
850        i += 1;
851    }
852    i
853}
854
855fn skip_space(chars: &[char], from: usize) -> usize {
856    let mut i = from;
857    while i < chars.len() && chars[i].is_whitespace() {
858        i += 1;
859    }
860    i
861}
862
863/// True when every character from the line start to `i` is whitespace.
864fn only_space_before(chars: &[char], i: usize) -> bool {
865    let mut j = i;
866    while j > 0 {
867        if chars[j - 1] == '\n' {
868            break;
869        }
870        if !chars[j - 1].is_whitespace() {
871            return false;
872        }
873        j -= 1;
874    }
875    true
876}
877
878fn matches_at(chars: &[char], i: usize, pattern: &str) -> bool {
879    for (j, pc) in (i..).zip(pattern.chars()) {
880        if chars.get(j) != Some(&pc) {
881            return false;
882        }
883    }
884    true
885}
886
887fn is_ident_start(c: char) -> bool {
888    c == '_' || c.is_alphabetic()
889}
890
891fn is_ident_char(c: char) -> bool {
892    c == '_' || c.is_alphanumeric()
893}
894
895fn ident_end(chars: &[char], start: usize) -> usize {
896    let mut i = start;
897    while i < chars.len() && is_ident_char(chars[i]) {
898        i += 1;
899    }
900    i
901}
902
903// ---------------------------------------------------------------------------
904// language tables
905// ---------------------------------------------------------------------------
906
907const RUST: Syntax = Syntax {
908    line_comments: &["//"],
909    block_comment: Some(("/*", "*/")),
910    quotes: &['"'],
911    keywords: &[
912        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
913        "extern", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut",
914        "pub", "ref", "return", "static", "struct", "super", "trait", "type", "unsafe", "use",
915        "where", "while", "yield", "macro_rules",
916    ],
917    types: &[
918        "bool", "char", "f32", "f64", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16",
919        "u32", "u64", "u128", "usize", "str", "String", "Vec", "Option", "Result", "Box", "Rc",
920        "Arc", "RefCell", "HashMap", "HashSet", "BTreeMap", "Cow", "PathBuf", "Path",
921    ],
922    constants: &["true", "false", "None", "Self"],
923    hash_line: false,
924    hash_bracket: true,
925    at_line: false,
926    rust_strings: true,
927    python_strings: false,
928};
929
930const C_LIKE: Syntax = Syntax {
931    line_comments: &["//"],
932    block_comment: Some(("/*", "*/")),
933    quotes: &['"', '\''],
934    keywords: &[
935        "auto", "break", "case", "const", "continue", "default", "do", "else", "enum", "extern",
936        "for", "goto", "if", "inline", "register", "restrict", "return", "sizeof", "static",
937        "struct", "switch", "typedef", "union", "volatile", "while", "class", "namespace",
938        "template", "typename", "using", "public", "private", "protected", "virtual", "override",
939        "new", "delete", "this", "operator", "friend", "constexpr", "try", "catch", "throw",
940        "noexcept", "nullptr", "true", "false",
941    ],
942    types: &[
943        "void", "bool", "char", "short", "int", "long", "float", "double", "signed", "unsigned",
944        "size_t", "ssize_t", "int8_t", "int16_t", "int32_t", "int64_t", "uint8_t", "uint16_t",
945        "uint32_t", "uint64_t", "intptr_t", "uintptr_t", "wchar_t", "auto",
946    ],
947    constants: &["NULL", "nullptr", "true", "false"],
948    hash_line: true,
949    hash_bracket: false,
950    at_line: false,
951    rust_strings: false,
952    python_strings: false,
953};
954
955const JAVASCRIPT: Syntax = Syntax {
956    line_comments: &["//"],
957    block_comment: Some(("/*", "*/")),
958    quotes: &['"', '\'', '`'],
959    keywords: &[
960        "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
961        "do", "else", "export", "extends", "finally", "for", "function", "if", "import", "in",
962        "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try",
963        "typeof", "var", "void", "while", "with", "yield", "async", "await", "of", "static",
964        "get", "set", "as", "from",
965    ],
966    types: &["number", "string", "boolean", "object", "symbol", "bigint", "undefined"],
967    constants: &["true", "false", "null", "undefined", "NaN", "Infinity"],
968    hash_line: false,
969    hash_bracket: false,
970    at_line: false,
971    rust_strings: false,
972    python_strings: false,
973};
974
975const TYPESCRIPT: Syntax = Syntax {
976    keywords: &[
977        "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
978        "do", "else", "export", "extends", "finally", "for", "function", "if", "import", "in",
979        "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try",
980        "typeof", "var", "void", "while", "with", "yield", "async", "await", "of", "static",
981        "get", "set", "as", "from", "interface", "type", "enum", "namespace", "declare",
982        "abstract", "implements", "private", "public", "protected", "readonly", "keyof", "infer",
983        "is", "asserts", "satisfies", "override",
984    ],
985    ..JAVASCRIPT
986};
987
988const PYTHON: Syntax = Syntax {
989    line_comments: &["#"],
990    block_comment: None,
991    quotes: &['"', '\''],
992    keywords: &[
993        "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
994        "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
995        "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with",
996        "yield", "match", "case",
997    ],
998    types: &["int", "float", "str", "bool", "list", "dict", "set", "tuple", "bytes", "object"],
999    constants: &["True", "False", "None", "self", "cls"],
1000    hash_line: false,
1001    hash_bracket: false,
1002    at_line: true,
1003    rust_strings: false,
1004    python_strings: true,
1005};
1006
1007const SHELL: Syntax = Syntax {
1008    line_comments: &["#"],
1009    block_comment: None,
1010    quotes: &['"', '\''],
1011    keywords: &[
1012        "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac",
1013        "in", "function", "select", "time", "coproc", "local", "export", "readonly", "declare",
1014        "unset", "shift", "return", "continue", "break", "source", "alias",
1015    ],
1016    types: &["echo", "cd", "pwd", "printf", "read", "set", "test", "trap", "eval", "exec"],
1017    constants: &["true", "false"],
1018    hash_line: false,
1019    hash_bracket: false,
1020    at_line: false,
1021    rust_strings: false,
1022    python_strings: false,
1023};
1024
1025const GO: Syntax = Syntax {
1026    line_comments: &["//"],
1027    block_comment: Some(("/*", "*/")),
1028    quotes: &['"', '\'', '`'],
1029    keywords: &[
1030        "break", "case", "chan", "const", "continue", "default", "defer", "else", "fallthrough",
1031        "for", "func", "go", "goto", "if", "import", "interface", "map", "package", "range",
1032        "return", "select", "struct", "switch", "type", "var",
1033    ],
1034    types: &[
1035        "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16",
1036        "uint32", "uint64", "uintptr", "byte", "rune", "float32", "float64", "complex64",
1037        "complex128", "error", "any",
1038    ],
1039    constants: &["true", "false", "nil", "iota"],
1040    hash_line: false,
1041    hash_bracket: false,
1042    at_line: false,
1043    rust_strings: false,
1044    python_strings: false,
1045};
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    fn classes(spans: &[Span]) -> Vec<TokenClass> {
1052        spans.iter().map(|s| s.class).collect()
1053    }
1054
1055    fn text_of(src: &str, spans: &[Span]) -> Vec<String> {
1056        let chars: Vec<char> = src.chars().collect();
1057        spans
1058            .iter()
1059            .map(|s| chars[s.start..s.end].iter().collect())
1060            .collect()
1061    }
1062
1063    #[test]
1064    fn detects_languages_by_extension() {
1065        assert_eq!(language_for(Path::new("a.rs")), Language::Rust);
1066        assert_eq!(language_for(Path::new("a.PY")), Language::Python);
1067        assert_eq!(language_for(Path::new("a.toml")), Language::KeyValue);
1068        assert_eq!(language_for(Path::new("a.bin")), Language::Plain);
1069        assert_eq!(language_for(Path::new("noext")), Language::Plain);
1070    }
1071
1072    #[test]
1073    fn rust_highlights_keywords_strings_comments_attributes() {
1074        let src = "// hi\n#[derive(Debug)]\nfn main() { let s = \"x\"; let n = 42; }";
1075        let spans = highlight(Language::Rust, src);
1076        let found = text_of(src, &spans);
1077        assert!(found.iter().any(|t| t == "// hi"));
1078        assert!(found.iter().any(|t| t == "#[derive(Debug)]"));
1079        assert!(found.iter().any(|t| t == "fn"));
1080        assert!(found.iter().any(|t| t == "\"x\""));
1081        assert!(found.iter().any(|t| t == "42"));
1082        assert!(classes(&spans).contains(&TokenClass::Keyword));
1083        assert!(classes(&spans).contains(&TokenClass::Preprocessor));
1084    }
1085
1086    #[test]
1087    fn python_highlights_decorators_and_triple_strings() {
1088        let src = "@decorator\ndef f():\n    x = \"\"\"multi\nline\"\"\"\n    return 1";
1089        let spans = highlight(Language::Python, src);
1090        let found = text_of(src, &spans);
1091        assert!(found.iter().any(|t| t == "@decorator"));
1092        assert!(found.iter().any(|t| t.contains("multi")));
1093        assert!(found.iter().any(|t| t == "def"));
1094    }
1095
1096    #[test]
1097    fn json_distinguishes_keys_from_values() {
1098        let src = "{ \"name\": \"value\", \"n\": 1 }";
1099        let spans = highlight(Language::Json, src);
1100        let key = spans
1101            .iter()
1102            .find(|s| s.class == TokenClass::Attribute)
1103            .map(|s| text_of(src, std::slice::from_ref(s)).remove(0));
1104        assert_eq!(key.as_deref(), Some("\"name\""));
1105    }
1106
1107    #[test]
1108    fn toml_highlights_sections_and_keys() {
1109        let src = "[panel]\nwidth = 300\nname = \"tree\"\n";
1110        let spans = highlight(Language::KeyValue, src);
1111        let found = text_of(src, &spans);
1112        assert!(found.iter().any(|t| t == "[panel]"));
1113        assert!(found.iter().any(|t| t == "width"));
1114        assert!(found.iter().any(|t| t == "300"));
1115        assert!(found.iter().any(|t| t == "\"tree\""));
1116    }
1117
1118    #[test]
1119    fn yaml_highlights_keys() {
1120        let src = "name: value\nlist:\n  - one\n";
1121        let spans = highlight(Language::Yaml, src);
1122        let found = text_of(src, &spans);
1123        assert!(found.iter().any(|t| t == "name"));
1124        assert!(found.iter().any(|t| t == "list"));
1125    }
1126
1127    #[test]
1128    fn markup_highlights_tags_and_attributes() {
1129        let src = "<a href=\"x\">text</a>";
1130        let spans = highlight(Language::Markup, src);
1131        let found = text_of(src, &spans);
1132        assert!(found.iter().any(|t| t == "a"));
1133        assert!(found.iter().any(|t| t == "href"));
1134        assert!(found.iter().any(|t| t == "\"x\""));
1135    }
1136
1137    #[test]
1138    fn css_highlights_at_rules_and_properties() {
1139        let src = "/* c */\n.foo { color: #fff; }";
1140        let spans = highlight(Language::Css, src);
1141        let found = text_of(src, &spans);
1142        assert!(found.iter().any(|t| t == "/* c */"));
1143        assert!(found.iter().any(|t| t == "color"));
1144        assert!(found.iter().any(|t| t == "#fff"));
1145    }
1146
1147    #[test]
1148    fn markdown_highlights_headings_code_and_links() {
1149        let src = "# Title\n\nSee [docs](http://x) and `code`.\n";
1150        let spans = highlight(Language::Markdown, src);
1151        let found = text_of(src, &spans);
1152        assert!(found.iter().any(|t| t == "# Title"));
1153        assert!(found.iter().any(|t| t == "[docs](http://x)"));
1154        assert!(found.iter().any(|t| t == "`code`"));
1155    }
1156
1157    #[test]
1158    fn spans_are_sorted_bounded_and_non_overlapping() {
1159        let src = "fn f() { // c\n    \"s\" 1 }";
1160        let spans = highlight(Language::CLike, src);
1161        let mut last = 0;
1162        for span in &spans {
1163            assert!(span.start < span.end, "empty span: {span:?}");
1164            assert!(span.start >= last, "overlap/unsorted at {span:?}");
1165            last = span.end;
1166        }
1167        assert!(spans.iter().all(|s| s.end <= src.chars().count()));
1168    }
1169
1170    #[test]
1171    fn plain_language_has_no_spans() {
1172        assert!(highlight(Language::Plain, "anything at all").is_empty());
1173    }
1174}