OXYGENHUB
◆ v0.9.0 oxygenhub / crates / hub / src / render / highlight.rs
6.0 KiB · Rust · last changed A minified file is shown plain instead of highlighted for seconds 18 days ago source blame history raw
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
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
40
/// Highlight `text`, returning one HTML fragment per line (no trailing
41
/// newline); plain escaped lines when the language is unknown. Cached by
42
/// the file's name (which picks the syntax) and its text.
43
pub fn lines(path: &str, text: &str) -> Vec<String> {
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
/// The longest line highlighted. syntect's time grows much faster than a
50
/// line's length: a minified 860 KB script of 31 lines took 6.2 s to
51
/// highlight, every time the hub had restarted, for a page nobody reads as
52
/// code. A text with a longer line is shown plain, as other forges do.
53
pub const MAX_HIGHLIGHTED_LINE: usize = 4_000;
54
55
fn lines_uncached(path: &str, text: &str) -> Vec<String> {
56
    if text.lines().any(|l| l.len() > MAX_HIGHLIGHTED_LINE) {
57
        return text.lines().map(super::escape).collect();
58
    }
59
    let set = syntaxes();
60
    let ext = path.rsplit('.').next().unwrap_or("");
61
    let name = path.rsplit('/').next().unwrap_or(path);
62
    let syntax = set.find_syntax_by_extension(ext).or_else(|| set.find_syntax_by_extension(name)).or_else(|| match name {
63
        "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
64
        _ => None,
65
    });
66
    let Some(syntax) = syntax else {
67
        return text.lines().map(super::escape).collect();
68
    };
69
    let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::SpacedPrefixed { prefix: "sy-" });
70
    let mut out = Vec::new();
71
    for line in LinesWithEndings::from(text) {
72
        if generator.parse_html_for_line_which_includes_newline(line).is_err() {
73
            return text.lines().map(super::escape).collect();
74
        }
75
    }
76
    let html = generator.finalize();
77
    // syntect emits the whole file with spans that may span lines; split
78
    // on newlines and keep spans balanced per line.
79
    let mut open: Vec<String> = Vec::new();
80
    let mut raw_lines: Vec<&str> = html.split('\n').collect();
81
    // syntect closes the outermost spans after the final newline; that
82
    // segment holds no text and every span is balanced per line below.
83
    if raw_lines.last().is_some_and(|l| l.replace("</span>", "").trim().is_empty()) {
84
        raw_lines.pop();
85
    }
86
    for raw in raw_lines {
87
        let mut line = String::new();
88
        for tag in &open {
89
            line.push_str(tag);
90
        }
91
        line.push_str(raw);
92
        // Track spans opened and closed on this line.
93
        let mut i = 0;
94
        while let Some(p) = raw[i..].find('<') {
95
            let start = i + p;
96
            let Some(end_rel) = raw[start..].find('>') else { break };
97
            let tag = &raw[start..start + end_rel + 1];
98
            if tag.starts_with("</") {
99
                open.pop();
100
            } else if tag.starts_with("<span") {
101
                open.push(tag.to_string());
102
            }
103
            i = start + end_rel + 1;
104
        }
105
        for _ in &open {
106
            line.push_str("</span>");
107
        }
108
        out.push(line);
109
    }
110
    out
111
}
112
113
#[cfg(test)]
114
mod tests {
115
    use super::*;
116
117
    #[test]
118
    fn highlights_rust_and_falls_back() {
119
        let l = lines("main.rs", "fn main() {\n    let x = 1;\n}\n");
120
        assert_eq!(l.len(), 3);
121
        assert!(l[0].contains("<span class=\"sy-source sy-rust\">"), "{}", l[0]);
122
        assert!(l[0].contains("sy-storage sy-type sy-function"), "{}", l[0]);
123
        // Scope names never reach the page as bare classes (`comment` is also a page class).
124
        let c = lines("main.rs", "// note\n");
125
        assert!(c[0].contains("sy-comment") && !c[0].contains("\"comment") && !c[0].contains(" comment"), "{}", c[0]);
126
        assert!(l[1].ends_with("</span>"), "spans are balanced per line: {}", l[1]);
127
        assert_eq!(lines("x.unknownext", "a<b\nc"), vec!["a&lt;b", "c"]);
128
        assert_eq!(language("a/b/lib.rs"), Some("Rust"));
129
        assert_eq!(language("Makefile"), Some("Makefile"));
130
        assert_eq!(language("blob.bin"), None);
131
    }
132
133
    /// A minified file is shown plain, at once, its lines still escaped.
134
    #[test]
135
    fn a_text_with_a_very_long_line_is_shown_plain() {
136
        let long = format!("var a=\"<\";{}\nb();\n", "x+".repeat(MAX_HIGHLIGHTED_LINE));
137
        let started = std::time::Instant::now();
138
        let l = lines("app.min.js", &long);
139
        assert!(started.elapsed() < std::time::Duration::from_millis(500), "{:?}", started.elapsed());
140
        assert_eq!(l.len(), 2);
141
        assert!(l[0].starts_with("var a=&quot;&lt;&quot;;") && !l[0].contains("<span"), "{}", &l[0][..40]);
142
        assert!(lines("app.js", "var a = 1;\n")[0].contains("<span"), "a normal file is highlighted");
143
    }
144
}