facett-git 0.1.11

facett — git repository facet: browse branches, commit log, the file tree, and syntax-coloured source, all clickable. gix-backed (optional feature), egui-rendered. A consumer (nornir) drops it in to show git for any repo.
Documentation
//! **Deterministic Rust source syntax highlighter** → coloured spans an egui
//! `LayoutJob` can render. Pure (no egui, no I/O), so it is unit-tested without a
//! window and produces bit-identical spans for the same input (FC-7). A tiny
//! hand-written tokenizer — not a full parser — classifying the things that matter
//! for reading code: keywords, types, strings/chars, line+block comments, numbers,
//! attributes, and punctuation.

/// The syntactic class of a span — maps to a theme colour at render time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// Plain identifier / whitespace / default text.
    Text,
    /// A language keyword (`fn`, `let`, `match`, …).
    Keyword,
    /// A type-ish name (UpperCamelCase, or a primitive like `u32`).
    Type,
    /// A string or char literal (including the quotes).
    Str,
    /// A numeric literal.
    Number,
    /// A `//` line comment or `/* */` block comment.
    Comment,
    /// An `#[attribute]` / `#![inner]`.
    Attribute,
    /// Punctuation / operators.
    Punct,
}

/// A coloured run: a byte range `[start, end)` into the source + its [`Kind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
    pub start: usize,
    pub end: usize,
    pub kind: Kind,
}

const KEYWORDS: &[&str] = &[
    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
    "use", "where", "while",
];

const PRIMITIVES: &[&str] = &[
    "bool", "char", "str", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64",
    "i128", "isize", "f32", "f64",
];

fn is_ident_start(c: char) -> bool {
    c == '_' || c.is_ascii_alphabetic()
}
fn is_ident_continue(c: char) -> bool {
    c == '_' || c.is_ascii_alphanumeric()
}

