Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Implement pretty diffs with highlighting
cloudhead committed 2 years ago
commit 90fe50c152853e569e6af288041f909694ed7902
parent 7abfd4870cf22731fc3999b3c4d8091cb9ba1c53
7 files changed +869 -2
modified Cargo.lock
@@ -1828,6 +1828,19 @@ dependencies = [
 "tempfile",
 "thiserror",
 "timeago",
+
 "tree-sitter",
+
 "tree-sitter-bash",
+
 "tree-sitter-c",
+
 "tree-sitter-css",
+
 "tree-sitter-go",
+
 "tree-sitter-highlight",
+
 "tree-sitter-html",
+
 "tree-sitter-md",
+
 "tree-sitter-python",
+
 "tree-sitter-ruby",
+
 "tree-sitter-rust",
+
 "tree-sitter-toml",
+
 "tree-sitter-typescript",
 "ureq",
 "zeroize",
]
@@ -2922,6 +2935,137 @@ dependencies = [
]

[[package]]
+
name = "tree-sitter"
+
version = "0.20.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d"
+
dependencies = [
+
 "cc",
+
 "regex",
+
]
+

+
[[package]]
+
name = "tree-sitter-bash"
+
version = "0.20.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "096f57b3b44c04bfc7b21a4da44bfa16adf1f88aba18993b8478a091076d0968"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-c"
+
version = "0.20.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "30b03bdf218020057abee831581a74bff8c298323d6c6cd1a70556430ded9f4b"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-css"
+
version = "0.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c44fce8f9b603fef51e6384e2771ec5448bca1b71f1aa4ee2717a1803f9b279b"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-go"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ad6d11f19441b961af2fda7f12f5d0dac325f6d6de83836a1d3750018cc5114"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-highlight"
+
version = "0.20.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "042342584c5a7a0b833d9fc4e2bdab3f9868ddc6c4b339a1e01451c6720868bc"
+
dependencies = [
+
 "regex",
+
 "thiserror",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-html"
+
version = "0.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "184e6b77953a354303dc87bf5fe36558c83569ce92606e7b382a0dc1b7443443"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-md"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a237fa10f6b466b76c783c79b08cc172581e547ef1dbb6ddf1f8b4e230157e1"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-python"
+
version = "0.20.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c93b1b1fbd0d399db3445f51fd3058e43d0b4dcff62ddbdb46e66550978aa5"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-ruby"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0ac30cbb1560363ae76e1ccde543d6d99087421e228cc47afcec004b86bb711a"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-rust"
+
version = "0.20.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b0832309b0b2b6d33760ce5c0e818cb47e1d72b468516bfe4134408926fa7594"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-toml"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca517f578a98b23d20780247cc2688407fa81effad5b627a5a364ec3339b53e8"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-typescript"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "079c695c32d39ad089101c66393aeaca30e967fba3486a91f573d2f0e12d290a"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
name = "try-lock"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified radicle-cli/Cargo.toml
@@ -29,6 +29,19 @@ serde_yaml = { version = "0.8" }
similar = { version = "2.2.1" }
thiserror = { version = "1" }
timeago = { version = "0.3", default-features = false }
+
tree-sitter = { version = "0.20.0" }
+
tree-sitter-highlight = { version = "0.20" }
+
tree-sitter-rust = { version = "0.20" }
+
tree-sitter-typescript = { version = "0.20" }
+
tree-sitter-html = { version = "0.19" }
+
tree-sitter-css = { version = "0.19" }
+
tree-sitter-toml = { version = "0.20" }
+
tree-sitter-c = { version = "0.20" }
+
tree-sitter-python = { version = "0.20" }
+
tree-sitter-ruby = { version = "0.20" }
+
tree-sitter-bash = { version = "0.20" }
+
tree-sitter-go = { version = "0.20.0" }
+
tree-sitter-md = { version = "0.1.5" }
ureq = { version = "2.6.1", default-features = false, features = ["json"] }
zeroize = { version = "1.1" }

modified radicle-cli/src/git.rs
@@ -1,7 +1,7 @@
//! Git-related functions and types.

pub mod ddiff;
-
#[path = "git/unified_diff.rs"]
+
pub mod pretty_diff;
pub mod unified_diff;

use std::collections::HashSet;
added radicle-cli/src/git/pretty_diff.rs
@@ -0,0 +1,377 @@
+
use radicle::git;
+
use radicle_surf::diff;
+
use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
+
use radicle_term as term;
+
use term::cell::Cell;
+

+
use crate::git::unified_diff::FileHeader;
+
use crate::terminal::highlight::{Highlighter, Theme};
+

+
use super::unified_diff::{Decode, HunkHeader};
+

+
/// A repository of Git blobs.
+
pub trait Repo {
+
    /// Lookup a blob from the repo.
+
    fn blob(&self, oid: git::Oid) -> Result<git::raw::Blob, git::raw::Error>;
+
}
+

+
impl Repo for git::raw::Repository {
+
    fn blob(&self, oid: git::Oid) -> Result<git::raw::Blob, git::raw::Error> {
+
        self.find_blob(*oid)
+
    }
+
}
+

+
/// Blobs passed down to the hunk renderer.
+
#[derive(Debug, Default)]
+
pub struct Blobs {
+
    old: Option<Vec<term::Line>>,
+
    new: Option<Vec<term::Line>>,
+
}
+

+
/// Types that can be rendered as pretty diffs.
+
pub trait ToPretty {
+
    /// The output of the render process.
+
    type Output: term::Element;
+
    /// Context that can be passed down from parent objects during rendering.
+
    type Context;
+

+
    /// Render to pretty diff output.
+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output;
+
}
+

+
impl ToPretty for Diff {
+
    type Output = term::VStack<'static>;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        term::VStack::default()
+
            .padding(0)
+
            .children(self.files().map(|f| f.pretty(hi, context, repo).boxed()))
+
    }
+
}
+

