OXYGENHUB

Coverage / crates/hub/src/render/highlight.rs 99.1%

106 of 107 lines run, 29 of 32 functions · run #525 on main · run missed

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
975×
fn syntaxes() -> &'static SyntaxSet {
10
    static SET: OnceLock<SyntaxSet> = OnceLock::new();
1175×
    SET.get_or_init(SyntaxSet::load_defaults_newlines)
1275×
}
13
14
/// The language name shown for a file, if syntect knows the extension.
1560×
pub fn language(path: &str) -> Option<&'static str> {
1660×
    let ext = path.rsplit('.').next().unwrap_or("");
1760×
    let name = path.rsplit('/').next().unwrap_or(path);
1860×
    let set = syntaxes();
1960×
    set.find_syntax_by_extension(ext)
2060×
        .or_else(|| set.find_syntax_by_extension(name))
2160×
        .or_else(|| match name {
224×
            "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
234×
            "Dockerfile" => set.find_syntax_by_name("Dockerfile"),
244×
            _ => None,
254×
        })
2660×
        .map(|s| s.name.as_str())
2760×
}
28
29
/// Highlighted files by (file name, text): 64 MiB.
3044×
fn cache() -> &'static super::cache::Lru<Vec<String>> {
31
    static C: OnceLock<super::cache::Lru<Vec<String>>> = OnceLock::new();
3244×
    C.get_or_init(|| super::cache::Lru::new(64 << 20))
3344×
}
34
35
/// (hits, misses, bytes) of the highlighted file cache.
362×
pub fn cache_stats() -> (u64, u64, usize) {
372×
    cache().stats()
382×
}
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.
4342×
pub fn lines(path: &str, text: &str) -> Vec<String> {
4442×
    let name = path.rsplit('/').next().unwrap_or(path);
4542×
    let key = super::cache::key(&[b"hl1", name.as_bytes(), text.as_bytes()]);
4642×
    cache().get_or(key, || lines_uncached(path, text)).as_ref().clone()
4742×
}
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
5516×
fn lines_uncached(path: &str, text: &str) -> Vec<String> {
5648×
    if text.lines().any(|l| l.len() > MAX_HIGHLIGHTED_LINE) {
571×
        return text.lines().map(super::escape).collect();
5815×
    }
5915×
    let set = syntaxes();
6015×
    let ext = path.rsplit('.').next().unwrap_or("");
6115×
    let name = path.rsplit('/').next().unwrap_or(path);
6215×
    let syntax = set.find_syntax_by_extension(ext).or_else(|| set.find_syntax_by_extension(name)).or_else(|| match name {
631×
        "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
641×
        _ => None,
651×
    });
6615×
    let Some(syntax) = syntax else {
671×
        return text.lines().map(super::escape).collect();
68
    };
6914×
    let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::SpacedPrefixed { prefix: "sy-" });
7014×
    let mut out = Vec::new();
7145×
    for line in LinesWithEndings::from(text) {
7245×
        if generator.parse_html_for_line_which_includes_newline(line).is_err() {
730×
            return text.lines().map(super::escape).collect();
7445×
        }
75
    }
7614×
    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.
7914×
    let mut open: Vec<String> = Vec::new();
8014×
    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.
8314×
    if raw_lines.last().is_some_and(|l| l.replace("</span>", "").trim().is_empty()) {
8412×
        raw_lines.pop();
8512×
    }
8645×
    for raw in raw_lines {
8745×
        let mut line = String::new();
8875×
        for tag in &open {
8975×
            line.push_str(tag);
9075×
        }
9145×
        line.push_str(raw);
92
        // Track spans opened and closed on this line.
9345×
        let mut i = 0;
94540×
        while let Some(p) = raw[i..].find('<') {
95495×
            let start = i + p;
96495×
            let Some(end_rel) = raw[start..].find('>') else { break };
97495×
            let tag = &raw[start..start + end_rel + 1];
98495×
            if tag.starts_with("</") {
99241×
                open.pop();
100254×
            } else if tag.starts_with("<span") {
101254×
                open.push(tag.to_string());
102254×
            }
103495×
            i = start + end_rel + 1;
104
        }
10588×
        for _ in &open {
10688×
            line.push_str("</span>");
10788×
        }
10845×
        out.push(line);
109
    }
11014×
    out
11116×
}
112
113
#[cfg(test)]
114
mod tests {
115
    use super::*;
116
117
    #[test]
1181×
    fn highlights_rust_and_falls_back() {
1191×
        let l = lines("main.rs", "fn main() {\n    let x = 1;\n}\n");
1201×
        assert_eq!(l.len(), 3);
1211×
        assert!(l[0].contains("<span class=\"sy-source sy-rust\">"), "{}", l[0]);
1221×
        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).
1241×
        let c = lines("main.rs", "// note\n");
1251×
        assert!(c[0].contains("sy-comment") && !c[0].contains("\"comment") && !c[0].contains(" comment"), "{}", c[0]);
1261×
        assert!(l[1].ends_with("</span>"), "spans are balanced per line: {}", l[1]);
1271×
        assert_eq!(lines("x.unknownext", "a<b\nc"), vec!["a&lt;b", "c"]);
1281×
        assert_eq!(language("a/b/lib.rs"), Some("Rust"));
1291×
        assert_eq!(language("Makefile"), Some("Makefile"));
1301×
        assert_eq!(language("blob.bin"), None);
1311×
    }
132
133
    /// A minified file is shown plain, at once, its lines still escaped.
134
    #[test]
1351×
    fn a_text_with_a_very_long_line_is_shown_plain() {
1361×
        let long = format!("var a=\"<\";{}\nb();\n", "x+".repeat(MAX_HIGHLIGHTED_LINE));
1371×
        let started = std::time::Instant::now();
1381×
        let l = lines("app.min.js", &long);
139
        // Highlighting it would take far longer; a debug build on a busy
140
        // machine takes some of this bound for reading it plain (#327).
1411×
        let bound = std::time::Duration::from_millis(if cfg!(debug_assertions) { 3000 } else { 500 });
1421×
        assert!(started.elapsed() < bound, "{:?}", started.elapsed());
1431×
        assert_eq!(l.len(), 2);
1441×
        assert!(l[0].starts_with("var a=&quot;&lt;&quot;;") && !l[0].contains("<span"), "{}", &l[0][..40]);
1451×
        assert!(lines("app.js", "var a = 1;\n")[0].contains("<span"), "a normal file is highlighted");
1461×
    }
147
}