~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
909 lines · 31.4 KB Raw History
//! 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<Vec<Language>> = 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 `<?php … ?>` 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<Highlighter> = 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<Vec<(u32, u32)>>,
}

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<Newest<Option<Arc<Ranges>>>> = 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<Arc<Ranges>> {
    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<Arc<Ranges>> {
    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<Arc<Ranges>> {
    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<Ranges> {
    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<usize> = 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<(u32, u32)>> = 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<usize> = (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<Ranges>, Vec<u32>)> {
    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<Vec<u32>> = 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<Ranges>, Vec<u32>)> {
    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<char>, Vec<char>) = (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::<u32>();
    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<String> {
        let units: Vec<u16> = 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 = () => <b x=\"1\">hi</b>; // c\n"),
            ("a.ts", "let x: number = 1;\n"),
            ("a.tsx", "const e = <div class=\"x\" />;\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", "<svg width=\"1\"><!-- c --></svg>\n"),
            ("fix.patch", "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"),
            ("flake.nix", "{ x = \"y\"; }\n"),
            ("a.php", "<p><?php echo 'x'; ?></p>\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 =
            "<a href=\"x\">y</a><script>let a = 'q';</script><style>b { color: red }</style>\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 = "<script lang=\"ts\">let a: number = 1;</script>\n\
            {#if a > 0}<b class=\"x\" title={'t'}>{a + 2}</b>{/if}\n\
            <style>b { color: red }</style>\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<u32>, new: Option<u32>, 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"]);
    }
}