+
impl ToPretty for FileHeader {
+
    type Output = term::Line;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        _hi: &mut Highlighter,
+
        _context: &Self::Context,
+
        _repo: &R,
+
    ) -> Self::Output {
+
        match self {
+
            FileHeader::Added { path, .. } => term::Line::new(path.display().to_string()),
+
            FileHeader::Moved { new_path, .. } => term::Line::new(new_path.display().to_string()),
+
            FileHeader::Deleted { path, .. } => term::Line::new(path.display().to_string()),
+
            FileHeader::Modified { path, .. } => term::Line::new(path.display().to_string()),
+
            FileHeader::Copied { new_path, .. } => term::Line::new(new_path.display().to_string()),
+
        }
+
    }
+
}
+

+
impl ToPretty for FileDiff {
+
    type Output = term::VStack<'static>;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        _context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        let content = match self {
+
            FileDiff::Added(f) => f.diff.pretty(hi, self, repo),
+
            FileDiff::Moved(f) => f.diff.pretty(hi, self, repo),
+
            FileDiff::Deleted(f) => f.diff.pretty(hi, self, repo),
+
            FileDiff::Modified(f) => f.diff.pretty(hi, self, repo),
+
            FileDiff::Copied(f) => f.diff.pretty(hi, self, repo),
+
        };
+
        term::VStack::default()
+
            .padding(0)
+
            .child(content)
+
            .child(term::Line::blank())
+
    }
+
}
+

