Krolyxon
Commit 55c1225

ferreniumsrcrenderhighlight.rs

252 lines · 7.8 KB · Rust

1//! Syntax highlighting into monochrome CSS classes.
2//!
3//! The whole file is parsed in one pass (so multi-line strings and comments
4//! are right), but the output is split into lines whose `<span>`s are
5//! balanced: each line can be put in its own table row.
6//!
7//! Scopes collapse to a handful of classes (see `syntax.css`):
8//! `hl-k` keywords/storage, `hl-d` names/numbers/constants, `hl-s`
9//! strings/punctuation/operators, `hl-c` comments, `hl-x` invalid.
10
11use std::sync::OnceLock;
12
13use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
14use syntect::util::LinesWithEndings;
15
16use super::escape_html;
17
18static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
19
20/// The embedded syntax set (bat's ~200 syntaxes). Loading takes a few tens
21/// of milliseconds; call once at startup to keep it off the request path.
22pub fn syntaxes() -> &'static SyntaxSet {
23 SYNTAXES.get_or_init(two_face::syntax::extra_newlines)
24}
25
26/// Pick a syntax from the file name, falling back to the first line
27/// (shebangs, modelines). `None` means plain text.
28pub fn detect(path: &str, first_line: &str) -> Option<&'static SyntaxReference> {
29 let set = syntaxes();
30 let name = path.rsplit('/').next().unwrap_or(path);
31 let found = set
32 .find_syntax_by_extension(name)
33 .or_else(|| {
34 let (_, ext) = name.rsplit_once('.')?;
35 set.find_syntax_by_extension(ext)
36 .or_else(|| set.find_syntax_by_extension(&ext.to_ascii_lowercase()))
37 })
38 .or_else(|| set.find_syntax_by_first_line(first_line));
39 found.filter(|s| s.name != "Plain Text")
40}
41
42/// Syntax for a Markdown code fence info string (`rust`, `sh`, `py`, …).
43pub fn for_token(token: &str) -> Option<&'static SyntaxReference> {
44 let token = token.split([' ', ',', '{']).next()?.trim();
45 if token.is_empty() {
46 return None;
47 }
48 syntaxes()
49 .find_syntax_by_token(token)
50 .filter(|s| s.name != "Plain Text")
51}
52
53struct Selectors {
54 comment: Scope,
55 invalid: Scope,
56 string: Scope,
57 punctuation: Scope,
58 operator: Scope,
59 keyword: Scope,
60 storage: Scope,
61 display: [Scope; 6],
62}
63
64fn selectors() -> &'static Selectors {
65 static SELECTORS: OnceLock<Selectors> = OnceLock::new();
66 SELECTORS.get_or_init(|| {
67 let s = |name: &str| Scope::new(name).expect("valid scope");
68 Selectors {
69 comment: s("comment"),
70 invalid: s("invalid"),
71 string: s("string"),
72 punctuation: s("punctuation"),
73 operator: s("keyword.operator"),
74 keyword: s("keyword"),
75 storage: s("storage"),
76 display: [
77 s("entity.name"),
78 s("support.function"),
79 s("support.type"),
80 s("support.class"),
81 s("constant.numeric"),
82 s("constant.language"),
83 ],
84 }
85 })
86}
87
88/// Class for text under `stack`: comments win outright, otherwise the
89/// innermost scope with a class decides.
90fn class_for(stack: &[Scope]) -> Option<&'static str> {
91 let sel = selectors();
92 if stack.iter().any(|s| sel.comment.is_prefix_of(*s)) {
93 return Some("hl-c");
94 }
95 for &scope in stack.iter().rev() {
96 if sel.invalid.is_prefix_of(scope) {
97 return Some("hl-x");
98 }
99 if sel.operator.is_prefix_of(scope)
100 || sel.punctuation.is_prefix_of(scope)
101 || sel.string.is_prefix_of(scope)
102 {
103 return Some("hl-s");
104 }
105 if sel.keyword.is_prefix_of(scope) || sel.storage.is_prefix_of(scope) {
106 return Some("hl-k");
107 }
108 if sel.display.iter().any(|d| d.is_prefix_of(scope)) {
109 return Some("hl-d");
110 }
111 }
112 None
113}
114
115/// Appends text to a line, opening/closing spans only when the class changes.
116struct LineWriter {
117 out: String,
118 open: Option<&'static str>,
119}
120
121impl LineWriter {
122 fn push(&mut self, text: &str, class: Option<&'static str>) {
123 let text = text.trim_end_matches(['\n', '\r']);
124 if text.is_empty() {
125 return;
126 }
127 if class != self.open {
128 if self.open.is_some() {
129 self.out.push_str("</span>");
130 }
131 if let Some(class) = class {
132 self.out.push_str("<span class=\"");
133 self.out.push_str(class);
134 self.out.push_str("\">");
135 }
136 self.open = class;
137 }
138 escape_html(&mut self.out, text);
139 }
140
141 fn finish(mut self) -> String {
142 if self.open.is_some() {
143 self.out.push_str("</span>");
144 }
145 self.out
146 }
147}
148
149/// Highlight `text` and return one HTML fragment per line.
150///
151/// If the grammar fails part-way, the remaining lines are emitted plain.
152pub fn lines(text: &str, syntax: &SyntaxReference) -> Vec<String> {
153 let set = syntaxes();
154 let mut state = ParseState::new(syntax);
155 let mut stack = ScopeStack::new();
156 let mut failed = false;
157 let mut out = Vec::new();
158 for line in LinesWithEndings::from(text) {
159 let ops = if failed {
160 None
161 } else {
162 state.parse_line(line, set).ok()
163 };
164 let Some(ops) = ops else {
165 failed = true;
166 out.push(plain_line(line));
167 continue;
168 };
169 let mut writer = LineWriter {
170 out: String::with_capacity(line.len() * 2),
171 open: None,
172 };
173 let mut pos = 0;
174 for (at, op) in ops {
175 let at = at.min(line.len());
176 if at > pos {
177 writer.push(&line[pos..at], class_for(stack.as_slice()));
178 pos = at;
179 }
180 if stack.apply(&op).is_err() {
181 failed = true;
182 }
183 }
184 writer.push(&line[pos..], class_for(stack.as_slice()));
185 out.push(writer.finish());
186 }
187 out
188}
189
190/// Escaped lines without highlighting.
191pub fn plain_lines(text: &str) -> Vec<String> {
192 LinesWithEndings::from(text).map(plain_line).collect()
193}
194
195fn plain_line(line: &str) -> String {
196 let mut out = String::with_capacity(line.len());
197 escape_html(&mut out, line.trim_end_matches(['\n', '\r']));
198 out
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn detects_by_name_extension_and_first_line() {
207 assert_eq!(detect("src/main.rs", "").unwrap().name, "Rust");
208 assert_eq!(detect("Makefile", "").unwrap().name, "Makefile");
209 assert_eq!(detect("a/b.PY", "").unwrap().name, "Python");
210 assert_eq!(
211 detect("run", "#!/bin/bash").unwrap().name,
212 "Bourne Again Shell (bash)"
213 );
214 assert!(detect("notes.txt", "hello").is_none());
215 assert_eq!(for_token("rust").unwrap().name, "Rust");
216 assert_eq!(for_token("sh").unwrap().name, "Bourne Again Shell (bash)");
217 assert!(for_token("").is_none());
218 }
219
220 #[test]
221 fn spans_are_balanced_per_line_across_multiline_comments() {
222 let src = "fn main() {\n /* a\n b */ let x = \"<s>\";\n}\n";
223 let lines = lines(src, detect("x.rs", "").unwrap());
224 assert_eq!(lines.len(), 4);
225 for line in &lines {
226 assert_eq!(
227 line.matches("<span").count(),
228 line.matches("</span>").count(),
229 "{line}"
230 );
231 }
232 assert!(
233 lines[0].contains(r#"<span class="hl-k">fn</span>"#),
234 "{}",
235 lines[0]
236 );
237 // The comment's second line is still a comment.
238 assert!(
239 lines[2].starts_with(r#"<span class="hl-c"> b */"#),
240 "{}",
241 lines[2]
242 );
243 // Escaped string contents.
244 assert!(lines[2].contains("&lt;s&gt;"), "{}", lines[2]);
245 assert!(!lines.iter().any(|l| l.contains('\n')));
246 }
247
248 #[test]
249 fn plain_lines_escape() {
250 assert_eq!(plain_lines("a<b\r\n\nc"), vec!["a&lt;b", "", "c"]);
251 }
252}