forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.5 KiB
1//! syntect class-based highlighting (see docs/m2-interfaces.md).
2//!
3//! Output is class-based (`hl-` prefix) and never carries inline styles, so the
4//! CSP without 'unsafe-inline' is respected. Each source line becomes one HTML
5//! fragment whose spans are balanced: scopes that continue across a line break
6//! are closed at the end of the line and reopened at the start of the next.
7
8// Nothing outside the tests calls this module until the blob view is wired up.
9#![allow(dead_code)]
10
11use std::sync::LazyLock;
12
13use syntect::highlighting::ThemeSet;
14use syntect::html::{ClassStyle, css_for_theme_with_class_style, line_tokens_to_classed_spans};
15use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet};
16use syntect::util::LinesWithEndings;
17
18/// Inputs above this many bytes are not highlighted.
19const MAX_BYTES: usize = 512 * 1024;
20/// Inputs above this many lines are not highlighted.
21const MAX_LINES: usize = 20_000;
22const CLASS_PREFIX: &str = "hl-";
23const LIGHT_THEME: &str = "InspiredGitHub";
24const DARK_THEME: &str = "base16-ocean.dark";
25/// Scope for the dark rules when the page has no explicit theme.
26const DARK_WHEN_AUTO: &str = ":root:not([data-theme=\"light\"])";
27/// Scope for the dark rules when the user picked the dark theme.
28const DARK_WHEN_FORCED: &str = ":root[data-theme=\"dark\"]";
29
30// Newline-aware syntaxes are required because input is fed through LinesWithEndings.
31static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
32static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
33static CSS: LazyLock<String> = LazyLock::new(build_css);
34
35fn class_style() -> ClassStyle {
36 ClassStyle::SpacedPrefixed {
37 prefix: CLASS_PREFIX,
38 }
39}
40
41fn find_syntax(file_name: &str, first_line: &str) -> Option<&'static SyntaxReference> {
42 let ss: &'static SyntaxSet = &SYNTAX_SET;
43 file_name
44 .rsplit_once('.')
45 .and_then(|(_, ext)| ss.find_syntax_by_extension(ext))
46 .or_else(|| ss.find_syntax_by_extension(file_name))
47 .or_else(|| ss.find_syntax_by_first_line(first_line))
48}
49
50/// Highlights `text` as the language implied by `file_name` (extension, then
51/// full file name, then shebang/first line). Returns one HTML fragment per
52/// line without the trailing newline, or `None` when the language is unknown
53/// or plain text, or the input exceeds the size limits.
54pub fn highlight_lines(file_name: &str, text: &str) -> Option<Vec<String>> {
55 if text.len() > MAX_BYTES {
56 return None;
57 }
58 let line_count = text.lines().count();
59 if line_count > MAX_LINES {
60 return None;
61 }
62 let first_line = text.lines().next().unwrap_or("");
63 let syntax = find_syntax(file_name, first_line)?;
64 if syntax.name == "Plain Text" {
65 return None;
66 }
67
68 let ss: &SyntaxSet = &SYNTAX_SET;
69 let style = class_style();
70 let mut parse_state = ParseState::new(syntax);
71 // Holds the scopes that are still open at the start of the next line.
72 let mut stack = ScopeStack::new();
73 let mut out = Vec::with_capacity(line_count);
74
75 for line in LinesWithEndings::from(text) {
76 let ops = parse_state.parse_line(line, ss).ok()?;
77 let mut html = String::with_capacity(line.len() * 2);
78
79 // Reopen spans for scopes carried over from the previous line. Each
80 // scope's classes are its dot-separated atoms, each with the prefix.
81 for scope in stack.as_slice() {
82 html.push_str("<span class=\"");
83 let name = scope.build_string();
84 let classes: Vec<String> = name
85 .split('.')
86 .map(|atom| format!("{CLASS_PREFIX}{atom}"))
87 .collect();
88 html.push_str(&classes.join(" "));
89 html.push_str("\">");
90 }
91
92 let (body, _) = line_tokens_to_classed_spans(line, &ops, style, &mut stack).ok()?;
93 html.push_str(&body);
94
95 // Close every span still open at end of line; the stack persists.
96 for _ in 0..stack.len() {
97 html.push_str("</span>");
98 }
99
100 // The line's trailing newline is the last raw newline in the fragment
101 // (closing tags never contain one). Drop it, and a CR before it.
102 if let Some(pos) = html.rfind('\n') {
103 html.remove(pos);
104 if pos > 0 && html.as_bytes()[pos - 1] == b'\r' {
105 html.remove(pos - 1);
106 }
107 }
108 out.push(html);
109 }
110 Some(out)
111}
112
113fn build_css() -> String {
114 let light = THEME_SET
115 .themes
116 .get(LIGHT_THEME)
117 .expect("syntect default theme InspiredGitHub is present");
118 let dark = THEME_SET
119 .themes
120 .get(DARK_THEME)
121 .expect("syntect default theme base16-ocean.dark is present");
122 let light_css = css_for_theme_with_class_style(light, class_style())
123 .expect("generating CSS for the light theme");
124 let dark_css = css_for_theme_with_class_style(dark, class_style())
125 .expect("generating CSS for the dark theme");
126 // The dark rules follow the page theme: the OS preference unless `data-theme="light"` is set,
127 // and always when `data-theme="dark"` is set.
128 format!(
129 "{light_css}\n@media (prefers-color-scheme: dark) {{\n{}}}\n{}",
130 scope_css(&dark_css, DARK_WHEN_AUTO),
131 scope_css(&dark_css, DARK_WHEN_FORCED),
132 )
133}
134
135/// Prefixes every selector in a syntect stylesheet with `scope`. syntect writes each rule as a
136/// selector line ending in `{` (selectors separated by commas), then declarations, then `}`.
137fn scope_css(css: &str, scope: &str) -> String {
138 let mut out = String::with_capacity(css.len() * 2);
139 for line in css.lines() {
140 match line.strip_suffix('{') {
141 Some(selectors) => {
142 let scoped: Vec<String> = selectors
143 .split(',')
144 .map(|selector| format!("{scope} {}", selector.trim()))
145 .collect();
146 out.push_str(&scoped.join(", "));
147 out.push_str(" {\n");
148 }
149 None => {
150 out.push_str(line);
151 out.push('\n');
152 }
153 }
154 }
155 out
156}
157
158/// CSS for the `hl-` classes: the light theme by default, and the dark theme
159/// under `prefers-color-scheme: dark`.
160pub fn highlight_css() -> String {
161 CSS.clone()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn balanced(fragment: &str) -> bool {
169 fragment.matches("<span").count() == fragment.matches("</span>").count()
170 }
171
172 #[test]
173 fn rust_line_count_matches_input() {
174 let src = "fn main() {\n println!(\"hi\");\n}\n";
175 let lines = highlight_lines("main.rs", src).expect("rust is known");
176 assert_eq!(lines.len(), src.lines().count());
177 assert_eq!(lines.len(), 3);
178 assert!(lines.iter().all(|l| !l.contains('\n')));
179 }
180
181 #[test]
182 fn multiline_constructs_stay_balanced_per_line() {
183 let src = "/* a\n b */ let x = \"multi\nline\";\nfn f() {}\n";
184 let lines = highlight_lines("lib.rs", src).expect("rust is known");
185 assert_eq!(lines.len(), 4);
186 for line in &lines {
187 assert!(balanced(line), "unbalanced fragment: {line}");
188 }
189 }
190
191 #[test]
192 fn script_in_string_is_escaped() {
193 let src = "let s = \"<script>alert(1)</script>\";\n";
194 let lines = highlight_lines("page.rs", src).expect("rust is known");
195 let joined = lines.join("\n");
196 assert!(
197 !joined.contains("<script"),
198 "raw script tag leaked: {joined}"
199 );
200 assert!(joined.contains("&lt;script&gt;"), "not escaped: {joined}");
201 assert!(!joined.contains("style="), "inline style emitted: {joined}");
202 }
203
204 #[test]
205 fn unknown_extension_returns_none() {
206 assert!(highlight_lines("data.unknownext", "hello\n").is_none());
207 }
208
209 #[test]
210 fn plain_text_returns_none() {
211 assert!(highlight_lines("notes.txt", "hello\n").is_none());
212 }
213
214 #[test]
215 fn shebang_is_detected() {
216 let src = "#!/bin/sh\necho hi\n";
217 let lines = highlight_lines("run", src).expect("shebang is detected");
218 assert_eq!(lines.len(), 2);
219 assert!(lines.iter().all(|l| balanced(l)));
220 }
221
222 #[test]
223 fn full_file_name_is_detected() {
224 let src = "all:\n\techo hi\n";
225 assert!(highlight_lines("Makefile", src).is_some());
226 }
227
228 #[test]
229 fn size_limits_return_none() {
230 let many_lines = "x\n".repeat(MAX_LINES + 1);
231 assert!(highlight_lines("a.rs", &many_lines).is_none());
232 let too_big = "a".repeat(MAX_BYTES + 1);
233 assert!(highlight_lines("a.rs", &too_big).is_none());
234 }
235
236 #[test]
237 fn css_has_classes_and_dark_media_query() {
238 let css = highlight_css();
239 assert!(css.contains(".hl-"));
240 assert!(css.contains("@media (prefers-color-scheme: dark)"));
241 assert!(css.contains(DARK_WHEN_AUTO));
242 assert!(css.contains(DARK_WHEN_FORCED));
243 assert!(!css.contains("@media (prefers-color-scheme: dark) {\n.hl-"));
244 }
245
246 #[test]
247 fn dark_rules_are_scoped_to_their_selectors() {
248 let scoped = scope_css(".hl-a,.hl-b {\n color: #fff;\n}\n", "S");
249 assert_eq!(scoped, "S .hl-a, S .hl-b {\n color: #fff;\n}\n");
250 }
251
252 #[test]
253 fn first_call_latency() {
254 let start = std::time::Instant::now();
255 let _ = highlight_lines("first.rs", "fn a() {}\n");
256 let first = start.elapsed();
257 let css_start = std::time::Instant::now();
258 let _ = highlight_css();
259 let css = css_start.elapsed();
260 println!(
261 "highlight first call (LazyLock init + highlight): {first:?}; css first call: {css:?}"
262 );
263 }
264}