+
impl ToPretty for DiffContent {
+
    type Output = term::VStack<'static>;
+
    type Context = FileDiff;
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        let header = FileHeader::from(context);
+
        let theme = Theme::default();
+

+
        let (old, new) = match context {
+
            FileDiff::Added(f) => (None, Some(f.new.oid)),
+
            FileDiff::Moved(f) => (Some(f.old.oid), Some(f.new.oid)),
+
            FileDiff::Deleted(f) => (Some(f.old.oid), None),
+
            FileDiff::Modified(f) => (Some(f.old.oid), Some(f.new.oid)),
+
            FileDiff::Copied(_) => {
+
                // This is due to not having `oid`s for copied files yet.
+
                unimplemented!("DiffContent::pretty: copied files are not supported in diffs")
+
            }
+
        };
+

+
        let mut header = header.pretty(hi, &(), repo);
+
        let mut additions = 0;
+
        let mut deletions = 0;
+

+
        match self {
+
            DiffContent::Plain { hunks, .. } => {
+
                for h in hunks.iter() {
+
                    for l in &h.lines {
+
                        match l {
+
                            Modification::Addition(_) => additions += 1,
+
                            Modification::Deletion(_) => deletions += 1,
+
                            _ => {}
+
                        }
+
                    }
+
                }
+
            }
+
            DiffContent::Empty => {}
+
            DiffContent::Binary => {}
+
        }
+
        if deletions > 0 {
+
            header.push(term::label(format!(" -{deletions}")).fg(theme.color("negative.light")));
+
        }
+
        if additions > 0 {
+
            header.push(term::label(format!(" +{additions}")).fg(theme.color("positive.light")));
+
        }
+

+
        let old = old.and_then(|oid| repo.blob(oid).ok());
+
        let new = new.and_then(|oid| repo.blob(oid).ok());
+
        let mut blobs = Blobs::default();
+

+
        if let Some(blob) = old {
+
            if !blob.is_binary() {
+
                blobs.old = hi.highlight(context.path(), blob.content()).ok().flatten();
+
            }
+
        }
+
        if let Some(blob) = new {
+
            if !blob.is_binary() {
+
                blobs.new = hi.highlight(context.path(), blob.content()).ok().flatten();
+
            }
+
        }
+
        let mut vstack = term::VStack::default()
+
            .border(Some(term::colors::FAINT))
+
            .padding(0)
+
            .child(term::Line::new(term::Label::space()).extend(header))
+
            .divider();
+

+
        match self {
+
            DiffContent::Plain { hunks, .. } => {
+
                for (i, h) in hunks.iter().enumerate() {
+
                    vstack.push(h.pretty(hi, &blobs, repo));
+
                    if i != hunks.0.len() - 1 {
+
                        vstack = vstack.divider();
+
                    }
+
                }
+
            }
+
            DiffContent::Empty => {
+
                vstack.push(term::Line::new(term::format::italic("Empty file")));
+
            }
+
            DiffContent::Binary => {
+
                vstack.push(term::Line::new(term::format::italic("Binary file")));
+
            }
+
        }
+
        vstack
+
    }
+
}
+

+
impl ToPretty for HunkHeader {
+
    type Output = term::Line;
+
    type Context = ();
+

+
    fn pretty<R: Repo>(
+
        &self,
+
        _hi: &mut Highlighter,
+
        _context: &Self::Context,
+
        _repo: &R,
+
    ) -> Self::Output {
+
        term::Line::spaced([
+
            term::label(format!(
+
                "@@ -{},{} +{},{} @@",
+
                self.old_line_no, self.old_size, self.new_line_no, self.new_size,
+
            ))
+
            .fg(term::colors::fixed::FAINT),
+
            term::label(String::from_utf8_lossy(&self.text).to_string())
+
                .fg(term::colors::fixed::DIM),
+
        ])
+
    }
+
}
+

+
impl ToPretty for Hunk<Modification> {
+
    type Output = term::VStack<'static>;
+
    type Context = Blobs;
+

+
    fn pretty<R: Repo>(&self, hi: &mut Highlighter, blobs: &Blobs, repo: &R) -> Self::Output {
+
        let mut vstack = term::VStack::default().padding(0);
+
        let mut table = term::Table::<5, term::Filled<term::Line>>::new(term::TableOptions {
+
            overflow: false,
+
            spacing: 0,
+
            border: None,
+
        });
+
        let theme = Theme::default();
+

+
        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
+
            vstack.push(header.pretty(hi, &(), repo));
+
        }
+
        for line in &self.lines {
+
            match line {
+
                Modification::Addition(a) => {
+
                    table.push([
+
                        term::Label::space()
+
                            .pad(5)
+
                            .bg(theme.color("positive"))
+
                            .to_line()
+
                            .filled(theme.color("positive")),
+
                        term::label(a.line_no.to_string())
+
                            .pad(5)
+
                            .fg(theme.color("positive.light"))
+
                            .to_line()
+
                            .filled(theme.color("positive")),
+
                        term::label(" + ")
+
                            .fg(theme.color("positive.light"))
+
                            .to_line()
+
                            .filled(theme.color("positive.dark")),
+
                        line.pretty(hi, blobs, repo)
+
                            .filled(theme.color("positive.dark")),
+
                        term::Line::blank().filled(term::Color::default()),
+
                    ]);
+
                }
+
                Modification::Deletion(a) => {
+
                    table.push([
+
                        term::label(a.line_no.to_string())
+
                            .pad(5)
+
                            .fg(theme.color("negative.light"))
+
                            .to_line()
+
                            .filled(theme.color("negative")),
+
                        term::Label::space()
+
                            .pad(5)
+
                            .fg(theme.color("dim"))
+
                            .to_line()
+
                            .filled(theme.color("negative")),
+
                        term::label(" - ")
+
                            .fg(theme.color("negative.light"))
+
                            .to_line()
+
                            .filled(theme.color("negative.dark")),
+
                        line.pretty(hi, blobs, repo)
+
                            .filled(theme.color("negative.dark")),
+
                        term::Line::blank().filled(term::Color::default()),
+
                    ]);
+
                }
+
                Modification::Context {
+
                    line_no_old,
+
                    line_no_new,
+
                    ..
+
                } => {
+
                    table.push([
+
                        term::label(line_no_old.to_string())
+
                            .pad(5)
+
                            .fg(theme.color("dim"))
+
                            .to_line()
+
                            .filled(theme.color("faint")),
+
                        term::label(line_no_new.to_string())
+
                            .pad(5)
+
                            .fg(theme.color("dim"))
+
                            .to_line()
+
                            .filled(theme.color("faint")),
+
                        term::label("   ").to_line().filled(term::Color::default()),
+
                        line.pretty(hi, blobs, repo).filled(term::Color::default()),
+
                        term::Line::blank().filled(term::Color::default()),
+
                    ]);
+
                }
+
            }
+
        }
+
        vstack.push(table);
+
        vstack
+
    }
