Sam Jeston
History
256 lines·
8.0 KB
·Rust
| 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 | |
| 8 | use std::collections::HashMap; |
| 9 | use std::fmt::Write; |
| 10 | use std::sync::LazyLock; |
| 11 | |
| 12 | use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet}; |
| 13 | |
| 14 | static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_newlines); |
| 15 | |
| 16 | /// Files larger than this are shown without highlighting. |
| 17 | const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; |
| 18 | |
| 19 | const 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 | |
| 55 | fn 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 | |
| 65 | pub fn escape(s: &str) -> String { |
| 66 | let mut out = String::with_capacity(s.len()); |
| 67 | escape_into(&mut out, s); |
| 68 | out |
| 69 | } |
| 70 | |
| 71 | fn escape_into(out: &mut String, s: &str) { |
| 72 | for c in s.chars() { |
| 73 | match c { |
| 74 | '&' => out.push_str("&"), |
| 75 | '<' => out.push_str("<"), |
| 76 | '>' => out.push_str(">"), |
| 77 | '"' => out.push_str("""), |
| 78 | '\'' => out.push_str("'"), |
| 79 | _ => out.push(c), |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | pub 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. |
| 95 | pub 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 | |
| 99 | struct LineHighlighter { |
| 100 | state: ParseState, |
| 101 | stack: ScopeStack, |
| 102 | cache: HashMap<Scope, Option<&'static str>>, |
| 103 | failed: bool, |
| 104 | } |
| 105 | |
| 106 | impl 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. |
| 197 | pub 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). |
| 212 | pub 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)] |
| 225 | mod 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("<hi>"), "{}", 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!["<b>&</b>"]); |
| 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 | } |