| 1 | //! Syntax highlighting (spec §8). |
| 2 | //! |
| 3 | //! > **Syntax highlighting** with `tree-sitter` and a curated grammar set |
| 4 | //! > (roughly the top 25 languages), falling back to plain text. […] Skip files |
| 5 | //! > over 1 MB or with lines over 5000 characters, and render those as plain |
| 6 | //! > text with a notice. |
| 7 | //! |
| 8 | //! The output is a `Vec<String>` of **per-line HTML fragments**, not one blob of |
| 9 | //! markup. The blob view needs a line-number gutter and anchorable line ids, and |
| 10 | //! producing lines here is what lets the diff view highlight a single side of a |
| 11 | //! hunk without re-parsing. |
| 12 | //! |
| 13 | //! Two safety properties this module is responsible for: |
| 14 | //! |
| 15 | //! * **Escaping.** The source is repository content, which is attacker |
| 16 | //! controlled. `tree-sitter-highlight`'s `HtmlRenderer` escapes `<`, `&`, `'` |
| 17 | //! and `"` in the text it emits, and every attribute this module adds is a |
| 18 | //! fixed string chosen from the highlight-name table — no user data ever |
| 19 | //! reaches an attribute value. `escaping_is_not_optional` pins that. |
| 20 | //! * **Termination.** A pathological file must degrade, not hang the process. |
| 21 | //! The size and line-length caps are checked *before* the parser runs, and a |
| 22 | //! highlight error returns `None` rather than propagating. |
| 23 | |
| 24 | use std::collections::HashMap; |
| 25 | use std::sync::LazyLock; |
| 26 | |
| 27 | use tree_sitter_highlight::{HighlightConfiguration, Highlighter, HtmlRenderer}; |
| 28 | |
| 29 | /// Files above this are served as plain text (spec §8). |
| 30 | pub const MAX_BYTES: usize = 1024 * 1024; |
| 31 | |
| 32 | /// A single line this long means the file is generated or minified. Parsing it |
| 33 | /// is slow and the result is unreadable either way (spec §8). |
| 34 | pub const MAX_LINE_CHARS: usize = 5_000; |
| 35 | |
| 36 | /// Highlight capture names, in the order the CSS classes are derived from. |
| 37 | /// |
| 38 | /// The index into this array is what `tree-sitter-highlight` hands back, so the |
| 39 | /// order is load-bearing: it must match [`CLASSES`] exactly. |
| 40 | const HIGHLIGHT_NAMES: &[&str] = &[ |
| 41 | "attribute", |
| 42 | "boolean", |
| 43 | "character", |
| 44 | "comment", |
| 45 | "comment.documentation", |
| 46 | "constant", |
| 47 | "constant.builtin", |
| 48 | "constructor", |
| 49 | "embedded", |
| 50 | "escape", |
| 51 | "function", |
| 52 | "function.builtin", |
| 53 | "function.method", |
| 54 | "keyword", |
| 55 | "label", |
| 56 | "module", |
| 57 | "number", |
| 58 | "operator", |
| 59 | "property", |
| 60 | "punctuation", |
| 61 | "punctuation.bracket", |
| 62 | "punctuation.delimiter", |
| 63 | "punctuation.special", |
| 64 | "string", |
| 65 | "string.escape", |
| 66 | "string.special", |
| 67 | "tag", |
| 68 | "type", |
| 69 | "type.builtin", |
| 70 | "variable", |
| 71 | "variable.builtin", |
| 72 | "variable.parameter", |
| 73 | ]; |
| 74 | |
| 75 | /// The `class="…"` attribute emitted for each highlight index. |
| 76 | /// |
| 77 | /// Precomputed as complete attribute bytes so the render callback is a slice |
| 78 | /// copy rather than a format call per token — highlighting a large file emits a |
| 79 | /// great many of these. |
| 80 | static CLASSES: LazyLock<Vec<Vec<u8>>> = LazyLock::new(|| { |
| 81 | HIGHLIGHT_NAMES |
| 82 | .iter() |
| 83 | .map(|name| { |
| 84 | // `hl-function-builtin` also carries `hl-function`, so the stylesheet |
| 85 | // can theme a whole family and refine one member of it. |
| 86 | let mut classes = String::new(); |
| 87 | let mut acc = String::new(); |
| 88 | for part in name.split('.') { |
| 89 | if !acc.is_empty() { |
| 90 | acc.push('-'); |
| 91 | } |
| 92 | acc.push_str(part); |
| 93 | if !classes.is_empty() { |
| 94 | classes.push(' '); |
| 95 | } |
| 96 | classes.push_str("hl-"); |
| 97 | classes.push_str(&acc); |
| 98 | } |
| 99 | format!(r#"class="{classes}""#).into_bytes() |
| 100 | }) |
| 101 | .collect() |
| 102 | }); |
| 103 | |
| 104 | /// A highlighted file. |
| 105 | #[derive(Debug)] |
| 106 | pub struct Highlighted { |
| 107 | /// The grammar that was used, for the "detected as Rust" affordance. |
| 108 | pub language: &'static str, |
| 109 | /// One HTML fragment per line, already escaped. No trailing newline. |
| 110 | pub lines: Vec<String>, |
| 111 | } |
| 112 | |
| 113 | /// Why a file was not highlighted. |
| 114 | /// |
| 115 | /// Distinguished from "no grammar" so the view can say *why* — a 4 MB file |
| 116 | /// rendering as plain text is a decision the reader should be told about, and a |
| 117 | /// `.md` file rendering plain because it is minified is worth explaining. |
| 118 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 119 | pub enum Skipped { |
| 120 | /// Over [`MAX_BYTES`]. |
| 121 | TooLarge, |
| 122 | /// Contains a line over [`MAX_LINE_CHARS`]. |
| 123 | LineTooLong, |
| 124 | /// No grammar for this file type. |
| 125 | NoGrammar, |
| 126 | /// The grammar failed on this input. Rare, and never fatal. |
| 127 | Failed, |
| 128 | } |
| 129 | |
| 130 | /// Highlight `source`, choosing a grammar from `path`. |
| 131 | /// |
| 132 | /// Returns `Err(Skipped)` whenever the file should be shown as plain text — |
| 133 | /// there is deliberately no partial result, because half a highlighted file is |
| 134 | /// worse than none. |
| 135 | pub fn highlight(path: &str, source: &str) -> Result<Highlighted, Skipped> { |
| 136 | if source.len() > MAX_BYTES { |
| 137 | return Err(Skipped::TooLarge); |
| 138 | } |
| 139 | if source.lines().any(|l| l.chars().count() > MAX_LINE_CHARS) { |
| 140 | return Err(Skipped::LineTooLong); |
| 141 | } |
| 142 | |
| 143 | let lang = language_for_path(path).ok_or(Skipped::NoGrammar)?; |
| 144 | let config = GRAMMARS.get(lang.grammar).ok_or(Skipped::NoGrammar)?; |
| 145 | |
| 146 | let mut highlighter = Highlighter::new(); |
| 147 | // The injection callback resolves embedded languages (JS inside HTML, and so |
| 148 | // on). Returning `None` means "render the embedded region with the outer |
| 149 | // grammar", which is correct-but-plain rather than wrong. |
| 150 | let events = highlighter |
| 151 | .highlight(config, source.as_bytes(), None, |name| GRAMMARS.get(name)) |
| 152 | .map_err(|_| Skipped::Failed)?; |
| 153 | |
| 154 | let mut renderer = HtmlRenderer::new(); |
| 155 | renderer |
| 156 | .render(events, source.as_bytes(), &|h, out: &mut Vec<u8>| { |
| 157 | if let Some(class) = CLASSES.get(h.0) { |
| 158 | out.extend_from_slice(class); |
| 159 | } |
| 160 | }) |
| 161 | .map_err(|_| Skipped::Failed)?; |
| 162 | |
| 163 | // `HtmlRenderer::lines` yields each line with a trailing newline and with |
| 164 | // every span opened on that line also closed on it, so a line is a valid |
| 165 | // standalone fragment. |
| 166 | let lines = renderer |
| 167 | .lines() |
| 168 | .map(|l| l.trim_end_matches('\n').to_owned()) |
| 169 | .collect(); |
| 170 | |
| 171 | Ok(Highlighted { language: lang.display, lines }) |
| 172 | } |
| 173 | |
| 174 | /// A grammar we can highlight with. |
| 175 | #[derive(Debug, Clone, Copy)] |
| 176 | pub struct Language { |
| 177 | /// Key into [`GRAMMARS`]. Also the name injections are looked up by, so it |
| 178 | /// matches tree-sitter's own language names. |
| 179 | pub grammar: &'static str, |
| 180 | /// Human-readable, for the UI. |
| 181 | pub display: &'static str, |
| 182 | } |
| 183 | |
| 184 | /// Pick a grammar for a path. |
| 185 | /// |
| 186 | /// Whole filenames are checked before extensions, so `Dockerfile` and |
| 187 | /// `.bashrc` resolve even though they have no useful extension. |
| 188 | pub fn language_for_path(path: &str) -> Option<Language> { |
| 189 | let name = path.rsplit('/').next().unwrap_or(path); |
| 190 | let lower = name.to_ascii_lowercase(); |
| 191 | |
| 192 | let by_name = match lower.as_str() { |
| 193 | "makefile" | "gnumakefile" | "dockerfile" | "containerfile" | "justfile" => Some("bash"), |
| 194 | ".bashrc" | ".bash_profile" | ".profile" | ".zshrc" | ".zprofile" => Some("bash"), |
| 195 | "cargo.lock" | "gemfile.lock" => Some("toml"), |
| 196 | "gemfile" | "rakefile" | "podfile" => Some("ruby"), |
| 197 | "cmakelists.txt" => None, |
| 198 | _ => None, |
| 199 | }; |
| 200 | if let Some(g) = by_name { |
| 201 | return GRAMMAR_DISPLAY |
| 202 | .iter() |
| 203 | .find(|(k, _)| *k == g) |
| 204 | .map(|(grammar, display)| Language { grammar, display }); |
| 205 | } |
| 206 | |
| 207 | // `.tar.gz` must not resolve as `gz`; only the final component matters. |
| 208 | let ext = lower.rsplit_once('.').map(|(_, e)| e)?; |
| 209 | |
| 210 | let grammar = match ext { |
| 211 | "rs" => "rust", |
| 212 | "py" | "pyi" | "pyw" => "python", |
| 213 | "js" | "mjs" | "cjs" | "jsx" => "javascript", |
| 214 | "ts" | "mts" | "cts" => "typescript", |
| 215 | "tsx" => "tsx", |
| 216 | "go" => "go", |
| 217 | "c" | "h" => "c", |
| 218 | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", |
| 219 | "java" => "java", |
| 220 | "rb" | "rake" | "gemspec" => "ruby", |
| 221 | "json" | "jsonc" | "webmanifest" => "json", |
| 222 | "toml" => "toml", |
| 223 | "yaml" | "yml" => "yaml", |
| 224 | "html" | "htm" | "xhtml" => "html", |
| 225 | "css" | "scss" => "css", |
| 226 | "sh" | "bash" | "zsh" | "ksh" => "bash", |
| 227 | "md" | "markdown" | "mdown" => "markdown", |
| 228 | "sql" => "sql", |
| 229 | "php" => "php", |
| 230 | "cs" => "c_sharp", |
| 231 | "scala" | "sbt" | "sc" => "scala", |
| 232 | "hs" => "haskell", |
| 233 | "swift" => "swift", |
| 234 | "ex" | "exs" => "elixir", |
| 235 | "lua" => "lua", |
| 236 | "zig" => "zig", |
| 237 | "nix" => "nix", |
| 238 | _ => return None, |
| 239 | }; |
| 240 | |
| 241 | GRAMMAR_DISPLAY |
| 242 | .iter() |
| 243 | .find(|(k, _)| *k == grammar) |
| 244 | .map(|(grammar, display)| Language { grammar, display }) |
| 245 | } |
| 246 | |
| 247 | /// Grammar key → display name. Also the authoritative list of what is loaded. |
| 248 | const GRAMMAR_DISPLAY: &[(&str, &str)] = &[ |
| 249 | ("bash", "Shell"), |
| 250 | ("c", "C"), |
| 251 | ("c_sharp", "C#"), |
| 252 | ("cpp", "C++"), |
| 253 | ("css", "CSS"), |
| 254 | ("elixir", "Elixir"), |
| 255 | ("go", "Go"), |
| 256 | ("haskell", "Haskell"), |
| 257 | ("html", "HTML"), |
| 258 | ("java", "Java"), |
| 259 | ("javascript", "JavaScript"), |
| 260 | ("json", "JSON"), |
| 261 | ("lua", "Lua"), |
| 262 | ("markdown", "Markdown"), |
| 263 | ("nix", "Nix"), |
| 264 | ("php", "PHP"), |
| 265 | ("python", "Python"), |
| 266 | ("ruby", "Ruby"), |
| 267 | ("rust", "Rust"), |
| 268 | ("scala", "Scala"), |
| 269 | ("sql", "SQL"), |
| 270 | ("swift", "Swift"), |
| 271 | ("toml", "TOML"), |
| 272 | ("tsx", "TSX"), |
| 273 | ("typescript", "TypeScript"), |
| 274 | ("yaml", "YAML"), |
| 275 | ("zig", "Zig"), |
| 276 | ]; |
| 277 | |
| 278 | /// Every grammar, parsed and configured once. |
| 279 | /// |
| 280 | /// `HighlightConfiguration::configure` is what binds capture names to the |
| 281 | /// indices used by [`CLASSES`], and it mutates the configuration — so it happens |
| 282 | /// here, once, rather than per request. Building these costs a few milliseconds |
| 283 | /// each and the map is shared across every request thereafter. |
| 284 | static GRAMMARS: LazyLock<HashMap<&'static str, HighlightConfiguration>> = LazyLock::new(|| { |
| 285 | let mut m = HashMap::new(); |
| 286 | |
| 287 | /// Build one configuration, skipping the grammar entirely if its queries do |
| 288 | /// not compile. A broken grammar must degrade to plain text, never panic at |
| 289 | /// the first request that touches it. |
| 290 | fn add( |
| 291 | m: &mut HashMap<&'static str, HighlightConfiguration>, |
| 292 | name: &'static str, |
| 293 | language: tree_sitter::Language, |
| 294 | highlights: &str, |
| 295 | injections: &str, |
| 296 | locals: &str, |
| 297 | ) { |
| 298 | match HighlightConfiguration::new(language, name, highlights, injections, locals) { |
| 299 | Ok(mut c) => { |
| 300 | c.configure(HIGHLIGHT_NAMES); |
| 301 | m.insert(name, c); |
| 302 | } |
| 303 | Err(e) => tracing_warn(name, &e.to_string()), |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | add(&mut m, "rust", tree_sitter_rust::LANGUAGE.into(), |
| 308 | tree_sitter_rust::HIGHLIGHTS_QUERY, tree_sitter_rust::INJECTIONS_QUERY, ""); |
| 309 | add(&mut m, "python", tree_sitter_python::LANGUAGE.into(), |
| 310 | tree_sitter_python::HIGHLIGHTS_QUERY, "", ""); |
| 311 | add(&mut m, "javascript", tree_sitter_javascript::LANGUAGE.into(), |
| 312 | tree_sitter_javascript::HIGHLIGHT_QUERY, |
| 313 | tree_sitter_javascript::INJECTIONS_QUERY, |
| 314 | tree_sitter_javascript::LOCALS_QUERY); |
| 315 | add(&mut m, "typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), |
| 316 | // The TypeScript grammar's queries extend JavaScript's rather than |
| 317 | // repeating them, so both must be supplied or half the file is plain. |
| 318 | &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY), |
| 319 | tree_sitter_javascript::INJECTIONS_QUERY, |
| 320 | tree_sitter_typescript::LOCALS_QUERY); |
| 321 | add(&mut m, "tsx", tree_sitter_typescript::LANGUAGE_TSX.into(), |
| 322 | &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY), |
| 323 | tree_sitter_javascript::INJECTIONS_QUERY, |
| 324 | tree_sitter_typescript::LOCALS_QUERY); |
| 325 | add(&mut m, "go", tree_sitter_go::LANGUAGE.into(), |
| 326 | tree_sitter_go::HIGHLIGHTS_QUERY, "", ""); |
| 327 | add(&mut m, "c", tree_sitter_c::LANGUAGE.into(), |
| 328 | tree_sitter_c::HIGHLIGHT_QUERY, "", ""); |
| 329 | add(&mut m, "cpp", tree_sitter_cpp::LANGUAGE.into(), |
| 330 | &format!("{}\n{}", tree_sitter_c::HIGHLIGHT_QUERY, tree_sitter_cpp::HIGHLIGHT_QUERY), "", ""); |
| 331 | add(&mut m, "java", tree_sitter_java::LANGUAGE.into(), |
| 332 | tree_sitter_java::HIGHLIGHTS_QUERY, "", ""); |
| 333 | add(&mut m, "ruby", tree_sitter_ruby::LANGUAGE.into(), |
| 334 | tree_sitter_ruby::HIGHLIGHTS_QUERY, "", tree_sitter_ruby::LOCALS_QUERY); |
| 335 | add(&mut m, "json", tree_sitter_json::LANGUAGE.into(), |
| 336 | tree_sitter_json::HIGHLIGHTS_QUERY, "", ""); |
| 337 | add(&mut m, "toml", tree_sitter_toml_ng::LANGUAGE.into(), |
| 338 | tree_sitter_toml_ng::HIGHLIGHTS_QUERY, "", ""); |
| 339 | add(&mut m, "yaml", tree_sitter_yaml::LANGUAGE.into(), |
| 340 | tree_sitter_yaml::HIGHLIGHTS_QUERY, "", ""); |
| 341 | add(&mut m, "html", tree_sitter_html::LANGUAGE.into(), |
| 342 | tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_html::INJECTIONS_QUERY, ""); |
| 343 | add(&mut m, "css", tree_sitter_css::LANGUAGE.into(), |
| 344 | tree_sitter_css::HIGHLIGHTS_QUERY, "", ""); |
| 345 | add(&mut m, "bash", tree_sitter_bash::LANGUAGE.into(), |
| 346 | tree_sitter_bash::HIGHLIGHT_QUERY, "", ""); |
| 347 | add(&mut m, "markdown", tree_sitter_md::LANGUAGE.into(), |
| 348 | tree_sitter_md::HIGHLIGHT_QUERY_BLOCK, tree_sitter_md::INJECTION_QUERY_BLOCK, ""); |
| 349 | add(&mut m, "sql", tree_sitter_sequel::LANGUAGE.into(), |
| 350 | tree_sitter_sequel::HIGHLIGHTS_QUERY, "", ""); |
| 351 | add(&mut m, "php", tree_sitter_php::LANGUAGE_PHP.into(), |
| 352 | tree_sitter_php::HIGHLIGHTS_QUERY, tree_sitter_php::INJECTIONS_QUERY, ""); |
| 353 | add(&mut m, "c_sharp", tree_sitter_c_sharp::LANGUAGE.into(), |
| 354 | tree_sitter_c_sharp::HIGHLIGHTS_QUERY, "", ""); |
| 355 | add(&mut m, "scala", tree_sitter_scala::LANGUAGE.into(), |
| 356 | tree_sitter_scala::HIGHLIGHTS_QUERY, "", tree_sitter_scala::LOCALS_QUERY); |
| 357 | add(&mut m, "haskell", tree_sitter_haskell::LANGUAGE.into(), |
| 358 | tree_sitter_haskell::HIGHLIGHTS_QUERY, tree_sitter_haskell::INJECTIONS_QUERY, |
| 359 | tree_sitter_haskell::LOCALS_QUERY); |
| 360 | add(&mut m, "swift", tree_sitter_swift::LANGUAGE.into(), |
| 361 | tree_sitter_swift::HIGHLIGHTS_QUERY, tree_sitter_swift::INJECTIONS_QUERY, |
| 362 | tree_sitter_swift::LOCALS_QUERY); |
| 363 | add(&mut m, "elixir", tree_sitter_elixir::LANGUAGE.into(), |
| 364 | tree_sitter_elixir::HIGHLIGHTS_QUERY, tree_sitter_elixir::INJECTIONS_QUERY, ""); |
| 365 | add(&mut m, "lua", tree_sitter_lua::LANGUAGE.into(), |
| 366 | tree_sitter_lua::HIGHLIGHTS_QUERY, tree_sitter_lua::INJECTIONS_QUERY, |
| 367 | tree_sitter_lua::LOCALS_QUERY); |
| 368 | add(&mut m, "zig", tree_sitter_zig::LANGUAGE.into(), |
| 369 | tree_sitter_zig::HIGHLIGHTS_QUERY, tree_sitter_zig::INJECTIONS_QUERY, ""); |
| 370 | add(&mut m, "nix", tree_sitter_nix::LANGUAGE.into(), |
| 371 | tree_sitter_nix::HIGHLIGHTS_QUERY, tree_sitter_nix::INJECTIONS_QUERY, ""); |
| 372 | |
| 373 | m |
| 374 | }); |
| 375 | |
| 376 | /// `df-render` deliberately has no `tracing` dependency — it is a pure |
| 377 | /// rendering crate — so a grammar that fails to load reports on stderr. |
| 378 | /// This only ever runs once per process, at first use. |
| 379 | fn tracing_warn(name: &str, message: &str) { |
| 380 | eprintln!("df-render: grammar {name} failed to load, files will render as plain text: {message}"); |
| 381 | } |
| 382 | |
| 383 | #[cfg(test)] |
| 384 | mod tests { |
| 385 | use super::*; |
| 386 | |
| 387 | #[test] |
| 388 | fn highlights_rust_and_splits_by_line() { |
| 389 | let out = highlight("src/main.rs", "fn main() {\n let x = 1;\n}\n").unwrap(); |
| 390 | assert_eq!(out.language, "Rust"); |
| 391 | assert_eq!(out.lines.len(), 3, "one fragment per source line"); |
| 392 | assert!(out.lines[0].contains("hl-keyword"), "`fn` should be a keyword: {:?}", out.lines[0]); |
| 393 | // The Rust grammar captures integer literals as `constant.builtin`, not |
| 394 | // `number`. Asserting the family class rather than the leaf keeps this |
| 395 | // test about "the literal got classified" instead of about upstream's |
| 396 | // choice of capture name. |
| 397 | assert!( |
| 398 | out.lines[1].contains("hl-constant"), |
| 399 | "`1` should be classified: {:?}", |
| 400 | out.lines[1] |
| 401 | ); |
| 402 | } |
| 403 | |
| 404 | /// The security-critical property: repository content is attacker |
| 405 | /// controlled, and it reaches a page that renders on our origin. |
| 406 | #[test] |
| 407 | fn escaping_is_not_optional() { |
| 408 | let evil = "let s = \"</span><script>alert(1)</script>\";\n"; |
| 409 | let out = highlight("x.rs", evil).unwrap(); |
| 410 | let joined = out.lines.join("\n"); |
| 411 | assert!( |
| 412 | !joined.contains("<script>"), |
| 413 | "raw markup escaped from the highlighter: {joined}" |
| 414 | ); |
| 415 | assert!(joined.contains("<script>"), "expected escaped form: {joined}"); |
| 416 | } |
| 417 | |
| 418 | /// Even with no grammar the caller must be able to tell "plain" from |
| 419 | /// "broken", so every rejection carries a reason. |
| 420 | #[test] |
| 421 | fn unknown_extensions_fall_back_rather_than_erroring() { |
| 422 | assert_eq!(highlight("a.unknownext", "hello").unwrap_err(), Skipped::NoGrammar); |
| 423 | assert_eq!(highlight("noextension", "hello").unwrap_err(), Skipped::NoGrammar); |
| 424 | } |
| 425 | |
| 426 | #[test] |
| 427 | fn the_spec_size_limits_are_enforced_before_parsing() { |
| 428 | let big = "a\n".repeat(MAX_BYTES); |
| 429 | assert_eq!(highlight("a.rs", &big).unwrap_err(), Skipped::TooLarge); |
| 430 | |
| 431 | let minified = format!("let x = \"{}\";\n", "y".repeat(MAX_LINE_CHARS + 1)); |
| 432 | assert_eq!(highlight("a.js", &minified).unwrap_err(), Skipped::LineTooLong); |
| 433 | } |
| 434 | |
| 435 | #[test] |
| 436 | fn class_names_carry_the_whole_family() { |
| 437 | let idx = HIGHLIGHT_NAMES.iter().position(|n| *n == "function.builtin").unwrap(); |
| 438 | let class = String::from_utf8(CLASSES[idx].clone()).unwrap(); |
| 439 | assert_eq!(class, r#"class="hl-function hl-function-builtin""#); |
| 440 | } |
| 441 | |
| 442 | #[test] |
| 443 | fn filenames_without_a_useful_extension_still_resolve() { |
| 444 | assert_eq!(language_for_path("Dockerfile").unwrap().display, "Shell"); |
| 445 | assert_eq!(language_for_path("a/b/Makefile").unwrap().display, "Shell"); |
| 446 | assert_eq!(language_for_path("Cargo.lock").unwrap().display, "TOML"); |
| 447 | // Only the final extension counts. |
| 448 | assert!(language_for_path("archive.tar.gz").is_none()); |
| 449 | assert_eq!(language_for_path("a.tar.rs").unwrap().display, "Rust"); |
| 450 | } |
| 451 | |
| 452 | /// Every grammar in the display table must actually have loaded. A typo in |
| 453 | /// a query, or an upstream grammar that stops compiling its queries against |
| 454 | /// the pinned `tree-sitter`, silently turns a language plain — this is the |
| 455 | /// test that notices. |
| 456 | #[test] |
| 457 | fn every_advertised_grammar_loads() { |
| 458 | for (key, display) in GRAMMAR_DISPLAY { |
| 459 | assert!( |
| 460 | GRAMMARS.contains_key(key), |
| 461 | "grammar {key} ({display}) is advertised but did not load" |
| 462 | ); |
| 463 | } |
| 464 | assert!(GRAMMARS.len() >= 25, "spec §8 asks for roughly the top 25 languages"); |
| 465 | } |
| 466 | |
| 467 | /// A file that ends without a trailing newline must not lose its last line. |
| 468 | #[test] |
| 469 | fn a_missing_trailing_newline_does_not_drop_a_line() { |
| 470 | let out = highlight("a.rs", "fn a() {}\nfn b() {}").unwrap(); |
| 471 | assert_eq!(out.lines.len(), 2); |
| 472 | assert!(out.lines[1].contains("b")); |
| 473 | } |
| 474 | |
| 475 | #[test] |
| 476 | fn an_empty_file_highlights_to_nothing() { |
| 477 | let out = highlight("a.rs", "").unwrap(); |
| 478 | assert!(out.lines.is_empty() || out.lines == vec![""]); |
| 479 | } |
| 480 | } |
480 lines · Rust