diff --git a/Cargo.lock b/Cargo.lock index 5e0ac38..8eaf99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "anstream" version = "0.5.0" @@ -68,6 +74,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base64" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -144,6 +165,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.27.0" @@ -170,6 +200,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "deranged" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" + [[package]] name = "dirs" version = "5.0.1" @@ -203,6 +239,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" +[[package]] +name = "flate2" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -234,10 +286,12 @@ dependencies = [ "dirs", "git2", "itertools", + "line-span", "nom", "paste", "serde", "serde_ignored", + "syntect", "test-case", "toml", "vte", @@ -256,6 +310,12 @@ dependencies = [ "url", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.0" @@ -279,6 +339,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.0.0" @@ -286,7 +356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.0", ] [[package]] @@ -298,6 +368,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + [[package]] name = "jobserver" version = "0.1.24" @@ -337,6 +413,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-span" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a" + +[[package]] +name = "line-wrap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" +dependencies = [ + "safemem", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "lock_api" version = "0.4.7" @@ -374,6 +471,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + [[package]] name = "mio" version = "0.8.4" @@ -396,6 +502,34 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "onig" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" +dependencies = [ + "bitflags 1.3.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -443,6 +577,20 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +[[package]] +name = "plist" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc0001cfea3db57a2e24bc0d818e9e20e554b5f97fabb9bc231dc240269ae06" +dependencies = [ + "base64", + "indexmap 1.9.3", + "line-wrap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -476,6 +624,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b9228215d82c7b61490fec1de287136b5de6f5700f6e58ea9ad61a7964ca51" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.29" @@ -505,6 +662,33 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -540,6 +724,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.3" @@ -612,6 +807,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02b4b303bf8d08bfeb0445cba5068a3d306b6baece1d5582171a9bf49188f91" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + [[package]] name = "test-case" version = "3.2.1" @@ -667,6 +883,34 @@ dependencies = [ "syn 2.0.23", ] +[[package]] +name = "time" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a79d09ac6b08c1ab3906a2f7cc2e81a0e27c7ae89c63812df75e52bef0751e07" +dependencies = [ + "deranged", + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c65469ed6b3a4809d987a41eb1dc918e9bc1d92211cbad7ae82931846f7451" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -709,7 +953,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ff63e60a958cefbb518ae1fd6566af80d9d4be430a33f3723dfc47d1d411d95" dependencies = [ - "indexmap", + "indexmap 2.0.0", "serde", "serde_spanned", "toml_datetime", @@ -788,6 +1032,16 @@ dependencies = [ "quote", ] +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -810,6 +1064,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -933,3 +1196,12 @@ checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" dependencies = [ "memchr", ] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] diff --git a/Cargo.toml b/Cargo.toml index e29f84c..523584c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,12 @@ crossterm = { version = "0.27.0", features = [ "serde" ] } dirs = "5.0.1" git2 = { version = "0.18.0", default-features = false } itertools = "0.11.0" +line-span = "0.1.5" nom = "7.1.3" paste = "1.0.14" serde = { version = "1.0.168", features = [ "derive" ] } serde_ignored = "0.1.9" +syntect = "5.1.0" toml = "0.8.0" vte = "0.11.1" diff --git a/src/config.rs b/src/config.rs index 98d76c4..100bf25 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,6 +46,7 @@ pub struct Options { pub lookahead_lines: usize, pub truncate_lines: bool, pub ws_error_highlight: WsErrorHighlight, + pub syntax_highlighting: Option, } #[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq)] @@ -64,6 +65,8 @@ impl Default for Options { lookahead_lines: 5, truncate_lines: true, ws_error_highlight: WsErrorHighlight::default(), + // TODO: decide on default + syntax_highlighting: Some("base16-ocean.dark".to_owned()), } } } @@ -258,7 +261,8 @@ error = \"#cc241d\" old: false, new: true, context: false - } + }, + syntax_highlighting: None }, colors: Colors { foreground: Color::from((235, 219, 178)), diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 0000000..9030c67 --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,252 @@ +use anyhow::{anyhow, Result}; +use crossterm::style; +use syntect::{ + easy::HighlightLines, + highlighting::{Color, FontStyle, Style, Theme, ThemeSet}, + parsing::{SyntaxReference, SyntaxSet}, + util::as_24_bit_terminal_escaped, +}; + +use crate::hunk::{DiffLineType, Hunk}; + +// Diff styles +// TODO(cptp): make colors configurable + +const HEADER_MARKER_STYLE: Style = Style { + foreground: Color::WHITE, + background: Color::BLACK, + font_style: FontStyle::empty(), +}; + +const MARKER_ADDED: &str = "\u{258c}"; +const MARKER_ADDED_STYLE: Style = Style { + foreground: Color { + r: 0x78, + g: 0xde, + b: 0x0c, + a: 0xff, + }, + background: Color { + r: 0x0a, + g: 0x28, + b: 0x00, + a: 0xff, + }, + font_style: FontStyle::empty(), +}; + +const MARKER_REMOVED: &str = "\u{258c}"; +const MARKER_REMOVED_STYLE: Style = Style { + foreground: Color { + r: 0xd3, + g: 0x2e, + b: 0x09, + a: 0xff, + }, + background: Color { + r: 0x3f, + g: 0x0e, + b: 0x00, + a: 0xff, + }, + font_style: FontStyle::empty(), +}; + +// const BG_ADDED_STRONG: Color = Color { +// r: 0x11, +// g: 0x4f, +// b: 0x05, +// a: 0xff, +// }; +// const BG_REMOVED_STRONG: Color = Color { +// r: 0x77, +// g: 0x23, +// b: 0x05, +// a: 0xff, +// }; + +/// Highlighter to use for +#[derive(Debug)] +pub enum DiffHighlighter { + Simple { + color_added: crossterm::style::Color, + color_removed: crossterm::style::Color, + }, + Syntect { + syntax_set: SyntaxSet, + theme: Box, + }, +} + +// "base16-eighties.dark" +impl DiffHighlighter { + pub fn syntect(theme_name: &str) -> Result { + let theme_set = ThemeSet::load_defaults(); + Ok(Self::Syntect { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme: Box::new( + theme_set + .themes + .get(theme_name) + .ok_or_else(|| anyhow!("Theme '{theme_name}' not found."))? + .clone(), + ), + }) + } + + // FIXME: it's a bit odd that this is passed as an extra option. + // Maybe use a "specialized" enum instead that wraps it with the sytect options. + /// Get file type specific syntax for syntect highlighting. + /// + /// Returns None if the `DiffHighlighter::Simple` is used. + pub fn get_syntax(&self, path: &str) -> Option<&SyntaxReference> { + match self { + Self::Simple { .. } => None, + Self::Syntect { syntax_set, .. } => { + // TODO: probably better to use std::path? + let file_ext = path.rsplit('.').next().unwrap_or(""); + Some( + syntax_set + .find_syntax_by_extension(file_ext) + .unwrap_or_else(|| syntax_set.find_syntax_plain_text()), + ) + } + } + } +} + +pub fn highlight_hunk( + hunk: &Hunk, + hl: &DiffHighlighter, + syntax: Option<&SyntaxReference>, +) -> String { + match hl { + DiffHighlighter::Simple { + color_added, + color_removed, + } => highlight_hunk_simple(hunk, *color_added, *color_removed), + DiffHighlighter::Syntect { syntax_set, theme } => { + highlight_hunk_syntect(hunk, syntax_set, theme, syntax.unwrap()) + } + } +} + +fn highlight_hunk_simple( + hunk: &Hunk, + color_added: crossterm::style::Color, + color_removed: crossterm::style::Color, +) -> String { + let mut buf = String::new(); + let color_added = style::SetForegroundColor(color_added).to_string(); + let color_removed = style::SetForegroundColor(color_removed).to_string(); + + let (header_marker, header_content) = hunk.header(); + buf.push_str(header_marker); + buf.push_str(header_content); + buf.push('\n'); + + for (line_type, line_content) in hunk.lines() { + match line_type { + DiffLineType::Unchanged => { + buf.push(' '); + } + DiffLineType::Added => { + buf.push_str(&color_added); + buf.push('+'); + } + DiffLineType::Removed => { + buf.push_str(&color_removed); + buf.push('-'); + } + } + buf.push_str(line_content); + buf.push('\n'); + } + + // workaround: remove trailing line break + buf.pop(); + buf.push_str("\x1b[0m"); + buf +} + +fn highlight_hunk_syntect( + hunk: &Hunk, + syntax_set: &SyntaxSet, + theme: &Theme, + syntax: &SyntaxReference, +) -> String { + // TODO: move somewhere else? + let marker_added = as_24_bit_terminal_escaped(&[(MARKER_ADDED_STYLE, MARKER_ADDED)], true); + let marker_removed = + as_24_bit_terminal_escaped(&[(MARKER_REMOVED_STYLE, MARKER_REMOVED)], true); + + // separate highlighters for added and removed lines to keep the syntax intact + let mut hl_add = HighlightLines::new(syntax, theme); + let mut hl_rem = HighlightLines::new(syntax, theme); + + let mut buf = String::new(); + + let (header_marker, header_content) = { + let header = hunk.header(); + let header_content = hl_add + .highlight_line(header.1, syntax_set) + .and_then(|_| hl_rem.highlight_line(header.1, syntax_set)); + ( + as_24_bit_terminal_escaped(&[(HEADER_MARKER_STYLE, header.0)], false), + header_content.map_or_else( + |_| header.1.to_owned(), + |content| as_24_bit_terminal_escaped(&content, false), + ), + ) + }; + + buf.push_str(&header_marker); + buf.push_str(&header_content); + buf.push('\n'); + + for (line_type, line_content) in hunk.lines() { + let ranges = match line_type { + DiffLineType::Unchanged => hl_add + .highlight_line(line_content, syntax_set) + .and_then(|_| hl_rem.highlight_line(line_content, syntax_set)), + DiffLineType::Added => hl_add.highlight_line(line_content, syntax_set), + DiffLineType::Removed => hl_rem.highlight_line(line_content, syntax_set), + }; + + let Ok(mut ranges) = ranges else { + buf.push_str(line_content); + continue; + }; + + let bg = match line_type { + DiffLineType::Unchanged => { + buf.push(' '); + false + } + DiffLineType::Added => { + buf.push_str(&marker_added); + for r in &mut ranges { + r.0.background = MARKER_ADDED_STYLE.background; + } + true + } + DiffLineType::Removed => { + buf.push_str(&marker_removed); + for r in &mut ranges { + r.0.background = MARKER_REMOVED_STYLE.background; + } + true + } + }; + + let highlighted_content = as_24_bit_terminal_escaped(&ranges, bg); + buf.push_str(&highlighted_content); + buf.push('\n'); + } + // workaround: remove trailing line break + buf.pop(); + + // according to docs of `as_24_bit_terminal_escaped` + buf.push_str("\x1b[0m"); + buf +} diff --git a/src/hunk.rs b/src/hunk.rs new file mode 100644 index 0000000..83da35f --- /dev/null +++ b/src/hunk.rs @@ -0,0 +1,104 @@ +use std::ops::Range; + +use anyhow::{anyhow, bail, Result}; +use line_span::{LineSpan, LineSpans}; + +pub struct Hunk { + /// Raw hunk string. + /// + /// This may not be modified after the hunk was created, as the other fields index into it. + raw_hunk: String, + /// Header marker and content. + header: (Range, Range), + /// Ranges of `raw_hunk` for each line with the corresponding diff type. + /// + /// Doesn't include the starting character of the diff line (`+/-/ `) or the line break. + lines: Vec, +} + +impl Hunk { + pub fn from_string(raw_hunk: String) -> Result { + let mut lines_iter = raw_hunk.line_spans(); + let header = match lines_iter.next().map(Self::parse_header) { + Some(Ok(header)) => header, + Some(Err(e)) => return Err(e), + _ => bail!("Empty hunk."), + }; + let lines = lines_iter + .map(HunkLine::from_line_span) + .collect::, _>>()?; + Ok(Self { + raw_hunk, + header, + lines, + }) + } + + pub fn raw(&self) -> &str { + &self.raw_hunk + } + + pub fn header(&self) -> (&str, &str) { + ( + &self.raw_hunk[self.header.0.clone()], + &self.raw_hunk[self.header.1.clone()], + ) + } + + pub fn lines(&self) -> impl Iterator { + self.lines + .iter() + .map(|line| (line.diff_type, &self.raw_hunk[line.range.clone()])) + } + + fn parse_header(line: LineSpan) -> Result<(Range, Range)> { + // TODO(cptp) + let range = line.range(); + Ok((range.start..range.start, range)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffLineType { + Unchanged, + Added, + Removed, +} + +impl TryFrom for DiffLineType { + type Error = anyhow::Error; + + fn try_from(value: char) -> Result { + match value { + ' ' => Ok(Self::Unchanged), + '+' => Ok(Self::Added), + '-' => Ok(Self::Removed), + c => Err(anyhow!("'{c}' is not a valid diff type character.")), + } + } +} + +/// A single line (range) of a hunk. +struct HunkLine { + /// Type of the line. + diff_type: DiffLineType, + /// The range of the line in the original string (see [`Hunk`]). + /// + /// Doesn't include the first character of the original line (`+`/`-`/` `). + range: Range, +} + +impl HunkLine { + fn from_line_span(line: LineSpan) -> Result { + let Some(first_char) = line.chars().next() else { + bail!(""); + }; + let line_range = line.range(); + let diff_type = DiffLineType::try_from(first_char) + // fallback to unchanged + // TODO(cptp): this shouldn't happen, panic instead? + .unwrap_or(DiffLineType::Unchanged); + let range = (line_range.start + first_char.len_utf8())..line_range.end; + Ok(Self { diff_type, range }) + } +} diff --git a/src/main.rs b/src/main.rs index e6c5de8..54bc0ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ mod branch; mod command; mod config; mod debug; +mod highlight; +mod hunk; mod minibuffer; mod parse; mod render; @@ -118,7 +120,7 @@ fn run(clargs: &Clargs) -> Result<()> { }) }); - let status = Status::new(&repo, &config.options)?; + let status = Status::new(&repo, config)?; let branch_list = BranchList::new()?; let view = View::Status; let renderer = Renderer::default(); diff --git a/src/parse.rs b/src/parse.rs index 7c14c2a..266553a 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -4,9 +4,11 @@ use anyhow::{Context, Result}; use itertools::Itertools; use nom::{bytes::complete::tag, character::complete::not_line_ending, IResult}; +use crate::hunk::Hunk; + /// The returned hashmap associates a filename with a `Vec` of `String` where the strings contain /// the content of each hunk. -pub fn parse_diff(input: &str) -> Result>> { +pub fn parse_diff(input: &str) -> Result>> { let mut diffs = HashMap::new(); for diff in input .lines() @@ -14,7 +16,10 @@ pub fn parse_diff(input: &str) -> Result>> { .split(|l| l.starts_with("diff")) .skip(1) { - diffs.insert(get_path(diff)?, get_hunks(diff)?); + let path = get_path(diff)?; + // get language syntax here, since all hunks are from the same file + let hunks = get_hunks(diff)?; + diffs.insert(path, hunks); } Ok(diffs) } @@ -29,7 +34,7 @@ fn get_path<'a>(diff: &[&'a str]) -> Result<&'a str> { Ok(path) } -fn get_hunks(diff: &[&str]) -> Result> { +fn get_hunks(diff: &[&str]) -> Result> { let mut hunks = Vec::new(); let hunk_groups = diff.iter().group_by(|line| line.starts_with("@@")); let mut hunk_groups = hunk_groups.into_iter(); @@ -46,11 +51,11 @@ fn get_hunks(diff: &[&str]) -> Result> { let (_key, hunk_tail) = hunk_groups .next() .context("strange output from `git diff`")?; - hunks.push( + hunks.push(Hunk::from_string( std::iter::once(hunk_head) .chain(hunk_tail.copied()) .join("\n"), - ); + )?); } Ok(hunks) } diff --git a/src/status.rs b/src/status.rs index 90a1844..aa340df 100644 --- a/src/status.rs +++ b/src/status.rs @@ -15,6 +15,7 @@ use nom::{bytes::complete::take_until, IResult}; use crate::{ config::{Config, Options, CONFIG}, git_process, + highlight::{highlight_hunk, DiffHighlighter}, minibuffer::{MessageType, MiniBuffer}, parse::{self, parse_hunk_new, parse_hunk_old}, render::{self, Renderer, ResetAttributes, ResetColor}, @@ -34,74 +35,98 @@ enum DiffType { Deleted, } +// TODO(cptp): move hunk structs to `hunk.rs` + +/// A hunk displayed in the UI. #[derive(Debug, Clone)] -pub struct Hunk { +pub struct HunkView { + /// The raw diff of the hunk. diff: String, + /// The highlighted diff of the hunk. + diff_highlighted: String, + /// The hunk is currently expanded in the UI. expanded: bool, } -impl fmt::Display for Hunk { +impl HunkView { + pub const fn new(diff: String, diff_highlighted: String, expanded: bool) -> Self { + Self { + diff, + diff_highlighted, + expanded, + } + } + + pub const fn display(&self, highlighted: bool) -> HunkDisplay { + HunkDisplay(self, highlighted) + } +} + +impl Expand for HunkView { + fn toggle_expand(&mut self) { + self.expanded = !self.expanded; + } + + fn expanded(&self) -> bool { + self.expanded + } +} + +/// Helper struct for [`HunkView`] that implements Display. +/// +/// Allows switching between the highlighted and non highlighted version. +#[derive(Clone, Copy)] +pub struct HunkDisplay<'a>(&'a HunkView, bool); + +impl<'a> fmt::Display for HunkDisplay<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { use fmt::Write; let config = CONFIG.get().expect("config wasn't initialised"); + let hunk = self.0; + let highlight = self.1; + + let mut lines = (if highlight { + &hunk.diff_highlighted + } else { + &hunk.diff + }) + .lines(); - let mut lines = self.diff.lines(); let Some(head) = lines.next() else { return Ok(()); }; let mut outbuf = format!( "{}{}{}", style::SetForegroundColor(config.colors.hunk_head), - if self.expanded { "⌄" } else { "›" }, + if hunk.expanded { "⌄" } else { "›" }, head.replace(" @@", &format!(" @@{ResetAttributes}")) ); - if self.expanded { - let ws_error_highlight = CONFIG - .get() - .expect("config is initialised at the start of the program") - .options - .ws_error_highlight; + if hunk.expanded { for line in lines { - match line.chars().next() { - Some('+') => write!( - &mut outbuf, - "\r\n{}{}", - style::SetForegroundColor(config.colors.addition), - if ws_error_highlight.new { - format_trailing_whitespace(line, config) - } else { - Cow::Borrowed(line) - } - ), - Some('-') => write!( - &mut outbuf, - "\r\n{}{}", - style::SetForegroundColor(config.colors.deletion), - if ws_error_highlight.old { - format_trailing_whitespace(line, config) - } else { - Cow::Borrowed(line) - } - ), - Some(c) => write!( - &mut outbuf, - "\r\n{}{c}{}", - style::SetForegroundColor(config.colors.foreground), - if ws_error_highlight.context { - format_trailing_whitespace(&line[1..], config) - } else { - Cow::Borrowed(&line[1..]) - } - ), - // I think this case never happens, but if it does, it just means the line was - // empty. - None => { - outbuf.push('\n'); - Ok(()) - } - }?; + write!( + &mut outbuf, + "\r\n{}{line}", + style::SetForegroundColor(style::Color::DarkGrey) + )?; } + // TODO(cptp): highlight white spaces? + // let ws_error_highlight = CONFIG + // .get() + // .expect("config is initialised at the start of the program") + // .options + // .ws_error_highlight; + // for line in lines { + // write!( + // &mut outbuf, + // "\r\n{}", + // if ws_error_highlight.context { + // format_trailing_whitespace(&line[1..], config) + // } else { + // Cow::Borrowed(&line[1..]) + // } + // )? + // } } write!(f, "{outbuf}") } @@ -127,27 +152,11 @@ fn format_trailing_whitespace<'s>(s: &'s str, config: &'_ Config) -> Cow<'s, str } } -impl Hunk { - pub const fn new(diff: String, expanded: bool) -> Self { - Self { diff, expanded } - } -} - -impl Expand for Hunk { - fn toggle_expand(&mut self) { - self.expanded = !self.expanded; - } - - fn expanded(&self) -> bool { - self.expanded - } -} - #[derive(Debug)] pub struct FileDiff { path: String, expanded: bool, - hunks: Vec, + hunks: Vec, cursor: usize, kind: DiffType, // The implementation here involving this `selected` field is awful and hacky and I can't wait @@ -196,10 +205,15 @@ impl render::Render for FileDiff { for (i, hunk) in self.hunks.iter().enumerate() { if self.selected && i + 1 == self.cursor { f.insert_cursor(); - write!(f, "{ResetAttributes}\r\n{}{hunk}", Attribute::Reverse)?; + write!( + f, + "{ResetAttributes}\r\n{}{}", + Attribute::Reverse, + hunk.display(true) + )?; f.insert_item_end(); } else { - write!(f, "{ResetAttributes}\r\n{hunk}")?; + write!(f, "{ResetAttributes}\r\n{}", hunk.display(false))?; } } } @@ -271,7 +285,7 @@ enum Stage { Reset, } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Status { pub branch: String, pub head: String, @@ -280,6 +294,8 @@ pub struct Status { pub count_unstaged: usize, pub count_staged: usize, pub cursor: usize, + // TODO(cptp): this feels a bit out of place here, maybe move it to `Config` + highlight: DiffHighlighter, } impl render::Render for Status { @@ -365,9 +381,25 @@ impl render::Render for Status { } impl Status { - pub fn new(repo: &Repository, options: &Options) -> Result { - let mut status = Self::default(); - status.fetch(repo, options)?; + pub fn new(repo: &Repository, config: &Config) -> Result { + let highlight = match &config.options.syntax_highlighting { + Some(theme_name) => DiffHighlighter::syntect(theme_name)?, + None => DiffHighlighter::Simple { + color_added: config.colors.addition, + color_removed: config.colors.deletion, + }, + }; + let mut status = Self { + branch: String::default(), + head: String::default(), + file_diffs: Vec::default(), + count_untracked: 0, + count_unstaged: 0, + count_staged: 0, + cursor: 0, + highlight, + }; + status.fetch(repo, &config.options)?; Ok(status) } @@ -515,13 +547,25 @@ impl Status { // Get the diff information for unstaged changes let diff = git_process(&["diff", "--no-ext-diff"])?; - Self::populate_diffs(&mut unstaged, &self.file_diffs, &diff, options) - .context("failed to populate unstaged file diffs")?; + Self::populate_diffs( + &mut unstaged, + &self.file_diffs, + &diff, + options, + &self.highlight, + ) + .context("failed to populate unstaged file diffs")?; // Get the diff information for staged changes let diff = git_process(&["diff", "--cached", "--no-ext-diff"])?; - Self::populate_diffs(&mut staged, &self.file_diffs, &diff, options) - .context("failed to populate unstaged file diffs")?; + Self::populate_diffs( + &mut staged, + &self.file_diffs, + &diff, + options, + &self.highlight, + ) + .context("failed to populate staged file diffs")?; self.branch = branch; self.head = std::str::from_utf8( @@ -559,10 +603,14 @@ impl Status { prev_file_diffs: &[FileDiff], diff: &Output, options: &Options, + highlight: &DiffHighlighter, ) -> Result<()> { let diff = std::str::from_utf8(&diff.stdout).context("malformed stdout from `git diff`")?; let hunks = parse::parse_diff(diff)?; for file in file_diffs { + // Get the syntax info for the specific file type here, since the hunks below are + // all from the same file. + let syntax = highlight.get_syntax(&file.path); if let Some(hunks) = hunks.get(file.path.as_str()) { // Get all the diffs entries of this file from the previous iteration. let previous_file_entries = prev_file_diffs.iter().filter(|f| f.path == file.path); @@ -576,7 +624,8 @@ impl Status { let h_header = h.diff.lines().next().expect("hunk should never be empty"); let hunk_header = - hunk.lines().next().expect("hunk should never be empty"); + // TODO(cptp): this has to be hunk.header().0 once the parsing is correct + hunk.header().1; (parse_hunk_new(h_header).unwrap_or_else(|e| panic!("{e:?}")) == parse_hunk_new(hunk_header) .unwrap_or_else(|e| panic!("{e:?}"))) @@ -588,7 +637,8 @@ impl Status { }) .map_or(options.auto_expand_hunks, |h| h.expanded); - Hunk::new(hunk.clone(), expanded) + let highlighted = highlight_hunk(hunk, highlight, syntax); + HunkView::new(hunk.raw().to_owned(), highlighted, expanded) }) .collect(); }