| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | mod classes; |
| 22 | mod lang; |
| 23 | |
| 24 | use std::panic::{AssertUnwindSafe, catch_unwind}; |
| 25 | |
| 26 | use inkjet::Highlighter; |
| 27 | use inkjet::constants::HIGHLIGHT_NAMES; |
| 28 | use inkjet::tree_sitter_highlight::HighlightEvent; |
| 29 | |
| 30 | pub use crate::classes::{HL_CLASSES, class_for}; |
| 31 | pub use crate::lang::{Lang, resolve}; |
| 32 | |
| 33 | |
| 34 | |
| 35 | |
| 36 | pub const DEFAULT_MAX_BYTES: usize = 1_048_576; |
| 37 | |
| 38 | |
| 39 | pub const MINIFIED_LINE_BYTES: usize = 5_000; |
| 40 | |
| 41 | |
| 42 | const BINARY_SNIFF_BYTES: usize = 8_192; |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 48 | pub struct Span { |
| 49 | |
| 50 | pub class: Option<&'static str>, |
| 51 | |
| 52 | pub text: String, |
| 53 | } |
| 54 | |
| 55 | |
| 56 | #[derive(Debug, Clone, PartialEq, Eq, Default)] |
| 57 | pub struct Line { |
| 58 | |
| 59 | pub spans: Vec<Span>, |
| 60 | } |
| 61 | |
| 62 | |
| 63 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 64 | pub struct Highlighted { |
| 65 | |
| 66 | pub lines: Vec<Line>, |
| 67 | |
| 68 | pub language: Option<String>, |
| 69 | } |
| 70 | |
| 71 | |
| 72 | |
| 73 | |
| 74 | |
| 75 | |
| 76 | #[must_use] |
| 77 | pub fn highlight( |
| 78 | path: &std::path::Path, |
| 79 | content: &[u8], |
| 80 | attr_override: Option<&str>, |
| 81 | ) -> Highlighted { |
| 82 | let lang = resolve(path, content, attr_override); |
| 83 | if lang.is_plain() || is_unhighlightable(content) { |
| 84 | return Highlighted { |
| 85 | lines: plain_lines(&String::from_utf8_lossy(content)), |
| 86 | language: None, |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | |
| 91 | let Ok(source) = std::str::from_utf8(content) else { |
| 92 | return Highlighted { |
| 93 | lines: plain_lines(&String::from_utf8_lossy(content)), |
| 94 | language: None, |
| 95 | }; |
| 96 | }; |
| 97 | match lang |
| 98 | .language() |
| 99 | .and_then(|inner| highlight_events(inner, source)) |
| 100 | { |
| 101 | Some(lines) => Highlighted { |
| 102 | lines, |
| 103 | language: lang.name().map(str::to_string), |
| 104 | }, |
| 105 | None => Highlighted { |
| 106 | lines: plain_lines(source), |
| 107 | language: None, |
| 108 | }, |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | |
| 113 | |
| 114 | |
| 115 | #[must_use] |
| 116 | pub fn highlight_document(lang: &Lang, source: &str) -> Vec<Line> { |
| 117 | if lang.is_plain() || is_unhighlightable(source.as_bytes()) { |
| 118 | return plain_lines(source); |
| 119 | } |
| 120 | lang.language() |
| 121 | .and_then(|inner| highlight_events(inner, source)) |
| 122 | .unwrap_or_else(|| plain_lines(source)) |
| 123 | } |
| 124 | |
| 125 | |
| 126 | |
| 127 | fn is_unhighlightable(content: &[u8]) -> bool { |
| 128 | if content.len() > DEFAULT_MAX_BYTES { |
| 129 | return true; |
| 130 | } |
| 131 | let window = &content[..content.len().min(BINARY_SNIFF_BYTES)]; |
| 132 | if window.contains(&0) { |
| 133 | return true; |
| 134 | } |
| 135 | content |
| 136 | .split(|b| *b == b'\n') |
| 137 | .any(|line| line.len() > MINIFIED_LINE_BYTES) |
| 138 | } |
| 139 | |
| 140 | |
| 141 | |
| 142 | fn highlight_events(language: inkjet::Language, source: &str) -> Option<Vec<Line>> { |
| 143 | let outcome = catch_unwind(AssertUnwindSafe(|| { |
| 144 | let mut highlighter = Highlighter::new(); |
| 145 | let events = highlighter.highlight_raw(language, &source).ok()?; |
| 146 | |
| 147 | let mut lines: Vec<Line> = Vec::new(); |
| 148 | let mut current: Vec<Span> = Vec::new(); |
| 149 | |
| 150 | let mut stack: Vec<Option<&'static str>> = Vec::new(); |
| 151 | |
| 152 | for event in events { |
| 153 | match event.ok()? { |
| 154 | HighlightEvent::HighlightStart(highlight) => { |
| 155 | let class = HIGHLIGHT_NAMES |
| 156 | .get(highlight.0) |
| 157 | .copied() |
| 158 | .and_then(class_for); |
| 159 | stack.push(class); |
| 160 | } |
| 161 | HighlightEvent::HighlightEnd => { |
| 162 | stack.pop(); |
| 163 | } |
| 164 | HighlightEvent::Source { start, end } => { |
| 165 | let text = source.get(start..end).unwrap_or_default(); |
| 166 | let class = stack.iter().rev().flatten().next().copied(); |
| 167 | push_text(&mut lines, &mut current, text, class); |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | if !current.is_empty() { |
| 172 | lines.push(Line { spans: current }); |
| 173 | } |
| 174 | Some(lines) |
| 175 | })); |
| 176 | outcome.ok().flatten() |
| 177 | } |
| 178 | |
| 179 | |
| 180 | fn plain_lines(source: &str) -> Vec<Line> { |
| 181 | let mut lines: Vec<Line> = Vec::new(); |
| 182 | let mut current: Vec<Span> = Vec::new(); |
| 183 | push_text(&mut lines, &mut current, source, None); |
| 184 | if !current.is_empty() { |
| 185 | lines.push(Line { spans: current }); |
| 186 | } |
| 187 | lines |
| 188 | } |
| 189 | |
| 190 | |
| 191 | |
| 192 | |
| 193 | |
| 194 | fn push_text( |
| 195 | lines: &mut Vec<Line>, |
| 196 | current: &mut Vec<Span>, |
| 197 | text: &str, |
| 198 | class: Option<&'static str>, |
| 199 | ) { |
| 200 | for (i, segment) in text.split('\n').enumerate() { |
| 201 | if i > 0 { |
| 202 | lines.push(Line { |
| 203 | spans: std::mem::take(current), |
| 204 | }); |
| 205 | } |
| 206 | if !segment.is_empty() { |
| 207 | current.push(Span { |
| 208 | class, |
| 209 | text: segment.to_string(), |
| 210 | }); |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | #[cfg(test)] |
| 216 | mod tests { |
| 217 | #![allow(clippy::unwrap_used)] |
| 218 | |
| 219 | use std::path::Path; |
| 220 | |
| 221 | use super::*; |
| 222 | |
| 223 | |
| 224 | fn line_text(line: &Line) -> String { |
| 225 | line.spans.iter().map(|s| s.text.as_str()).collect() |
| 226 | } |
| 227 | |
| 228 | #[test] |
| 229 | fn highlights_rust_and_marks_keywords() { |
| 230 | let out = highlight(Path::new("main.rs"), b"fn main() {}\n", None); |
| 231 | assert_eq!(out.language.as_deref(), Some("rust")); |
| 232 | assert_eq!(out.lines.len(), 1); |
| 233 | |
| 234 | let classes: Vec<_> = out.lines[0].spans.iter().filter_map(|s| s.class).collect(); |
| 235 | assert!(classes.contains(&"hl-keyword"), "classes: {classes:?}"); |
| 236 | assert_eq!(line_text(&out.lines[0]), "fn main() {}"); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn every_line_is_independently_balanced_across_a_multiline_string() { |
| 241 | |
| 242 | |
| 243 | let src = "fn f() -> &'static str {\n \"line one\nline two\nline three\"\n}\n"; |
| 244 | let out = highlight(Path::new("s.rs"), src.as_bytes(), None); |
| 245 | |
| 246 | let string_lines: Vec<usize> = out |
| 247 | .lines |
| 248 | .iter() |
| 249 | .enumerate() |
| 250 | .filter(|(_, l)| l.spans.iter().any(|s| s.class == Some("hl-string"))) |
| 251 | .map(|(i, _)| i) |
| 252 | .collect(); |
| 253 | assert!( |
| 254 | string_lines.len() >= 3, |
| 255 | "string spans should reopen on each continuation line: {string_lines:?}" |
| 256 | ); |
| 257 | |
| 258 | for line in &out.lines { |
| 259 | for span in &line.spans { |
| 260 | assert!(!span.text.contains('\n'), "span leaked a newline: {span:?}"); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | #[test] |
| 266 | fn preserves_blank_lines_and_line_count() { |
| 267 | let out = highlight(Path::new("a.rs"), b"fn a() {}\n\nfn b() {}\n", None); |
| 268 | assert_eq!(out.lines.len(), 3); |
| 269 | assert_eq!(line_text(&out.lines[1]), ""); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn binary_content_degrades_to_plain() { |
| 274 | let out = highlight(Path::new("x.rs"), b"fn main() {\0}\n", None); |
| 275 | assert_eq!(out.language, None, "NUL byte marks it binary → plain"); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn minified_content_degrades_to_plain() { |
| 280 | let mut src = String::from("let x = "); |
| 281 | src.push_str(&"a".repeat(MINIFIED_LINE_BYTES + 1)); |
| 282 | src.push('\n'); |
| 283 | let out = highlight(Path::new("min.js"), src.as_bytes(), None); |
| 284 | assert_eq!(out.language, None); |
| 285 | } |
| 286 | |
| 287 | #[test] |
| 288 | fn unknown_language_is_plain_but_still_line_split() { |
| 289 | let out = highlight(Path::new("notes.zzz"), b"one\ntwo\n", None); |
| 290 | assert_eq!(out.language, None); |
| 291 | assert_eq!(out.lines.len(), 2); |
| 292 | assert_eq!(line_text(&out.lines[0]), "one"); |
| 293 | assert!(out.lines[0].spans.iter().all(|s| s.class.is_none())); |
| 294 | } |
| 295 | |
| 296 | #[test] |
| 297 | fn highlight_document_matches_source_lines() { |
| 298 | let lang = resolve(Path::new("main.rs"), b"fn main() {}\n", None); |
| 299 | let lines = highlight_document(&lang, "fn main() {}\nlet x = 1;\n"); |
| 300 | assert_eq!(lines.len(), 2); |
| 301 | assert_eq!(line_text(&lines[0]), "fn main() {}"); |
| 302 | assert_eq!(line_text(&lines[1]), "let x = 1;"); |
| 303 | } |
| 304 | |
| 305 | |
| 306 | fn render(lines: &[Line]) -> String { |
| 307 | use std::fmt::Write as _; |
| 308 | |
| 309 | let mut out = String::new(); |
| 310 | for line in lines { |
| 311 | for span in &line.spans { |
| 312 | match span.class { |
| 313 | Some(class) => { |
| 314 | let _ = write!(out, "<span class=\"{class}\">{}</span>", span.text); |
| 315 | } |
| 316 | None => out.push_str(&span.text), |
| 317 | } |
| 318 | } |
| 319 | out.push('\n'); |
| 320 | } |
| 321 | out |
| 322 | } |
| 323 | |
| 324 | #[test] |
| 325 | fn snapshot_rust_fixture() { |
| 326 | let src = "use std::io;\n\nfn add(a: i32, b: i32) -> i32 {\n a + b // sum\n}\n"; |
| 327 | let out = highlight(Path::new("fixture.rs"), src.as_bytes(), None); |
| 328 | insta::assert_snapshot!(render(&out.lines)); |
| 329 | } |
| 330 | } |