use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
HunkHeader,
Context,
Added,
Removed,
Meta,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HighlightSpan {
pub text: String,
pub foreground: Option<(u8, u8, u8)>,
pub bold: bool,
pub italic: bool,
}
impl HighlightSpan {
fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
foreground: None,
bold: false,
italic: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub old_line: Option<usize>,
pub new_line: Option<usize>,
pub spans: Vec<HighlightSpan>,
}
impl DiffLine {
#[cfg(test)]
pub fn text(&self) -> String {
self.spans.iter().map(|span| span.text.as_str()).collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffDocument {
pub title: String,
pub lines: Vec<DiffLine>,
pub truncated: bool,
}
impl DiffDocument {
pub fn empty(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
lines: vec![DiffLine {
kind: DiffLineKind::Meta,
old_line: None,
new_line: None,
spans: vec![HighlightSpan::plain(message)],
}],
truncated: false,
}
}
}
struct HighlightAssets {
syntaxes: SyntaxSet,
theme: Theme,
}
static HIGHLIGHT_ASSETS: OnceLock<HighlightAssets> = OnceLock::new();
fn highlight_assets() -> &'static HighlightAssets {
HIGHLIGHT_ASSETS.get_or_init(|| {
let syntaxes = two_face::syntax::extra_newlines();
let themes = ThemeSet::load_defaults();
let theme = themes
.themes
.get("base16-ocean.dark")
.or_else(|| themes.themes.values().next())
.cloned()
.unwrap_or_default();
HighlightAssets { syntaxes, theme }
})
}
pub fn parse_diff(
raw: &[u8],
title: impl Into<String>,
path_hint: Option<&Path>,
truncated: bool,
) -> DiffDocument {
let title = title.into();
if raw.is_empty() {
return DiffDocument::empty(title, "No textual diff to display");
}
let assets = highlight_assets();
let mut active_path = path_hint.map(Path::to_path_buf);
let mut syntax = syntax_for_path(&assets.syntaxes, active_path.as_deref());
let mut old_highlighter = HighlightLines::new(syntax, &assets.theme);
let mut new_highlighter = HighlightLines::new(syntax, &assets.theme);
let mut old_line = None;
let mut new_line = None;
let mut lines = Vec::new();
for raw_line in String::from_utf8_lossy(raw).lines() {
if let Some(path) = raw_line.strip_prefix("+++ b/") {
active_path = Some(PathBuf::from(path));
syntax = syntax_for_path(&assets.syntaxes, active_path.as_deref());
old_highlighter = HighlightLines::new(syntax, &assets.theme);
new_highlighter = HighlightLines::new(syntax, &assets.theme);
continue;
}
if raw_line.starts_with("diff --git ")
|| raw_line.starts_with("index ")
|| raw_line.starts_with("--- ")
|| raw_line.starts_with("new file mode ")
|| raw_line.starts_with("deleted file mode ")
|| raw_line.starts_with("similarity index ")
|| raw_line.starts_with("rename from ")
|| raw_line.starts_with("rename to ")
{
continue;
}
if raw_line.starts_with("@@") {
let (old_start, new_start) = parse_hunk_starts(raw_line);
old_line = old_start;
new_line = new_start;
lines.push(meta_line(DiffLineKind::HunkHeader, raw_line));
continue;
}
if let Some(content) = raw_line.strip_prefix('+') {
let number = new_line;
new_line = new_line.map(|line| line + 1);
lines.push(DiffLine {
kind: DiffLineKind::Added,
old_line: None,
new_line: number,
spans: highlight(&mut new_highlighter, content, &assets.syntaxes),
});
} else if let Some(content) = raw_line.strip_prefix('-') {
let number = old_line;
old_line = old_line.map(|line| line + 1);
lines.push(DiffLine {
kind: DiffLineKind::Removed,
old_line: number,
new_line: None,
spans: highlight(&mut old_highlighter, content, &assets.syntaxes),
});
} else if let Some(content) = raw_line.strip_prefix(' ') {
let old_number = old_line;
let new_number = new_line;
old_line = old_line.map(|line| line + 1);
new_line = new_line.map(|line| line + 1);
let spans = highlight(&mut new_highlighter, content, &assets.syntaxes);
let _ = old_highlighter.highlight_line(content, &assets.syntaxes);
lines.push(DiffLine {
kind: DiffLineKind::Context,
old_line: old_number,
new_line: new_number,
spans,
});
} else {
lines.push(meta_line(DiffLineKind::Meta, raw_line));
}
}
if truncated {
lines.push(meta_line(
DiffLineKind::Meta,
"… diff truncated to keep Quinjet responsive …",
));
}
DiffDocument {
title,
lines,
truncated,
}
}
fn syntax_for_path<'a>(syntaxes: &'a SyntaxSet, path: Option<&Path>) -> &'a SyntaxReference {
path.and_then(|path| syntaxes.find_syntax_for_file(path).ok().flatten())
.unwrap_or_else(|| syntaxes.find_syntax_plain_text())
}
fn highlight(
highlighter: &mut HighlightLines<'_>,
line: &str,
syntaxes: &SyntaxSet,
) -> Vec<HighlightSpan> {
match highlighter.highlight_line(line, syntaxes) {
Ok(ranges) => ranges
.into_iter()
.map(|(style, text)| HighlightSpan {
text: text.to_owned(),
foreground: Some((style.foreground.r, style.foreground.g, style.foreground.b)),
bold: style.font_style.contains(FontStyle::BOLD),
italic: style.font_style.contains(FontStyle::ITALIC),
})
.collect(),
Err(_) => vec![HighlightSpan::plain(line)],
}
}
fn meta_line(kind: DiffLineKind, text: &str) -> DiffLine {
DiffLine {
kind,
old_line: None,
new_line: None,
spans: vec![HighlightSpan::plain(text)],
}
}
fn parse_hunk_starts(line: &str) -> (Option<usize>, Option<usize>) {
let mut fields = line.split_ascii_whitespace();
let _marker = fields.next();
let old = fields
.next()
.and_then(|field| parse_range_start(field, '-'));
let new = fields
.next()
.and_then(|field| parse_range_start(field, '+'));
(old, new)
}
fn parse_range_start(field: &str, prefix: char) -> Option<usize> {
field.strip_prefix(prefix)?.split(',').next()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hunks_and_tracks_line_numbers() {
let raw = b"diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,3 +10,4 @@ fn main() {\n let value = 1;\n-old();\n+new();\n+more();\n end();\n";
let document = parse_diff(raw, "main.rs", Some(Path::new("src/main.rs")), false);
let content: Vec<_> = document
.lines
.iter()
.filter(|line| {
matches!(
line.kind,
DiffLineKind::Context | DiffLineKind::Added | DiffLineKind::Removed
)
})
.collect();
assert_eq!(content.len(), 5);
assert_eq!(
(content[0].old_line, content[0].new_line),
(Some(10), Some(10))
);
assert_eq!((content[1].old_line, content[1].new_line), (Some(11), None));
assert_eq!((content[2].old_line, content[2].new_line), (None, Some(11)));
assert_eq!((content[3].old_line, content[3].new_line), (None, Some(12)));
assert_eq!(
(content[4].old_line, content[4].new_line),
(Some(12), Some(13))
);
}
#[test]
fn returns_explanatory_line_for_empty_diff() {
let document = parse_diff(b"", "empty", None, false);
assert_eq!(document.lines[0].text(), "No textual diff to display");
}
#[test]
fn highlights_typescript_and_hides_git_transport_headers() {
let raw = b"diff --git a/widget.tsx b/widget.tsx\nindex aaaaaaa..bbbbbbb 100644\n--- a/widget.tsx\n+++ b/widget.tsx\n@@ -1 +1 @@\n-const oldValue: number = 1;\n+const newValue: number = 2;\n";
let document = parse_diff(raw, "widget.tsx", Some(Path::new("widget.tsx")), false);
assert_eq!(document.lines.len(), 3);
assert_eq!(document.lines[0].kind, DiffLineKind::HunkHeader);
assert!(document.lines[2].spans.len() > 1);
assert!(
document.lines[2]
.spans
.iter()
.filter_map(|span| span.foreground)
.collect::<std::collections::HashSet<_>>()
.len()
> 1
);
}
}