+
}
+

+
impl ToPretty for Modification {
+
    type Output = term::Line;
+
    type Context = Blobs;
+

+
    fn pretty<R: Repo>(&self, _hi: &mut Highlighter, blobs: &Blobs, _repo: &R) -> Self::Output {
+
        match self {
+
            Modification::Deletion(diff::Deletion { line, line_no }) => {
+
                if let Some(lines) = &blobs.old.as_ref() {
+
                    lines[*line_no as usize - 1].clone()
+
                } else {
+
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
+
                }
+
            }
+
            Modification::Addition(diff::Addition { line, line_no }) => {
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    lines[*line_no as usize - 1].clone()
+
                } else {
+
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
+
                }
+
            }
+
            Modification::Context {
+
                line, line_no_new, ..
+
            } => {
+
                // Nb. we can check in the old or the new blob, we choose the new.
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    lines[*line_no_new as usize - 1].clone()
+
                } else {
+
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
+
                }
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use std::ffi::OsStr;
+

+
    use term::Constraint;
+
    use term::Element;
+

+
    use super::*;
+
    use radicle::git::raw::RepositoryOpenFlags;
+
    use radicle::git::raw::{Oid, Repository};
+

+
    #[test]
+
    #[ignore]
+
    fn test_pretty() {
+
        let repo = Repository::open_ext::<_, _, &[&OsStr]>(
+
            env!("CARGO_MANIFEST_DIR"),
+
            RepositoryOpenFlags::all(),
+
            &[],
+
        )
+
        .unwrap();
+
        let commit = repo
+
            .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
+
            .unwrap();
+
        let parent = commit.parents().next().unwrap();
+
        let old_tree = parent.tree().unwrap();
+
        let new_tree = commit.tree().unwrap();
+
        let diff = repo
+
            .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
+
            .unwrap();
+
        let diff = Diff::try_from(diff).unwrap();
+

+
        let mut hi = Highlighter::default();
+
        let pretty = diff.pretty(&mut hi, &(), &repo);
+

+
        pretty.write(Constraint::from_env().maximize());
+
    }
+
}
modified radicle-cli/src/git/unified_diff.rs
@@ -148,7 +148,12 @@ pub trait Decode: Sized {

    /// Decode from a string input.
    fn parse(s: &str) -> Result<Self, Error> {
-
        let mut r = io::BufReader::new(s.as_bytes());
+
        Self::from_bytes(s.as_bytes())
+
    }
+

+
    /// Decode from a string input.
+
    fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
+
        let mut r = io::BufReader::new(bytes);
        Self::decode(&mut r)
    }
}
modified radicle-cli/src/terminal.rs
@@ -4,6 +4,7 @@ pub mod format;
pub mod io;
pub use io::{proposal, signer};
pub mod comment;
+
pub mod highlight;
pub mod issue;
pub mod patch;

added radicle-cli/src/terminal/highlight.rs
@@ -0,0 +1,327 @@
+
use std::{collections::HashMap, path::Path};
+

+
use radicle_term as term;
+
use tree_sitter_highlight as ts;
+

