samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
256 lines· 8.0 KB ·Rust
Raw
1//! Server-side syntax highlighting.
2//!
3//! Scopes from syntect grammars are collapsed into a small set of token
4//! classes (`tok-k`, `tok-s`, ...) so colours live in our stylesheet and
5//! follow the light/dark theme. Each line is rendered self-contained, which
6//! lets the code view and diffs place lines in separate table rows.
7
8use std::collections::HashMap;
9use std::fmt::Write;
10use std::sync::LazyLock;
11
12use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
13
14static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_newlines);
15
16/// Files larger than this are shown without highlighting.
17const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;
18
19const SCOPE_CLASSES: &[(&str, &str)] = &[
20 ("comment", "c"),
21 ("punctuation.definition.comment", "c"),
22 ("string.regexp", "sr"),
23 ("constant.character.escape", "e"),
24 ("string", "s"),
25 ("markup.raw", "s"),
26 ("constant.numeric", "n"),
27 ("constant", "kc"),
28 ("variable.language", "kc"),
29 ("support.constant", "kc"),
30 ("keyword.operator", "o"),
31 ("keyword", "k"),
32 ("storage", "k"),
33 ("entity.name.function", "f"),
34 ("support.function", "f"),
35 ("variable.function", "f"),
36 ("entity.name.tag", "tag"),
37 ("entity.other.attribute-name", "attr"),
38 ("support.type.property-name", "attr"),
39 ("entity.name.section", "h"),
40 ("markup.heading", "h"),
41 ("entity.name", "t"),
42 ("entity.other.inherited-class", "t"),
43 ("support.type", "t"),
44 ("support.class", "t"),
45 ("meta.annotation", "m"),
46 ("meta.attribute", "m"),
47 ("variable.parameter", "v"),
48 ("markup.bold", "b"),
49 ("markup.italic", "i"),
50 ("markup.inserted", "ins"),
51 ("markup.deleted", "del"),
52 ("markup.underline.link", "link"),
53];
54
55fn class_for_scope(scope: Scope) -> Option<&'static str> {
56 let name = scope.build_string();
57 SCOPE_CLASSES
58 .iter()
59 .find(|(prefix, _)| {
60 name == *prefix || (name.starts_with(prefix) && name.as_bytes()[prefix.len()] == b'.')
61 })
62 .map(|(_, class)| *class)
63}
64
65pub fn escape(s: &str) -> String {
66 let mut out = String::with_capacity(s.len());
67 escape_into(&mut out, s);
68 out
69}
70
71fn escape_into(out: &mut String, s: &str) {
72 for c in s.chars() {
73 match c {
74 '&' => out.push_str("&amp;"),
75 '<' => out.push_str("&lt;"),
76 '>' => out.push_str("&gt;"),
77 '"' => out.push_str("&quot;"),
78 '\'' => out.push_str("&#39;"),
79 _ => out.push(c),
80 }
81 }
82}
83
84pub fn find_syntax(path: &str, first_line: &str) -> Option<&'static SyntaxReference> {
85 let ss = &*SYNTAXES;
86 let file_name = path.rsplit('/').next().unwrap_or(path);
87 let ext = file_name.rsplit_once('.').map(|(_, e)| e);
88 ext.and_then(|e| ss.find_syntax_by_extension(e))
89 .or_else(|| ss.find_syntax_by_extension(file_name))
90 .or_else(|| ss.find_syntax_by_first_line(first_line))
91 .filter(|s| s.name != "Plain Text")
92}
93
94/// Human readable language name for a file, if known.
95pub fn language_name(path: &str, first_line: &str) -> Option<&'static str> {
96 find_syntax(path, first_line).map(|s| s.name.as_str())
97}
98
99struct LineHighlighter {
100 state: ParseState,
101 stack: ScopeStack,
102 cache: HashMap<Scope, Option<&'static str>>,
103 failed: bool,
104}
105
106impl LineHighlighter {
107 fn new(syntax: &SyntaxReference) -> Self {
108 LineHighlighter {
109 state: ParseState::new(syntax),
110 stack: ScopeStack::new(),
111 cache: HashMap::new(),
112 failed: false,
113 }
114 }
115
116 fn current_class(&mut self) -> Option<&'static str> {
117 for scope in self.stack.as_slice().iter().rev() {
118 let class = *self
119 .cache
120 .entry(*scope)
121 .or_insert_with(|| class_for_scope(*scope));
122 if class.is_some() {
123 return class;
124 }
125 }
126 None
127 }
128
129 /// Highlights one line (without its trailing newline).
130 fn line(&mut self, text: &str) -> String {
131 if self.failed {
132 return escape(text);
133 }
134 let with_newline = format!("{text}\n");
135 let ops = match self.state.parse_line(&with_newline, &SYNTAXES) {
136 Ok(ops) => ops,
137 Err(_) => {
138 self.failed = true;
139 return escape(text);
140 }
141 };
142 let mut out = String::with_capacity(text.len() * 2);
143 let mut pending = String::new();
144 let mut pending_class: Option<&'static str> = None;
145 let mut cursor = 0;
146
147 let flush = |out: &mut String, pending: &mut String, class: Option<&'static str>| {
148 if pending.is_empty() {
149 return;
150 }
151 match class {
152 Some(c) => {
153 let _ = write!(out, r#"<span class="tok-{c}">"#);
154 escape_into(out, pending);
155 out.push_str("</span>");
156 }
157 None => escape_into(out, pending),
158 }
159 pending.clear();
160 };
161
162 for (pos, op) in ops {
163 let pos = pos.min(text.len());
164 if pos > cursor {
165 let class = self.current_class();
166 if class != pending_class {
167 flush(&mut out, &mut pending, pending_class);
168 pending_class = class;
169 }
170 pending.push_str(&text[cursor..pos]);
171 cursor = pos;
172 }
173 if self.stack.apply(&op).is_err() {
174 self.failed = true;
175 break;
176 }
177 }
178 if cursor < text.len() {
179 let class = if self.failed {
180 None
181 } else {
182 self.current_class()
183 };
184 if class != pending_class {
185 flush(&mut out, &mut pending, pending_class);
186 pending_class = class;
187 }
188 pending.push_str(&text[cursor..]);
189 }
190 flush(&mut out, &mut pending, pending_class);
191 out
192 }
193}
194
195/// Highlights a sequence of lines from the file at `path`, returning one
196/// HTML fragment per input line.
197pub fn highlight_lines(path: &str, lines: &[&str]) -> Vec<String> {
198 let total: usize = lines.iter().map(|l| l.len()).sum();
199 let syntax = (total <= MAX_HIGHLIGHT_BYTES)
200 .then(|| find_syntax(path, lines.first().copied().unwrap_or("")))
201 .flatten();
202 match syntax {
203 Some(syntax) => {
204 let mut h = LineHighlighter::new(syntax);
205 lines.iter().map(|l| h.line(l)).collect()
206 }
207 None => lines.iter().map(|l| escape(l)).collect(),
208 }
209}
210
211/// Highlights a code block in a known language (used for Markdown fences).
212pub fn highlight_block(lang: &str, code: &str) -> Option<String> {
213 let syntax = SYNTAXES
214 .find_syntax_by_token(lang)
215 .filter(|s| s.name != "Plain Text")?;
216 if code.len() > MAX_HIGHLIGHT_BYTES {
217 return None;
218 }
219 let mut h = LineHighlighter::new(syntax);
220 let lines: Vec<String> = code.lines().map(|l| h.line(l)).collect();
221 Some(lines.join("\n"))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn highlights_rust_keywords_and_strings() {
230 let out = highlight_lines("main.rs", &["fn main() {", " let s = \"<hi>\";", "}"]);
231 assert_eq!(out.len(), 3);
232 assert!(
233 out[0].contains(r#"<span class="tok-k">fn</span>"#),
234 "{}",
235 out[0]
236 );
237 assert!(out[1].contains("&lt;hi&gt;"), "{}", out[1]);
238 assert!(out[1].contains("tok-s"), "{}", out[1]);
239 }
240
241 #[test]
242 fn unknown_files_are_escaped() {
243 let out = highlight_lines("notes.unknownext", &["<b>&</b>"]);
244 assert_eq!(out, vec!["&lt;b&gt;&amp;&lt;/b&gt;"]);
245 }
246
247 #[test]
248 fn multiline_comment_classes_every_line() {
249 let out = highlight_lines("a.c", &["/* one", "two */ int x;"]);
250 assert!(
251 out[1].starts_with(r#"<span class="tok-c">two */</span>"#),
252 "{}",
253 out[1]
254 );
255 }
256}