//! Syntax highlighting as ranges, not markup: the page carries the text //! once, and a few lines of script hand these ranges to the CSS Custom //! Highlight API. Offsets are in UTF-16 code units, as the DOM counts. //! //! Code is parsed with tree-sitter, whose grammars take tens of megabytes a //! second; a language without one here is shown plain. use crate::card::Newest; use crate::diff::{Kind, Row}; use knotless::telemetry::looked; use sha2::{Digest, Sha256}; use std::cell::RefCell; use std::fmt::Write; use std::sync::{Arc, LazyLock}; use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; /// Larger files are shown plain: highlighting them costs more than it gives. pub const LIMIT: usize = 1024 * 1024; /// The highlight names for code, each styled by `::highlight(name)`. const CODE: &[&str] = &[ "comment", "string", "keyword", "number", "function", "type", "tag", "attr", ]; /// The captures the grammars' queries use, and the highlight each gives. /// A capture takes its longest listed prefix: `function.method` is a /// `function`. const CAPTURES: [(&str, usize); 23] = [ ("comment", 0), ("string", 1), ("string.special.key", 7), ("escape", 1), ("character", 1), ("keyword", 2), ("variable.builtin", 2), ("number", 3), ("float", 3), ("boolean", 3), ("constant", 3), ("constant.builtin", 3), ("function", 4), ("function.builtin", 4), ("function.method", 4), ("function.macro", 4), ("type", 5), ("type.builtin", 5), ("constructor", 5), ("tag", 6), ("attribute", 7), ("property.definition", 7), ("label", 2), ]; /// The names for a diff's rows: the code names, then the changed part of /// an added line, and of a removed one. const ROWS: &[&str] = &[ "comment", "string", "keyword", "number", "function", "type", "tag", "attr", "xins", "xdel", ]; struct Language { name: &'static str, extensions: &'static [&'static str], config: HighlightConfiguration, } fn language( name: &'static str, extensions: &'static [&'static str], grammar: tree_sitter_language::LanguageFn, highlights: &[&str], injections: &str, ) -> Language { let mut config = HighlightConfiguration::new(grammar.into(), name, &highlights.join("\n"), injections, "") .expect("a bundled highlight query compiles"); let names: Vec<&str> = CAPTURES.iter().map(|(n, _)| *n).collect(); config.configure(&names); Language { name, extensions, config, } } static LANGUAGES: LazyLock> = LazyLock::new(|| { use tree_sitter_javascript as js; use tree_sitter_typescript as ts; vec![ language( "rust", &["rs"], tree_sitter_rust::LANGUAGE, &[tree_sitter_rust::HIGHLIGHTS_QUERY], "", ), language( "javascript", &["js", "mjs", "cjs", "jsx"], js::LANGUAGE, &[js::HIGHLIGHT_QUERY, js::JSX_HIGHLIGHT_QUERY], "", ), language( "typescript", &["ts", "mts", "cts"], ts::LANGUAGE_TYPESCRIPT, &[js::HIGHLIGHT_QUERY, ts::HIGHLIGHTS_QUERY], "", ), language( "tsx", &["tsx"], ts::LANGUAGE_TSX, &[ js::HIGHLIGHT_QUERY, js::JSX_HIGHLIGHT_QUERY, ts::HIGHLIGHTS_QUERY, ], "", ), language( "python", &["py", "pyi"], tree_sitter_python::LANGUAGE, &[tree_sitter_python::HIGHLIGHTS_QUERY], "", ), language( "go", &["go"], tree_sitter_go::LANGUAGE, &[tree_sitter_go::HIGHLIGHTS_QUERY], "", ), language( "c", &["c", "h"], tree_sitter_c::LANGUAGE, &[tree_sitter_c::HIGHLIGHT_QUERY], "", ), language( "cpp", &["cc", "cpp", "cxx", "hpp", "hh", "hxx"], tree_sitter_cpp::LANGUAGE, &[ tree_sitter_c::HIGHLIGHT_QUERY, tree_sitter_cpp::HIGHLIGHT_QUERY, ], "", ), language( "java", &["java"], tree_sitter_java::LANGUAGE, &[tree_sitter_java::HIGHLIGHTS_QUERY], "", ), language( "ruby", &["rb", "rake", "gemspec", "Gemfile", "Rakefile"], tree_sitter_ruby::LANGUAGE, &[tree_sitter_ruby::HIGHLIGHTS_QUERY], "", ), language( "bash", &["sh", "bash", "zsh"], tree_sitter_bash::LANGUAGE, &[tree_sitter_bash::HIGHLIGHT_QUERY], "", ), language( "json", &["json"], tree_sitter_json::LANGUAGE, // Where patterns overlap the last wins: keys over strings. &[ tree_sitter_json::HIGHLIGHTS_QUERY, "(pair key: (_) @string.special.key)", ], "", ), language( "toml", &["toml", "lock"], tree_sitter_toml_ng::LANGUAGE, &[tree_sitter_toml_ng::HIGHLIGHTS_QUERY], "", ), language( "yaml", &["yml", "yaml"], tree_sitter_yaml::LANGUAGE, &[tree_sitter_yaml::HIGHLIGHTS_QUERY], "", ), language( "html", &["html", "htm"], tree_sitter_html::LANGUAGE, &[tree_sitter_html::HIGHLIGHTS_QUERY], tree_sitter_html::INJECTIONS_QUERY, ), language( "css", &["css"], tree_sitter_css::LANGUAGE, &[tree_sitter_css::HIGHLIGHTS_QUERY], "", ), language( "lua", &["lua"], tree_sitter_lua::LANGUAGE, &[tree_sitter_lua::HIGHLIGHTS_QUERY], "", ), language( "zig", &["zig"], tree_sitter_zig::LANGUAGE, &[tree_sitter_zig::HIGHLIGHTS_QUERY], "", ), language( "svelte", &["svelte"], tree_sitter_svelte_ng::LANGUAGE, // Its query builds on HTML's, as its grammar does. &[ tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_svelte_ng::HIGHLIGHTS_QUERY, r##"["#" ":" "/" "@"] @keyword"##, ], SVELTE_INJECTIONS, ), language( "hcl", &["tf", "tfvars", "hcl"], tree_sitter_hcl::LANGUAGE, &[HCL], "", ), language( "markdown", &["md", "markdown"], tree_sitter_md::LANGUAGE, &[MARKDOWN], MARKDOWN_INJECTIONS, ), language( "markdown_inline", &[], tree_sitter_md::INLINE_LANGUAGE, &[MARKDOWN_INLINE], tree_sitter_md::INJECTION_QUERY_INLINE, ), language( "dockerfile", &["Dockerfile", "Containerfile", "dockerfile"], tree_sitter_containerfile::LANGUAGE, &[tree_sitter_containerfile::HIGHLIGHTS_QUERY], tree_sitter_containerfile::INJECTIONS_QUERY, ), language( "xml", &["xml", "svg", "xsl", "xsd", "plist"], tree_sitter_xml::LANGUAGE_XML, &[ tree_sitter_xml::XML_HIGHLIGHT_QUERY, "(Attribute (Name) @attribute) (PseudoAtt (Name) @attribute)", ], "", ), language( "diff", &["diff", "patch"], tree_sitter_diff::LANGUAGE, &[DIFF], "", ), language( "nix", &["nix"], tree_sitter_nix::LANGUAGE, &[tree_sitter_nix::HIGHLIGHTS_QUERY], tree_sitter_nix::INJECTIONS_QUERY, ), language( "php", &["php"], tree_sitter_php::LANGUAGE_PHP, &[tree_sitter_php::HIGHLIGHTS_QUERY], // What is outside `` is HTML. r#"((text) @injection.content (#set! injection.language "html") (#set! injection.combined))"#, ), language( "csharp", &["cs"], tree_sitter_c_sharp::LANGUAGE, &[tree_sitter_c_sharp::HIGHLIGHTS_QUERY], "", ), language( "swift", &["swift"], tree_sitter_swift::LANGUAGE, &[tree_sitter_swift::HIGHLIGHTS_QUERY], "", ), ] }); /// A component's scripts are TypeScript, which reads JavaScript too, as /// do the expressions in its markup's braces. const SVELTE_INJECTIONS: &str = r#" ((script_element (raw_text) @injection.content) (#set! injection.language "typescript")) ((style_element (raw_text) @injection.content) (#set! injection.language "css")) ((svelte_raw_text) @injection.content (#set! injection.language "typescript")) "#; /// Terraform and the rest of HCL: the crate brings no query of its own. const HCL: &str = r#" (comment) @comment (block (identifier) @keyword) (attribute (identifier) @attribute) (object_elem key: (expression (variable_expr (identifier) @attribute))) (function_call (identifier) @function) [ (quoted_template_start) (quoted_template_end) (template_literal) (heredoc_start) (heredoc_identifier) ] @string (numeric_lit) @number [(bool_lit) (null_lit)] @constant.builtin ["for" "in" "if" "else" "endif" "endfor"] @keyword "#; /// Markdown's source. The crate's queries name captures no highlight here /// takes, so these are its own: headings, markers, code and links. const MARKDOWN: &str = " [(atx_heading) (setext_heading)] @function [ (list_marker_plus) (list_marker_minus) (list_marker_star) (list_marker_dot) (list_marker_parenthesis) (thematic_break) (block_quote_marker) ] @keyword (fenced_code_block_delimiter) @comment (info_string) @attribute (indented_code_block) @string [(link_destination) (link_title)] @string (link_label) @attribute "; /// The crate's injections, but taking in what the block grammar leaves /// within the text it hands on: without it, all but the first token is /// lost. const MARKDOWN_INJECTIONS: &str = r#" (fenced_code_block (info_string (language) @injection.language) (code_fence_content) @injection.content (#set! injection.include-children)) ((html_block) @injection.content (#set! injection.language "html")) ((minus_metadata) @injection.content (#set! injection.language "yaml")) ((plus_metadata) @injection.content (#set! injection.language "toml")) ((inline) @injection.content (#set! injection.language "markdown_inline") (#set! injection.include-children)) "#; /// The text within Markdown's blocks. const MARKDOWN_INLINE: &str = " (code_span) @string [(link_text) (image_description)] @attribute [(link_destination) (uri_autolink)] @string "; /// A patch: the crate's own query takes additions for strings. const DIFF: &str = " [(addition) (new_file)] @tag [(deletion) (old_file)] @keyword (location) @attribute (commit) @number (command) @comment "; thread_local! { static HIGHLIGHTER: RefCell = RefCell::new(Highlighter::new()); } /// The language named `name`, or with it for an extension: a Markdown /// fence names its code either way. fn by_name(name: &str) -> Option<&'static Language> { LANGUAGES .iter() .find(|l| l.name == name || l.extensions.contains(&name)) } /// The language of the file at `path`: by its extension, or its name, or /// the interpreter its first line names. fn language_for(path: &str, text: &str) -> Option<&'static Language> { let name = path.rsplit('/').next().unwrap_or(path); let ext = name.rsplit_once('.').map_or(name, |(_, ext)| ext); let by_ext = LANGUAGES.iter().find(|l| l.extensions.contains(&ext)); let shebang = text .lines() .next() .and_then(|l| l.strip_prefix("#!")) .unwrap_or_default(); let interpreter = shebang.rsplit(['/', ' ']).next().unwrap_or_default(); let by_shebang = match interpreter.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.') { "sh" | "bash" | "zsh" => by_name("bash"), "python" => by_name("python"), "node" => by_name("javascript"), "ruby" => by_name("ruby"), _ => None, }; by_ext.or(by_shebang) } /// Runs of each named highlight, as `[start, end)` in UTF-16 units. #[derive(Debug, PartialEq)] pub struct Ranges { names: &'static [&'static str], runs: Vec>, } impl Ranges { fn new(names: &'static [&'static str]) -> Ranges { Ranges { names, runs: vec![Vec::new(); names.len()], } } /// Adds a run, joining it to the one before when they touch. fn push(&mut self, class: usize, start: u32, end: u32) { let runs = &mut self.runs[class]; match runs.last_mut() { Some(last) if last.1 == start => last.1 = end, _ if start < end => runs.push((start, end)), _ => {} } } /// `name:gap,len,gap,len;…`, each gap from the end of the run before: /// short, and safe in an attribute as it is. pub fn encode(&self) -> String { let mut out = String::new(); let named = self .names .iter() .zip(&self.runs) .filter(|(_, r)| !r.is_empty()); for (name, runs) in named { let sep = if out.is_empty() { "" } else { ";" }; write!(out, "{sep}{name}:").unwrap(); let mut at = 0; for (i, (start, end)) in runs.iter().enumerate() { let sep = if i == 0 { "" } else { "," }; write!(out, "{sep}{},{}", start - at, end - start).unwrap(); at = *end; } } out } } fn utf16_len(s: &str) -> u32 { s.chars().map(|c| c.len_utf16() as u32).sum() } /// Texts highlighted lately, by language and digest: a pull's page reads /// the whole of each file it changes, both sides, on every view, and /// parsing a long one takes a good part of a second. static HIGHLIGHTED: LazyLock>>> = LazyLock::new(|| Newest::new(128)); /// The highlighting of `text`, the file at `path`: `None` for a language /// without a grammar here, or a file too large. pub fn code(path: &str, text: &str) -> Option> { cached(language_for(path, text)?, text) } /// The highlighting of `text`, a Markdown block of code fenced as `info` /// says: by the language its first word names, as GitHub takes it. pub fn fence(info: &str, text: &str) -> Option> { let name = info.split_whitespace().next()?.to_ascii_lowercase(); cached(by_name(&name)?, text) } /// [`parsed`], kept in [`HIGHLIGHTED`]: nothing for a text too large. fn cached(language: &'static Language, text: &str) -> Option> { if text.len() > LIMIT { return None; } let key = format!("{}:{:x}", language.name, Sha256::digest(text)); if let Some(known) = looked("highlights", HIGHLIGHTED.get(&key)) { return known; } let ranges = parsed(language, text).map(Arc::new); HIGHLIGHTED.put(&key, ranges.clone()); ranges } fn parsed(language: &Language, text: &str) -> Option { HIGHLIGHTER.with_borrow_mut(|highlighter| { let events = highlighter .highlight(&language.config, text.as_bytes(), None, None, |name| { by_name(name).map(|l| &l.config) }) .ok()?; let mut ranges = Ranges::new(CODE); let mut stack: Vec = Vec::new(); // Sources come in order, so offsets are counted once, as they go. let (mut byte, mut unit) = (0, 0u32); for event in events { match event.ok()? { HighlightEvent::HighlightStart(h) => stack.push(CAPTURES[h.0].1), HighlightEvent::HighlightEnd => { stack.pop(); } HighlightEvent::Source { start, end } => { unit += utf16_len(&text[byte..start]); let len = utf16_len(&text[start..end]); if let Some(&class) = stack.last() { ranges.push(class, unit, unit + len); } (byte, unit) = (end, unit + len); } } } Some(ranges) }) } /// The whole of each side of a file's diff, where it is known. Its rows /// are then highlighted as they read in their file: a hunk can start /// inside a string or a comment, and read alone its lines would be taken /// for something else. #[derive(Clone, Copy, Debug, Default)] pub struct Sides<'a> { pub old: Option<&'a str>, pub new: Option<&'a str>, } /// The highlighting of a file's changed lines, shown a row each: offsets /// run over the lines' text as if joined by newlines. Removed lines are /// highlighted as the old side reads, and the rest as the new side reads: /// in the whole of that side where `sides` has it, or else in the context /// the diff shows. Of a removed line and the added line paired with it, /// the part that differs is marked too. pub fn rows(path: &str, rows: &[Row], sides: Sides) -> Ranges { let lines: Vec<&Row> = rows.iter().filter(|r| r.kind.is_line()).collect(); let mut starts = Vec::with_capacity(lines.len()); let mut at = 0; for line in &lines { starts.push(at); at += utf16_len(&line.text) + 1; } let mut runs: Vec> = vec![Vec::new(); ROWS.len()]; for side in [Kind::Del, Kind::Add] { let (other, whole) = if side == Kind::Del { (Kind::Add, sides.old) } else { (Kind::Del, sides.new) }; let picked: Vec = (0..lines.len()) .filter(|&i| lines[i].kind != other) .collect(); let read = whole.and_then(|w| in_whole(path, w, &lines, &picked, side)); let Some((ranges, side_starts)) = read.or_else(|| joined(path, &lines, &picked)) else { break; }; for (class, list) in ranges.runs.iter().enumerate() { for &(s, e) in list { let mut j = side_starts.partition_point(|&x| x <= s).saturating_sub(1); while j < picked.len() && side_starts[j] < e { let i = picked[j]; let (from, to) = ( s.max(side_starts[j]), e.min(side_starts[j] + utf16_len(&lines[i].text)), ); let shown = side == Kind::Add || lines[i].kind == Kind::Del; if shown && from < to { // From where the line starts on its side to where // its row starts as shown. let moved = |x: u32| x - side_starts[j] + starts[i]; runs[class].push((moved(from), moved(to))); } j += 1; } } } } // Each run of removed lines, and the added lines after it. let mut i = 0; while i < lines.len() { let dels = lines[i..] .iter() .take_while(|l| l.kind == Kind::Del) .count(); let adds = lines[i + dels..] .iter() .take_while(|l| l.kind == Kind::Add) .count(); for k in 0..dels.min(adds) { let (d, a) = (i + k, i + dels + k); if let Some((dr, ar)) = changed(&lines[d].text, &lines[a].text) { runs[ROWS.len() - 1].push((starts[d] + dr.0, starts[d] + dr.1)); runs[ROWS.len() - 2].push((starts[a] + ar.0, starts[a] + ar.1)); } } i += (dels + adds).max(1); } let mut out = Ranges::new(ROWS); for (class, mut list) in runs.into_iter().enumerate() { list.sort_unstable(); for (s, e) in list { out.push(class, s, e); } } out } /// The highlighting of the whole of one side, and where in it each picked /// row's line starts: `None` if a row's number does not find its text /// there, as then this is not the text the diff was made from. fn in_whole( path: &str, whole: &str, lines: &[&Row], picked: &[usize], side: Kind, ) -> Option<(Arc, Vec)> { let mut at = 0; let file: Vec<(u32, &str)> = whole .split('\n') .map(|l| { let start = at; at += utf16_len(l) + 1; // Git's lines end at `\n`; the diff's rows lose a `\r` before it. (start, l.strip_suffix('\r').unwrap_or(l)) }) .collect(); let number = |i: usize| match side { Kind::Del => lines[i].old, _ => lines[i].new, }; let found = |i: usize| { let at = number(i).and_then(|n| n.checked_sub(1)); let (start, text) = at.and_then(|n| file.get(n as usize))?; (*text == lines[i].text).then_some(*start) }; let starts: Option> = picked.iter().map(|&i| found(i)).collect(); let starts = starts?; Some((code(path, whole)?, starts)) } /// The highlighting of just the picked rows, joined by newlines, and where /// each starts: all there is to go on without the whole side. fn joined(path: &str, lines: &[&Row], picked: &[usize]) -> Option<(Arc, Vec)> { let mut text = String::new(); let mut starts = Vec::with_capacity(picked.len()); let mut at = 0; for &i in picked { starts.push(at); at += utf16_len(&lines[i].text) + 1; text.push_str(&lines[i].text); text.push('\n'); } Some((code(path, &text)?, starts)) } /// Where `old` and `new` differ, in each, between what they start and end /// with alike: `None` when that is less than half the shorter, as then the /// line was rewritten rather than edited. fn changed(old: &str, new: &str) -> Option<((u32, u32), (u32, u32))> { let (o, n): (Vec, Vec) = (old.chars().collect(), new.chars().collect()); let prefix = o.iter().zip(&n).take_while(|(a, b)| a == b).count(); let room = o.len().min(n.len()) - prefix; let suffix = o .iter() .rev() .zip(n.iter().rev()) .take(room) .take_while(|(a, b)| a == b) .count(); if (prefix + suffix) * 2 < o.len().min(n.len()) || prefix + suffix == 0 { return None; } let units = |c: &[char]| c.iter().map(|c| c.len_utf16() as u32).sum::(); let span = |c: &[char]| (units(&c[..prefix]), units(&c[..c.len() - suffix])); Some((span(&o), span(&n))) } #[cfg(test)] mod tests { use super::*; /// The text each run of `name` covers. fn spans(r: &Ranges, text: &str, name: &str) -> Vec { let units: Vec = text.encode_utf16().collect(); let class = r.names.iter().position(|n| *n == name).unwrap(); r.runs[class] .iter() .map(|(s, e)| String::from_utf16(&units[*s as usize..*e as usize]).unwrap()) .collect() } #[test] fn rust() { let text = "// é😀 hi\nfn main() { let s: u8 = \"x\"; 42 }\n"; let r = code("src/main.rs", text).unwrap(); // The same text again is the one highlighted before. assert!(Arc::ptr_eq(&r, &code("lib.rs", text).unwrap())); assert_eq!(spans(&r, text, "comment"), ["// é😀 hi"]); assert_eq!(spans(&r, text, "string"), ["\"x\""]); assert_eq!(spans(&r, text, "number"), ["42"]); assert_eq!(spans(&r, text, "function"), ["main"]); assert_eq!(spans(&r, text, "type"), ["u8"]); assert!(spans(&r, text, "keyword").contains(&"fn".to_string())); let encoded = r.encode(); assert!(encoded.starts_with("comment:0,9;string:"), "{encoded}"); } #[test] fn every_language_parses() { let samples = [ ("a.js", "const f = () => hi; // c\n"), ("a.ts", "let x: number = 1;\n"), ("a.tsx", "const e =
;\n"), ("a.py", "def f():\n return 'x'\n"), ("a.go", "package main\nfunc main() {}\n"), ("a.c", "int main(void) { return 0; }\n"), ("a.cpp", "class A {};\n"), ("A.java", "class A { void f() {} }\n"), ("Gemfile", "gem 'x'\n"), ("a.json", "{\"key\": 1}\n"), ("Cargo.lock", "[[package]]\nname = \"x\"\n"), ("a.yml", "a: 'b'\n"), ("a.css", "a { color: red; }\n"), ("a.lua", "local x = 'y'\n"), ("a.zig", "const x = 1;\n"), ("run", "#!/usr/bin/env bash\necho 'hi'\n"), ("tool", "#!/usr/bin/python3.12\nx = 'y'\n"), ("cli", "#!/usr/bin/env node\nlet a = 'b'\n"), ("task", "#!/usr/bin/ruby\nputs 'x'\n"), ("main.tf", "resource \"a\" \"b\" {\n n = 1 # c\n}\n"), ("README.md", "# Title\n\n- `code`\n"), ("Dockerfile", "FROM alpine AS base\nRUN echo 'hi'\n"), ("icon.svg", "\n"), ("fix.patch", "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"), ("flake.nix", "{ x = \"y\"; }\n"), ("a.php", "

\n"), ("A.cs", "class A { int f() { return 1; } }\n"), ("a.swift", "let x = \"y\"\n"), ]; for (path, text) in samples { let r = code(path, text).expect(path); assert!(!r.encode().is_empty(), "{path}"); } let json = "{\"key\": 1}\n"; assert_eq!( spans(&code("a.json", json).unwrap(), json, "attr"), ["\"key\""] ); } #[test] fn html_carries_its_scripts_and_styles() { let html = "y\n"; let r = code("index.html", html).unwrap(); assert_eq!(spans(&r, html, "attr"), ["href"]); assert!( spans(&r, html, "string").contains(&"'q'".to_string()), "{:?}", r ); assert!(spans(&r, html, "tag").contains(&"script".to_string())); } #[test] fn svelte_carries_its_scripts_styles_and_expressions() { let svelte = "\n\ {#if a > 0}{a + 2}{/if}\n\ \n"; let r = code("Avatar.svelte", svelte).unwrap(); assert_eq!(spans(&r, svelte, "attr"), ["lang", "class", "title"]); assert!(spans(&r, svelte, "type").contains(&"number".to_string())); let numbers = spans(&r, svelte, "number"); assert!( ["1", "0", "2"] .iter() .all(|n| numbers.contains(&n.to_string())) ); assert!(spans(&r, svelte, "string").contains(&"'t'".to_string())); assert!(spans(&r, svelte, "keyword").contains(&"#if".to_string())); assert!(spans(&r, svelte, "tag").contains(&"style".to_string())); } #[test] fn markdown_fences_take_their_language() { let md = "# Hi\n\n```rs\nfn f() {}\n```\n\nSee [it](x.md).\n"; let r = code("README.md", md).unwrap(); assert_eq!(spans(&r, md, "function"), ["# Hi\n", "f"]); assert_eq!(spans(&r, md, "attr"), ["rs", "it"]); assert_eq!(spans(&r, md, "string"), ["x.md"]); } #[test] fn what_it_leaves_plain() { assert_eq!(code("notes.unknown-kind", "plain\n"), None); assert_eq!(code("script", "#!/bin/perl\n"), None); assert_eq!(code("big.rs", &"x".repeat(LIMIT + 1)), None); let mut empty = Ranges::new(CODE); empty.push(0, 4, 4); assert_eq!(empty.encode(), ""); } fn row(kind: Kind, old: Option, new: Option, text: &str) -> Row { Row { kind, old, new, text: text.into(), } } #[test] fn diff_rows() { let rows = [ row(Kind::Hunk, None, None, "@@ -1,3 +1,3 @@"), row(Kind::Context, None, None, "/* a"), row(Kind::Del, None, None, "b */ let x = 1;"), row(Kind::Add, None, None, "b */ let x = 2;"), row(Kind::Add, None, None, "let é = \"q\";"), row(Kind::Note, None, None, "\\ No newline at end of file"), row(Kind::Del, None, None, "gone"), row(Kind::Add, None, None, "here"), ]; let r = super::rows("a.rs", &rows, Sides::default()); let shown = "/* a\nb */ let x = 1;\nb */ let x = 2;\nlet é = \"q\";\ngone\nhere\n"; // The comment spans two rows, and is cut at the row between. assert_eq!(spans(&r, shown, "comment"), ["/* a", "b */", "b */"]); assert_eq!(spans(&r, shown, "number"), ["1", "2"]); assert_eq!(spans(&r, shown, "string"), ["\"q\""]); assert_eq!(spans(&r, shown, "xdel"), ["1"]); assert_eq!(spans(&r, shown, "xins"), ["2"]); assert_eq!( super::rows("notes.unknown-kind", &rows, Sides::default()).encode(), "xins:34,1;xdel:18,1" ); assert_eq!(changed("abc", "xyz"), None); assert_eq!(changed("gone", "here"), None); assert_eq!(changed("aé", "aéb"), Some(((2, 2), (2, 3)))); assert_eq!(changed("aa", "a"), Some(((1, 2), (1, 1)))); } #[test] fn diff_rows_in_their_files() { // The first hunk opens a string the second never sees close: read // alone, the string is taken for code. let old = "const Q: &str = \"a\nb\nc\nd\ne\";\n/// doc\nfn f() {}\n"; let new = "const Q: &str = \"a\r\nB\r\nc\r\nd\r\ne\";\r\n/// doc\r\nfn g() {}\r\n"; let rows = [ row(Kind::Hunk, None, None, "@@ -1,2 +1,2 @@"), row(Kind::Context, Some(1), Some(1), "const Q: &str = \"a"), row(Kind::Del, Some(2), None, "b"), row(Kind::Add, None, Some(2), "B"), row(Kind::Hunk, None, None, "@@ -6,2 +6,2 @@"), row(Kind::Context, Some(6), Some(6), "/// doc"), row(Kind::Del, Some(7), None, "fn f() {}"), row(Kind::Add, None, Some(7), "fn g() {}"), ]; let shown = "const Q: &str = \"a\nb\nB\n/// doc\nfn f() {}\nfn g() {}\n"; let both = Sides { old: Some(old), new: Some(new), }; let r = super::rows("a.rs", &rows, both); assert_eq!(spans(&r, shown, "string"), ["\"a", "b", "B"]); assert_eq!(spans(&r, shown, "comment"), ["/// doc"]); assert_eq!(spans(&r, shown, "function"), ["f", "g"]); let alone = super::rows("a.rs", &rows, Sides::default()); assert!(spans(&alone, shown, "string").is_empty(), "{alone:?}"); // A side whose lines are not the diff's is no help: the rows are // read alone, as they are without it. let wrong = Sides { old: Some("nothing like it\n"), new: Some(""), }; assert_eq!(super::rows("a.rs", &rows, wrong), alone); // Nor is one in a language without a grammar here. assert_eq!( super::rows("a.unknown-kind", &rows, both), super::rows("a.unknown-kind", &rows, Sides::default()) ); // A side with no rows of its own still lines up. let added = [row(Kind::Add, None, Some(2), "B")]; let r = super::rows("a.rs", &added, both); assert_eq!(spans(&r, "B\n", "string"), ["B"]); } }