+
/// Highlight groups enabled.
+
const HIGHLIGHTS: &[&str] = &[
+
    "attribute",
+
    "constant",
+
    "constant.builtin",
+
    "comment",
+
    "constructor",
+
    "function.builtin",
+
    "function",
+
    "integer_literal",
+
    "float.literal",
+
    "keyword",
+
    "label",
+
    "operator",
+
    "property",
+
    "punctuation",
+
    "punctuation.bracket",
+
    "punctuation.delimiter",
+
    "punctuation.special",
+
    "string",
+
    "string.special",
+
    "tag",
+
    "type",
+
    "type.builtin",
+
    "variable",
+
    "variable.builtin",
+
    "variable.parameter",
+
    "text.literal",
+
    "text.title",
+
];
+

+
/// Syntax highlighter based on `tree-sitter`.
+
#[derive(Default)]
+
pub struct Highlighter {
+
    configs: HashMap<&'static str, ts::HighlightConfiguration>,
+
}
+

+
/// Syntax theme.
+
pub struct Theme {
+
    color: fn(&'static str) -> Option<term::Color>,
+
}
+

+
impl Default for Theme {
+
    fn default() -> Self {
+
        let color = if term::Paint::truecolor() {
+
            term::colors::rgb::theme
+
        } else {
+
            term::colors::fixed::theme
+
        };
+
        Self { color }
+
    }
+
}
+

+
impl Theme {
+
    /// Get the named color.
+
    pub fn color(&self, color: &'static str) -> term::Color {
+
        if let Some(c) = (self.color)(color) {
+
            c
+
        } else {
+
            term::Color::Unset
+
        }
+
    }
+

+
    /// Return the color of a syntax group.
+
    pub fn highlight(&self, group: &'static str) -> Option<term::Color> {
+
        let color = match group {
+
            "keyword" => self.color("red"),
+
            "comment" => self.color("grey"),
+
            "constant" => self.color("orange"),
+
            "string" => self.color("teal"),
+
            "function" => self.color("purple"),
+
            "operator" => self.color("blue"),
+
            // Eg. `true` and `false` in rust.
+
            "constant.builtin" => self.color("blue"),
+
            "type.builtin" => self.color("cyan"),
+
            "punctuation.bracket" | "punctuation.delimiter" => term::Color::default(),
+
            // Eg. the '#' in Markdown titles.
+
            "punctuation.special" => self.color("dim"),
+
            // Eg. Markdown code blocks.
+
            "text.literal" => self.color("blue"),
+
            "text.title" => self.color("orange"),
+
            "variable.builtin" => term::Color::default(),
+
            "property" => self.color("blue"),
+
            // Eg. `#[derive(Debug)]` in rust
+
            "attribute" => self.color("blue"),
+
            "label" => self.color("green"),
+
            // `Option`
+
            "type" => self.color("grey.light"),
+
            "variable.parameter" => term::Color::default(),
+
            "constructor" => self.color("orange"),
+

+
            _ => return None,
+
        };
+
        Some(color)
+
    }
+
}
+

+
/// Syntax highlighted file builder.
+
#[derive(Default)]
+
struct Builder {
+
    /// Output lines.
+
    lines: Vec<term::Line>,
+
    /// Current output line.
+
    line: Vec<term::Label>,
+
    /// Current label.
+
    label: Vec<u8>,
+
    /// Current stack of styles.
+
    styles: Vec<term::Style>,
+
}
+

+
impl Builder {
+
    /// Run the builder to completion.
+
    fn run(
+
        mut self,
+
        highlights: impl Iterator<Item = Result<ts::HighlightEvent, ts::Error>>,
+
        code: &[u8],
+
        theme: &Theme,
+
    ) -> Result<Vec<term::Line>, ts::Error> {
+
        for event in highlights {
+
            match event? {
+
                ts::HighlightEvent::Source { start, end } => {
+
                    let range = &code[start..end];
+

+
                    for byte in range {
+
                        if *byte == b'\n' {
+
                            self.advance();
+
                            // Start on new line.
+
                            self.lines.push(term::Line::from(self.line.clone()));
+
                            self.line.clear();
+
                        } else {
+
                            // Add to existing label.
+
                            self.label.push(*byte);
+
                        }
+
                    }
+
                }
+
                ts::HighlightEvent::HighlightStart(h) => {
+
                    let name = HIGHLIGHTS[h.0];
+

+
                    self.advance();
+
                    self.styles.push(
+
                        term::Style::default()
+
                            .fg(theme.highlight(name).unwrap_or(term::Color::default())),
+
                    );
+
                }
+
                ts::HighlightEvent::HighlightEnd => {
+
                    self.advance();
+
                    self.styles.pop();
+
                }
+
            }
+
        }
+
        Ok(self.lines)
+
    }
+

+
    /// Advance the state by pushing the current label onto the current line,
+
    /// using the current styling.
+
    fn advance(&mut self) {
+
        if !self.label.is_empty() {
+
            // Take the top-level style when there are more than one.
+
            let style = self.styles.first().cloned().unwrap_or_default();
+
            self.line
+
                .push(term::Label::new(String::from_utf8_lossy(&self.label).as_ref()).style(style));
+
            self.label.clear();
+
        }
+
    }
+
}
+

+
impl Highlighter {
+
    /// Highlight a source code file. Returns `None` if the file type was not recognized.
+
    pub fn highlight(
+
        &mut self,
+
        path: &Path,
+
        code: &[u8],
+
    ) -> Result<Option<Vec<term::Line>>, ts::Error> {
+
        let theme = Theme::default();
+
        let mut highlighter = ts::Highlighter::new();
+
        let Some(config) = self.detect(path, code) else {
+
            return Ok(None);
+
        };
+
        config.configure(HIGHLIGHTS);
+

+
        let highlights = highlighter.highlight(config, code, None, |_| {
+
            // Language injection callback.
+
            None
+
        })?;
+

+
        Builder::default().run(highlights, code, &theme).map(Some)
+
    }
+

+
    /// Detect language.
+
    fn detect(&mut self, path: &Path, _code: &[u8]) -> Option<&mut ts::HighlightConfiguration> {
+
        match path.extension().and_then(|e| e.to_str()) {
+
            Some("rs") => self.config("rust"),
+
            Some("ts" | "js" | "json") => self.config("typescript"),
+
            Some("sh" | "bash") => self.config("shell"),
+
            Some("md" | "markdown") => self.config("markdown"),
+
            Some("go") => self.config("go"),
+
            Some("c") => self.config("c"),
+
            Some("py") => self.config("python"),
+
            Some("rb") => self.config("ruby"),
+
            Some("tsx") => self.config("tsx"),
+
            Some("html") | Some("htm") | Some("xml") => self.config("html"),
+
            Some("css") => self.config("css"),
+
            Some("toml") => self.config("toml"),
+
            _ => None,
+
        }
+
    }
+

+
    /// Get a language configuration.
+
    fn config(&mut self, language: &'static str) -> Option<&mut ts::HighlightConfiguration> {
+
        match language {
+
            "rust" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_rust::language(),
+
                    tree_sitter_rust::HIGHLIGHT_QUERY,
+
                    tree_sitter_rust::INJECTIONS_QUERY,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "typescript" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_typescript::language_typescript(),
+
                    tree_sitter_typescript::HIGHLIGHT_QUERY,
+
                    "",
+
                    tree_sitter_typescript::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "markdown" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_md::language(),
+
                    tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
+
                    tree_sitter_md::INJECTION_QUERY_BLOCK,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "css" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_css::language(),
+
                    tree_sitter_css::HIGHLIGHTS_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "go" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_go::language(),
+
                    tree_sitter_go::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "shell" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_bash::language(),
+
                    tree_sitter_bash::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "c" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_c::language(),
+
                    tree_sitter_c::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "python" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_python::language(),
+
                    tree_sitter_python::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "ruby" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_ruby::language(),
+
                    tree_sitter_ruby::HIGHLIGHT_QUERY,
+
                    "",
+
                    tree_sitter_ruby::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "tsx" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_typescript::language_tsx(),
+
                    tree_sitter_typescript::HIGHLIGHT_QUERY,
+
                    "",
+
                    tree_sitter_typescript::LOCALS_QUERY,
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "html" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_html::language(),
+
                    tree_sitter_html::HIGHLIGHT_QUERY,
+
                    tree_sitter_html::INJECTION_QUERY,
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            "toml" => Some(self.configs.entry(language).or_insert_with(|| {
+
                ts::HighlightConfiguration::new(
+
                    tree_sitter_toml::language(),
+
                    tree_sitter_toml::HIGHLIGHT_QUERY,
+
                    "",
+
                    "",
+
                )
+
                .expect("Highlighter::config: highlight configuration must be valid")
+
            })),
+
            _ => None,
+
        }
+
    }
+
}