/// Tokenize Rust `src` into a contiguous, ordered list of coloured [`Span`]s
/// covering every byte (the concatenation of span ranges == the input). ASCII-byte
/// driven; multi-byte UTF-8 inside a token is preserved (only token boundaries are
/// decided on ASCII).
#[must_use]
pub fn highlight(src: &str) -> Vec<Span> {
    let b = src.as_bytes();
    let n = b.len();
    let mut out: Vec<Span> = Vec::new();
    let mut i = 0;
    let push = |out: &mut Vec<Span>, s: usize, e: usize, k: Kind| {
        if e > s {
            out.push(Span { start: s, end: e, kind: k });
        }
    };
    while i < n {
        let c = b[i] as char;
        // line comment
        if c == '/' && i + 1 < n && b[i + 1] == b'/' {
            let s = i;
            while i < n && b[i] != b'\n' {
                i += 1;
            }
            push(&mut out, s, i, Kind::Comment);
            continue;
        }
        // block comment (non-nested is fine for colouring)
        if c == '/' && i + 1 < n && b[i + 1] == b'*' {
            let s = i;
            i += 2;
            while i + 1 < n && !(b[i] == b'*' && b[i + 1] == b'/') {
                i += 1;
            }
            i = (i + 2).min(n);
            push(&mut out, s, i, Kind::Comment);
            continue;
        }
        // string literal
        if c == '"' {
            let s = i;
            i += 1;
            while i < n {
                if b[i] == b'\\' {
                    i += 2;
                    continue;
                }
                if b[i] == b'"' {
                    i += 1;
                    break;
                }
                i += 1;
            }
            push(&mut out, s, i.min(n), Kind::Str);
            continue;
        }
        // char literal (heuristic: '\?' or 'x' then ')
        if c == '\'' && i + 1 < n {
            let s = i;
            let mut j = i + 1;
            if b[j] == b'\\' {
                j += 2;
            } else {
                j += 1;
            }
            if j < n && b[j] == b'\'' {
                j += 1;
                push(&mut out, s, j, Kind::Str);
                i = j;
                continue;
            }
            // else fall through (it's a lifetime/label) — treat the quote as punct
        }
        // attribute #[...] or #![...]
        if c == '#' && i + 1 < n && (b[i + 1] == b'[' || b[i + 1] == b'!') {
            let s = i;
            // consume to the matching ] (shallow)
            while i < n && b[i] != b'[' {
                i += 1;
            }
            let mut depth = 0i32;
            while i < n {
                if b[i] == b'[' {
                    depth += 1;
                } else if b[i] == b']' {
                    depth -= 1;
                    if depth == 0 {
                        i += 1;
                        break;
                    }
                }
                i += 1;
            }
            push(&mut out, s, i.min(n), Kind::Attribute);
            continue;
        }
        // number
        if c.is_ascii_digit() {
            let s = i;
            while i < n {
                let d = b[i] as char;
                if d.is_ascii_alphanumeric() || d == '_' || d == '.' {
                    i += 1;
                } else {
                    break;
                }
            }
            push(&mut out, s, i, Kind::Number);
            continue;
        }
        // identifier / keyword / type
        if is_ident_start(c) {
            let s = i;
            while i < n && is_ident_continue(b[i] as char) {
                i += 1;
            }
            let word = &src[s..i];
            let kind = if KEYWORDS.contains(&word) {
                Kind::Keyword
            } else if PRIMITIVES.contains(&word) || word.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
                Kind::Type
            } else {
                Kind::Text
            };
            push(&mut out, s, i, kind);
            continue;
        }
        // whitespace → Text run
        if c.is_ascii_whitespace() {
            let s = i;
            while i < n && (b[i] as char).is_ascii_whitespace() {
                i += 1;
            }
            push(&mut out, s, i, Kind::Text);
            continue;
        }
        // punctuation (single byte)
        push(&mut out, i, i + 1, Kind::Punct);
        i += 1;
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn kinds_of<'a>(src: &'a str, spans: &[Span]) -> Vec<(&'a str, Kind)> {
        spans.iter().map(|s| (&src[s.start..s.end], s.kind)).collect()
    }

    /// INJECT-ASSERT: spans cover every byte, in order, with no gaps/overlaps —
    /// the highlighter never drops source (a render invariant).
    #[test]
    fn spans_tile_the_whole_source() {
        let src = "fn main() { let x = 42; }";
        let spans = highlight(src);
        assert!(!spans.is_empty());
        assert_eq!(spans.first().unwrap().start, 0);
        assert_eq!(spans.last().unwrap().end, src.len());
        for w in spans.windows(2) {
            assert_eq!(w[0].end, w[1].start, "contiguous, no gap/overlap");
        }
    }

    /// INJECT-ASSERT: keywords, types, numbers, strings, comments, attributes each
    /// get the right class.
    #[test]
    fn classifies_the_main_token_kinds() {
        let src = "#[derive(Clone)]\nfn f() -> u32 { let s = \"hi\"; /* c */ return 7; } // tail";
        let got = kinds_of(src, &highlight(src));
        let find = |needle: &str, k: Kind| got.iter().any(|(t, kk)| *t == needle && *kk == k);
        assert!(find("fn", Kind::Keyword), "fn is a keyword");
        assert!(find("let", Kind::Keyword) && find("return", Kind::Keyword));
        assert!(find("u32", Kind::Type), "u32 is a primitive type");
        assert!(find("Clone", Kind::Type) || got.iter().any(|(t, k)| t.contains("Clone") && *k == Kind::Attribute));
        assert!(got.iter().any(|(t, k)| *t == "\"hi\"" && *k == Kind::Str), "string incl quotes");
        assert!(got.iter().any(|(t, k)| t.contains("/* c */") && *k == Kind::Comment));
        assert!(got.iter().any(|(t, k)| t.contains("// tail") && *k == Kind::Comment));
        assert!(got.iter().any(|(t, k)| *t == "7" && *k == Kind::Number));
        assert!(got.iter().any(|(t, k)| t.starts_with("#[derive") && *k == Kind::Attribute));
    }

    /// INJECT-ASSERT (FC-7): highlighting is deterministic.
    #[test]
    fn deterministic() {
        let src = "struct S { a: Vec<u8> }";
        assert_eq!(highlight(src), highlight(src));
    }

    /// INJECT-ASSERT: an empty source yields no spans (honest empty).
    #[test]
    fn empty_is_empty() {
        assert!(highlight("").is_empty());
    }
}