fabrica

hanna/fabrica

12215 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Syntax highlighting producing independently-valid HTML per line.
6//!
7//! Two hard requirements drive the design. First, **line-addressable output**: the
8//! blob view anchors on line numbers, so highlighting yields a `Vec<`[`Line`]`>`
9//! where each line is a `Vec<`[`Span`]`>` that a template renders into balanced
10//! markup on its own — a span that crosses a newline is split and its class
11//! reopened on the next line. Second, **per-side diff highlighting**: a diff
12//! highlights the pre- and post-image of a file as whole documents (never
13//! per-hunk, which would feed a context-dependent grammar garbage) and then zips
14//! the resulting lines with the hunks.
15//!
16//! Highlighting **must not** be able to fail a page render (§8): oversized, binary,
17//! and minified inputs skip straight to plain text, and a grammar panic is caught
18//! and degraded the same way. The output carries no colours — only the stable
19//! `hl-*` classes from [`classes`], which themes style.
20
21mod classes;
22mod lang;
23
24use std::panic::{AssertUnwindSafe, catch_unwind};
25
26use inkjet::Highlighter;
27use inkjet::constants::HIGHLIGHT_NAMES;
28use inkjet::tree_sitter_highlight::HighlightEvent;
29
30pub use crate::classes::{HL_CLASSES, class_for};
31pub use crate::lang::{Lang, resolve};
32
33/// Files larger than this are rendered as plain text (matches the
34/// `ui.max_highlight_bytes` default; the web layer may pre-filter with the
35/// configured value).
36pub const DEFAULT_MAX_BYTES: usize = 1_048_576;
37
38/// A single line longer than this marks the file minified; it is rendered plain.
39pub const MINIFIED_LINE_BYTES: usize = 5_000;
40
41/// Bytes sniffed from the head of the content when deciding whether it is binary.
42const BINARY_SNIFF_BYTES: usize = 8_192;
43
44/// One styled run of text within a line. `class` is a stable `hl-*` name or `None`
45/// for unstyled text; `text` is raw (the renderer escapes it) and never contains a
46/// newline.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Span {
49 /// The highlight class, or `None` for text with no styling.
50 pub class: Option<&'static str>,
51 /// The raw text of this run, newline-free.
52 pub text: String,
53}
54
55/// One rendered source line: its spans in order, without the trailing newline.
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct Line {
58 /// The line's styled runs, left to right.
59 pub spans: Vec<Span>,
60}
61
62/// The result of highlighting a whole blob.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Highlighted {
65 /// One entry per source line.
66 pub lines: Vec<Line>,
67 /// The resolved language's display name, or `None` when rendered plain.
68 pub language: Option<String>,
69}
70
71/// Highlight a blob for the file at `path`, applying every guard.
72///
73/// `attr_override` is the `.gitattributes` language override the git layer
74/// resolved. Oversized, binary, minified, or unrecognized-language inputs — and any
75/// grammar failure — degrade to plain text; the call never fails.
76#[must_use]
77pub 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 // `is_unhighlightable` already rejected non-UTF-8 (a NUL sniff), but guard the
90 // decode anyway; a lossy fallback would misalign byte offsets.
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/// Highlight a whole document (one side of a diff) with an already-resolved
113/// language, for zipping with hunks. Applies the size and minified guards and falls
114/// back to plain text on anything unhighlightable.
115#[must_use]
116pub 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/// Whether `content` should skip highlighting outright: too large, binary (a NUL in
126/// the sniff window), or minified (a very long line).
127fn 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/// Run the grammar and fold its events into lines, or `None` on any grammar error
141/// or panic (the caller falls back to plain text).
142fn 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 // A stack of classes, innermost last; `None` for captures we do not style.
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/// Render unhighlighted `source` into one plain span per line.
180fn 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/// Append `text` (with class `class`) to the line being built, splitting on
191/// newlines. Each embedded `\n` finishes the current line — preserving blank lines
192/// — and the class carries onto the next line, which is the crux of producing
193/// balanced per-line markup for a span that crosses a break.
194fn 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)]
216mod tests {
217 #![allow(clippy::unwrap_used)]
218
219 use std::path::Path;
220
221 use super::*;
222
223 /// Flatten a line back to its text, for assertions independent of span layout.
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 // `fn` should carry the keyword class somewhere on the line.
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 // A multi-line string literal: the string class must reopen on each line,
242 // so every line stands alone with balanced spans.
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 // The three literal lines each carry the string class.
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 // No span text ever contains a newline.
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 /// Render lines to the same span markup the web layer emits, for snapshotting.
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}