OXYGENHUB
# a0d4bbfbedd1 oxygenhub / crates / hub / src / render / highlight.rs
4.2 KiB · Rust · last changed Theme switch, auto theme, and a highlighting fix 1 month 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
/// Highlight `text`, returning one HTML fragment per line (no trailing
30
/// newline); plain escaped lines when the language is unknown.
31
pub fn lines(path: &str, text: &str) -> Vec<String> {
32
    let set = syntaxes();
33
    let ext = path.rsplit('.').next().unwrap_or("");
34
    let name = path.rsplit('/').next().unwrap_or(path);
35
    let syntax = set.find_syntax_by_extension(ext).or_else(|| set.find_syntax_by_extension(name)).or_else(|| match name {
36
        "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"),
37
        _ => None,
38
    });
39
    let Some(syntax) = syntax else {
40
        return text.lines().map(super::escape).collect();
41
    };
42
    let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::SpacedPrefixed { prefix: "sy-" });
43
    let mut out = Vec::new();
44
    for line in LinesWithEndings::from(text) {
45
        if generator.parse_html_for_line_which_includes_newline(line).is_err() {
46
            return text.lines().map(super::escape).collect();
47
        }
48
    }
49
    let html = generator.finalize();
50
    // syntect emits the whole file with spans that may span lines; split
51
    // on newlines and keep spans balanced per line.
52
    let mut open: Vec<String> = Vec::new();
53
    let mut raw_lines: Vec<&str> = html.split('\n').collect();
54
    // syntect closes the outermost spans after the final newline; that
55
    // segment holds no text and every span is balanced per line below.
56
    if raw_lines.last().is_some_and(|l| l.replace("</span>", "").trim().is_empty()) {
57
        raw_lines.pop();
58
    }
59
    for raw in raw_lines {
60
        let mut line = String::new();
61
        for tag in &open {
62
            line.push_str(tag);
63
        }
64
        line.push_str(raw);
65
        // Track spans opened and closed on this line.
66
        let mut i = 0;
67
        while let Some(p) = raw[i..].find('<') {
68
            let start = i + p;
69
            let Some(end_rel) = raw[start..].find('>') else { break };
70
            let tag = &raw[start..start + end_rel + 1];
71
            if tag.starts_with("</") {
72
                open.pop();
73
            } else if tag.starts_with("<span") {
74
                open.push(tag.to_string());
75
            }
76
            i = start + end_rel + 1;
77
        }
78
        for _ in &open {
79
            line.push_str("</span>");
80
        }
81
        out.push(line);
82
    }
83
    out
84
}
85
86
#[cfg(test)]
87
mod tests {
88
    use super::*;
89
90
    #[test]
91
    fn highlights_rust_and_falls_back() {
92
        let l = lines("main.rs", "fn main() {\n    let x = 1;\n}\n");
93
        assert_eq!(l.len(), 3);
94
        assert!(l[0].contains("<span class=\"sy-source sy-rust\">"), "{}", l[0]);
95
        assert!(l[0].contains("sy-storage sy-type sy-function"), "{}", l[0]);
96
        // Scope names never reach the page as bare classes (`comment` is also a page class).
97
        let c = lines("main.rs", "// note\n");
98
        assert!(c[0].contains("sy-comment") && !c[0].contains("\"comment") && !c[0].contains(" comment"), "{}", c[0]);
99
        assert!(l[1].ends_with("</span>"), "spans are balanced per line: {}", l[1]);
100
        assert_eq!(lines("x.unknownext", "a<b\nc"), vec!["a&lt;b", "c"]);
101
        assert_eq!(language("a/b/lib.rs"), Some("Rust"));
102
        assert_eq!(language("Makefile"), Some("Makefile"));
103
        assert_eq!(language("blob.bin"), None);
104
    }
105
}