OXYGENHUB
◆ v0.2.0 oxygenhub / crates / hub / src / render / highlight.rs
4.9 KiB · Rust · last changed Cache rendered Markdown and highlighted files by content, in memory-bounded LRUs 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Syntax highlighting with syntect, emitted as classed spans so the
2
//! stylesheet colours them in both themes (`templates/style.css`).
3
4
use std::sync::OnceLock;
5
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
6
use syntect::parsing::SyntaxSet;
7
use syntect::util::LinesWithEndings;
8
9
fn syntaxes() -> &'static SyntaxSet {
10
    static SET: OnceLock<SyntaxSet> = OnceLock::new();
11
    SET.get_or_init(SyntaxSet::load_defaults_newlines)
12
}
13
14
/// The language name shown for a file, if syntect knows the extension.
15
pub fn language(path: &str) -> Option<&'static str> {
16
    let ext = path.rsplit('.').next().unwrap_or("");
17
    let name = path.rsplit('/').next().unwrap_or(path);
18
    let set = syntaxes();
19
    set.find_syntax_by_extension(ext)
20
        .or_else(|| set.find_syntax_by_extension(name))
21
        .or_else(|| match name {
22
            "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
23
            "Dockerfile" => set.find_syntax_by_name("Dockerfile"),
24
            _ => None,
25
        })
26
        .map(|s| s.name.as_str())
27
}
28
f5c44337 Andy McCall ↶ 29
/// Highlighted files by (file name, text): 64 MiB.
30
fn cache() -> &'static super::cache::Lru<Vec<String>> {
31
    static C: OnceLock<super::cache::Lru<Vec<String>>> = OnceLock::new();
32
    C.get_or_init(|| super::cache::Lru::new(64 << 20))
33
}
34
35
/// (hits, misses, bytes) of the highlighted file cache.
36
pub fn cache_stats() -> (u64, u64, usize) {
37
    cache().stats()
38
}
39
d23464c0 Andy McCall ↶ 40
/// Highlight `text`, returning one HTML fragment per line (no trailing
f5c44337 Andy McCall ↶ 41
/// newline); plain escaped lines when the language is unknown. Cached by
42
/// the file's name (which picks the syntax) and its text.
d23464c0 Andy McCall ↶ 43
pub fn lines(path: &str, text: &str) -> Vec<String> {
f5c44337 Andy McCall ↶ 44
    let name = path.rsplit('/').next().unwrap_or(path);
45
    let key = super::cache::key(&[b"hl1", name.as_bytes(), text.as_bytes()]);
46
    cache().get_or(key, || lines_uncached(path, text)).as_ref().clone()
47
}
48
49
fn lines_uncached(path: &str, text: &str) -> Vec<String> {
d23464c0 Andy McCall ↶ 50
    let set = syntaxes();
51
    let ext = path.rsplit('.').next().unwrap_or("");
52
    let name = path.rsplit('/').next().unwrap_or(path);
53
    let syntax = set.find_syntax_by_extension(ext).or_else(|| set.find_syntax_by_extension(name)).or_else(|| match name {
54
        "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
55
        _ => None,
56
    });
57
    let Some(syntax) = syntax else {
58
        return text.lines().map(super::escape).collect();
59
    };
c40c2d3e Andy McCall ↶ 60
    let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::SpacedPrefixed { prefix: "sy-" });
d23464c0 Andy McCall ↶ 61
    let mut out = Vec::new();
62
    for line in LinesWithEndings::from(text) {
63
        if generator.parse_html_for_line_which_includes_newline(line).is_err() {
64
            return text.lines().map(super::escape).collect();
65
        }
66
    }
67
    let html = generator.finalize();
68
    // syntect emits the whole file with spans that may span lines; split
69
    // on newlines and keep spans balanced per line.
70
    let mut open: Vec<String> = Vec::new();
71
    let mut raw_lines: Vec<&str> = html.split('\n').collect();
72
    // syntect closes the outermost spans after the final newline; that
73
    // segment holds no text and every span is balanced per line below.
74
    if raw_lines.last().is_some_and(|l| l.replace("</span>", "").trim().is_empty()) {
75
        raw_lines.pop();
76
    }
77
    for raw in raw_lines {
78
        let mut line = String::new();
79
        for tag in &open {
80
            line.push_str(tag);
81
        }
82
        line.push_str(raw);
83
        // Track spans opened and closed on this line.
84
        let mut i = 0;
85
        while let Some(p) = raw[i..].find('<') {
86
            let start = i + p;
87
            let Some(end_rel) = raw[start..].find('>') else { break };
88
            let tag = &raw[start..start + end_rel + 1];
89
            if tag.starts_with("</") {
90
                open.pop();
91
            } else if tag.starts_with("<span") {
92
                open.push(tag.to_string());
93
            }
94
            i = start + end_rel + 1;
95
        }
96
        for _ in &open {
97
            line.push_str("</span>");
98
        }
99
        out.push(line);
100
    }
101
    out
102
}
103
104
#[cfg(test)]
105
mod tests {
106
    use super::*;
107
108
    #[test]
109
    fn highlights_rust_and_falls_back() {
110
        let l = lines("main.rs", "fn main() {\n    let x = 1;\n}\n");
111
        assert_eq!(l.len(), 3);
c40c2d3e Andy McCall ↶ 112
        assert!(l[0].contains("<span class=\"sy-source sy-rust\">"), "{}", l[0]);
113
        assert!(l[0].contains("sy-storage sy-type sy-function"), "{}", l[0]);
114
        // Scope names never reach the page as bare classes (`comment` is also a page class).
115
        let c = lines("main.rs", "// note\n");
116
        assert!(c[0].contains("sy-comment") && !c[0].contains("\"comment") && !c[0].contains(" comment"), "{}", c[0]);
d23464c0 Andy McCall ↶ 117
        assert!(l[1].ends_with("</span>"), "spans are balanced per line: {}", l[1]);
118
        assert_eq!(lines("x.unknownext", "a<b\nc"), vec!["a&lt;b", "c"]);
119
        assert_eq!(language("a/b/lib.rs"), Some("Rust"));
120
        assert_eq!(language("Makefile"), Some("Makefile"));
121
        assert_eq!(language("blob.bin"), None);
122
    }
123
}