use crate::Res;
use crate::error::Error;
use crate::git::diff::Diff;
use crate::style::{Color, Modifier, Style};
use anstyle_parse::{DefaultCharAccumulator, Params, Parser, Perform};
use regex::Regex;
use std::collections::HashMap;
use std::io::Write;
use std::mem;
use std::ops::Range;
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread;
const OSC1717_ADVERTISED: &str = "V1";
const FEATURES_VAR: &str = "DELTA_FEATURES";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum LineKind {
Context,
Added,
Deleted,
HunkHeader,
FileHeader,
Commit,
}
impl LineKind {
pub(crate) fn is_content(self) -> bool {
matches!(
self,
LineKind::Context | LineKind::Added | LineKind::Deleted
)
}
}
#[derive(Debug, PartialEq)]
pub(crate) struct CommitBlock<'a> {
pub commit: Option<&'a str>,
pub rows: &'a [ParsedLine],
}
pub(crate) fn commit_blocks(lines: &[ParsedLine]) -> Vec<CommitBlock<'_>> {
let mut starts: Vec<(usize, Option<&str>)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if let Some(record) = line
.records
.iter()
.find(|record| record.kind == LineKind::Commit)
{
let commit = record.file.as_str();
let already_open = starts
.last()
.and_then(|&(_, open)| open)
.is_some_and(|open| names_same_commit(open, commit));
if !already_open {
starts.push((i, Some(commit)));
}
} else if starts.is_empty() {
starts.push((0, None));
}
}
starts
.iter()
.enumerate()
.map(|(n, &(start, commit))| CommitBlock {
commit,
rows: &lines[start..starts.get(n + 1).map_or(lines.len(), |&(next, _)| next)],
})
.collect()
}
fn names_same_commit(one: &str, other: &str) -> bool {
!one.is_empty() && (one.starts_with(other) || other.starts_with(one))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LineMetadata {
pub kind: LineKind,
pub new_line: u32,
pub old_line: Option<u32>,
pub file: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Hyperlink {
pub range: Range<usize>,
pub uri: String,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub(crate) struct ParsedLine {
pub text: String,
pub runs: Vec<(Range<usize>, Style)>,
pub hyperlinks: Vec<Hyperlink>,
pub records: Vec<LineMetadata>,
}
#[derive(Debug, Default)]
pub(crate) struct ParsedOutput {
pub lines: Vec<ParsedLine>,
pub protocol_version: Option<u32>,
}
pub(crate) struct DiffIndex {
files: HashMap<String, usize>,
lines: HashMap<(usize, LineKind, u32), (usize, usize)>,
hunks: HashMap<(usize, u32), usize>,
}
impl DiffIndex {
pub(crate) fn of(diff: &Diff) -> Self {
let mut index = Self {
files: HashMap::new(),
lines: HashMap::new(),
hunks: HashMap::new(),
};
for (file_index, file_diff) in diff.file_diffs.iter().enumerate() {
for path in [&file_diff.header.new_file, &file_diff.header.old_file] {
index
.files
.entry(path.fmt(&diff.text).to_string())
.or_insert(file_index);
}
for (hunk_index, hunk) in file_diff.hunks.iter().enumerate() {
index
.hunks
.entry((file_index, hunk.header.new_line_start))
.or_insert(hunk_index);
let content = &diff.text[hunk.content.range.clone()];
let mut new_num = hunk.header.new_line_start;
let mut old_num = hunk.header.old_line_start;
for (line_index, line) in content.split_inclusive('\n').enumerate() {
let key = match line.chars().next() {
Some('+') => Some((LineKind::Added, new_num)),
Some('-') => Some((LineKind::Deleted, old_num)),
Some(' ') => Some((LineKind::Context, new_num)),
_ => None,
};
if let Some((kind, num)) = key {
index
.lines
.entry((file_index, kind, num))
.or_insert((hunk_index, line_index));
}
match line.chars().next() {
Some('+') => new_num += 1,
Some('-') => old_num += 1,
Some('\\') => {} _ => {
new_num += 1;
old_num += 1;
}
}
}
}
}
index
}
pub(crate) fn line(&self, meta: &LineMetadata) -> Option<(usize, usize, usize)> {
let file = *self.files.get(&meta.file)?;
let num = match meta.kind {
LineKind::Deleted => meta.old_line?,
LineKind::Added | LineKind::Context => meta.new_line,
_ => return None,
};
let &(hunk, line) = self.lines.get(&(file, meta.kind, num))?;
Some((file, hunk, line))
}
pub(crate) fn hunk(&self, meta: &LineMetadata) -> Option<(usize, usize)> {
let file = *self.files.get(&meta.file)?;
let &hunk = self.hunks.get(&(file, meta.new_line))?;
Some((file, hunk))
}
}
pub(crate) fn resolve_file(diff: &Diff, meta: &LineMetadata, from: usize) -> Option<usize> {
(from..diff.file_diffs.len()).find(|&file_index| {
let header = &diff.file_diffs[file_index].header;
meta.file == header.new_file.fmt(&diff.text) || meta.file == header.old_file.fmt(&diff.text)
})
}
pub(crate) fn renderer_command(
command: &[String],
params: &crate::items::RenderParams,
dir: Option<&Path>,
) -> Option<Command> {
let width = params.width();
let (program, args) = command.split_first()?;
let args: Vec<String> = args
.iter()
.map(|arg| arg.replace("{width}", &width.to_string()))
.collect();
let mut command = Command::new(program);
command
.args(&args)
.env("OSC1717", OSC1717_ADVERTISED)
.env("COLUMNS", width.to_string());
if let Some(features) = feature_overlay(¶ms.features) {
command.env(FEATURES_VAR, features);
}
if let Some(dir) = dir {
command.current_dir(dir);
}
Some(command)
}
pub(crate) fn run(
command: &[String],
input: Option<&str>,
params: &crate::items::RenderParams,
dir: Option<&Path>,
) -> Option<String> {
let program = command.first()?;
let mut command = renderer_command(command, params, dir)?;
command
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command
.spawn()
.inspect_err(|e| log::warn!("renderer '{program}' failed to spawn: {e}"))
.ok()?;
let writer = input.map(|input| {
let mut stdin = child.stdin.take().expect("stdin was piped");
let input = input.to_owned();
thread::spawn(move || stdin.write_all(input.as_bytes()))
});
let output = child.wait_with_output().ok()?;
if let Some(writer) = writer {
let _ = writer.join();
}
if !output.status.success() {
log::warn!("renderer '{program}' exited with {}", output.status);
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn feature_overlay(features: &[String]) -> Option<String> {
if features.is_empty() {
return None;
}
Some(format!("+{}", features.join(" ")))
}
pub(crate) fn offered_features(
configured: &[String],
git_config: &git2::Config,
) -> Res<Vec<String>> {
let defined = defined_features(git_config)?;
let mut offered = Vec::new();
for entry in configured {
let names = match glob(entry) {
Some(pattern) => defined
.iter()
.filter(|name| pattern.is_match(name))
.cloned()
.collect(),
None => vec![entry.clone()],
};
for name in names {
if !offered.contains(&name) {
offered.push(name);
}
}
}
Ok(offered)
}
fn defined_features(git_config: &git2::Config) -> Res<Vec<String>> {
let mut defined = Vec::new();
let mut entries = git_config.entries(None).map_err(Error::ReadGitConfig)?;
while let Some(entry) = entries.next() {
let entry = entry.map_err(Error::ReadGitConfig)?;
if let Some(name) = entry.name().and_then(feature_name) {
defined.push(name);
}
}
defined.sort();
defined.dedup();
Ok(defined)
}
fn glob(entry: &str) -> Option<Regex> {
if !entry.contains(['*', '?']) {
return None;
}
let mut pattern = String::from("^");
for c in entry.chars() {
match c {
'*' => pattern.push_str(".*"),
'?' => pattern.push('.'),
c => pattern.push_str(®ex::escape(c.encode_utf8(&mut [0; 4]))),
}
}
pattern.push('$');
Some(Regex::new(&pattern).expect("an escaped glob is a valid regex"))
}
fn feature_name(entry: &str) -> Option<String> {
let subsection_and_key = entry.strip_prefix("delta.")?;
let (name, _key) = subsection_and_key.rsplit_once('.')?;
Some(name.to_owned())
}
pub(crate) fn strip_ansi(text: &str) -> String {
parse_ansi_lines(text)
.lines
.iter()
.flat_map(|line| [line.text.as_str(), "\n"])
.collect()
}
pub(crate) fn parse_ansi_lines(output: &str) -> ParsedOutput {
let mut performer = Performer::default();
let mut parser = Parser::<DefaultCharAccumulator>::new();
for &byte in output.as_bytes() {
parser.advance(&mut performer, byte);
}
performer.finish()
}
#[derive(Default)]
struct Performer {
lines: Vec<ParsedLine>,
style: Style,
text: String,
runs: Vec<(Range<usize>, Style)>,
open: Option<(usize, Style)>,
hyperlink: Option<(usize, String)>,
hyperlinks: Vec<Hyperlink>,
records: Vec<LineMetadata>,
handshake_line: bool,
protocol_version: Option<u32>,
}
impl Performer {
fn end_line(&mut self) {
self.finish_hyperlink();
if let Some((start, style)) = self.open.take() {
self.runs.push((start..self.text.len(), style));
}
let handshake_only =
mem::take(&mut self.handshake_line) && self.text.is_empty() && self.records.is_empty();
let line = ParsedLine {
text: mem::take(&mut self.text),
runs: mem::take(&mut self.runs),
hyperlinks: mem::take(&mut self.hyperlinks),
records: mem::take(&mut self.records),
};
if let Some((start, _)) = &mut self.hyperlink {
*start = 0;
}
if !handshake_only {
self.lines.push(line);
}
}
fn finish(mut self) -> ParsedOutput {
if !self.text.is_empty() || self.open.is_some() || !self.records.is_empty() {
self.end_line();
}
ParsedOutput {
lines: self.lines,
protocol_version: self.protocol_version,
}
}
fn set_hyperlink(&mut self, parts: &[&[u8]]) {
self.finish_hyperlink();
if let Some((start, style)) = self.open.take()
&& start < self.text.len()
{
self.runs.push((start..self.text.len(), style));
}
let uri = parts
.iter()
.map(|part| String::from_utf8_lossy(part))
.collect::<Vec<_>>()
.join(";");
self.hyperlink = (!uri.is_empty()).then_some((self.text.len(), uri));
}
fn finish_hyperlink(&mut self) {
if let Some((start, uri)) = &self.hyperlink
&& *start < self.text.len()
{
self.hyperlinks.push(Hyperlink {
range: *start..self.text.len(),
uri: uri.clone(),
});
}
}
}
impl Perform for Performer {
fn print(&mut self, c: char) {
match self.open {
Some((_, style)) if style == self.style => {}
Some((start, style)) => {
self.runs.push((start..self.text.len(), style));
self.open = Some((self.text.len(), self.style));
}
None => self.open = Some((self.text.len(), self.style)),
}
self.text.push(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' => self.end_line(),
b'\t' => self.print('\t'),
_ => {}
}
}
fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, action: u8) {
if action == b'm' {
let codes: Vec<u16> = params
.iter()
.flat_map(|group| group.iter().copied())
.collect();
apply_sgr(&mut self.style, &codes);
}
}
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
if let [b"8", _, uri @ ..] = params {
self.set_hyperlink(uri);
return;
}
if params.first() != Some(&b"1717".as_slice()) {
return;
}
match params {
[_, version] => {
self.protocol_version = parse_u32(version);
self.handshake_line = true;
}
[_, version, kind, new_line, old_line, file_head @ ..] if !file_head.is_empty() => {
self.protocol_version = self.protocol_version.or_else(|| parse_u32(version));
if let Some(kind) = line_kind(kind) {
self.records.push(LineMetadata {
kind,
new_line: parse_u32(new_line).unwrap_or(0),
old_line: parse_u32(old_line),
file: file_head
.iter()
.map(|part| String::from_utf8_lossy(part))
.collect::<Vec<_>>()
.join(";"),
});
}
}
_ => {}
}
}
}
fn parse_u32(bytes: &[u8]) -> Option<u32> {
std::str::from_utf8(bytes).ok()?.parse().ok()
}
fn line_kind(bytes: &[u8]) -> Option<LineKind> {
match bytes {
b"c" => Some(LineKind::Context),
b"a" => Some(LineKind::Added),
b"d" => Some(LineKind::Deleted),
b"h" => Some(LineKind::HunkHeader),
b"f" => Some(LineKind::FileHeader),
b"C" => Some(LineKind::Commit),
_ => None, }
}
fn apply_sgr(style: &mut Style, codes: &[u16]) {
if codes.is_empty() {
*style = Style::new();
return;
}
let mut i = 0;
while i < codes.len() {
match codes[i] {
0 => *style = Style::new(),
1 => style.add_modifier |= Modifier::BOLD,
2 => style.add_modifier |= Modifier::DIM,
3 => style.add_modifier |= Modifier::ITALIC,
4 => style.add_modifier |= Modifier::UNDERLINED,
7 => style.add_modifier |= Modifier::REVERSED,
9 => style.add_modifier |= Modifier::CROSSED_OUT,
22 => style.add_modifier.remove(Modifier::BOLD | Modifier::DIM),
23 => style.add_modifier.remove(Modifier::ITALIC),
24 => style.add_modifier.remove(Modifier::UNDERLINED),
27 => style.add_modifier.remove(Modifier::REVERSED),
29 => style.add_modifier.remove(Modifier::CROSSED_OUT),
c @ 30..=37 => style.fg = Some(Color::Indexed((c - 30) as u8)),
38 => match parse_extended_color(&codes[i + 1..]) {
Some((color, advance)) => {
style.fg = Some(color);
i += advance;
}
None => break,
},
39 => style.fg = None,
c @ 40..=47 => style.bg = Some(Color::Indexed((c - 40) as u8)),
48 => match parse_extended_color(&codes[i + 1..]) {
Some((color, advance)) => {
style.bg = Some(color);
i += advance;
}
None => break,
},
49 => style.bg = None,
c @ 90..=97 => style.fg = Some(Color::Indexed((c - 90 + 8) as u8)),
c @ 100..=107 => style.bg = Some(Color::Indexed((c - 100 + 8) as u8)),
_ => {}
}
i += 1;
}
}
fn parse_extended_color(rest: &[u16]) -> Option<(Color, usize)> {
match rest.first()? {
2 => Some((
Color::Rgb(
*rest.get(1)? as u8,
*rest.get(2)? as u8,
*rest.get(3)? as u8,
),
4,
)),
5 => Some((Color::Indexed(*rest.get(1)? as u8), 2)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::items::RenderParams;
use crate::style::Color;
fn echo_features() -> Vec<String> {
["sh", "-c", "printf '%s' \"$DELTA_FEATURES\""]
.map(String::from)
.to_vec()
}
fn params_with(features: &[&str]) -> RenderParams {
RenderParams {
features: features.iter().copied().map(String::from).collect(),
..Default::default()
}
}
#[test]
fn selected_features_reach_the_renderer_as_an_overlay() {
assert_eq!(
run(
&echo_features(),
None,
¶ms_with(&["side-by-side"]),
None
),
Some("+side-by-side".to_string())
);
assert_eq!(
run(&echo_features(), None, ¶ms_with(&["a", "b"]), None),
Some("+a b".to_string())
);
}
#[test]
fn selecting_no_features_leaves_the_variable_unset() {
assert_eq!(
run(&echo_features(), None, ¶ms_with(&[]), None),
Some(String::new())
);
}
fn git_config_of(text: &str) -> (temp_dir::TempDir, git2::Config) {
let dir = temp_dir::TempDir::new().unwrap();
let path = dir.path().join("config");
std::fs::write(&path, text).unwrap();
let config = git2::Config::open(&path).unwrap();
(dir, config)
}
fn features_config() -> (temp_dir::TempDir, git2::Config) {
git_config_of(
"[delta]\n\
\tnavigate = true\n\
[delta \"my-theme\"]\n\
\tline-numbers = true\n\
\tsyntax-theme = Nord\n\
[delta \"boxed\"]\n\
\thunk-header-style = box\n",
)
}
fn offered(configured: &[&str], git_config: &git2::Config) -> Vec<String> {
let configured: Vec<String> = configured.iter().copied().map(String::from).collect();
offered_features(&configured, git_config).unwrap()
}
#[test]
fn features_defined_in_git_config_are_offered_only_when_named() {
let (_dir, git_config) = features_config();
assert_eq!(offered(&["side-by-side"], &git_config), ["side-by-side"]);
}
#[test]
fn a_pattern_offers_the_features_in_git_config_it_matches() {
let (_dir, git_config) = features_config();
assert_eq!(
offered(&["side-by-side", "*"], &git_config),
["side-by-side", "boxed", "my-theme"]
);
assert_eq!(offered(&["my-*"], &git_config), ["my-theme"]);
assert_eq!(offered(&["?oxed"], &git_config), ["boxed"]);
}
#[test]
fn a_feature_named_twice_is_offered_once() {
let (_dir, git_config) = git_config_of("[delta \"side-by-side\"]\n\twidth = 100\n");
assert_eq!(
offered(&["side-by-side", "line-numbers", "*"], &git_config),
["side-by-side", "line-numbers"]
);
}
#[test]
fn parses_lines_tiling_text_with_colors() {
let output = "\x1b[38;2;255;0;0mfn\x1b[0m main\nplain line\n";
let lines = parse_ansi_lines(output).lines;
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "fn main");
assert_eq!(lines[1].text, "plain line");
for line in &lines {
let mut next = 0;
for (range, _) in &line.runs {
assert_eq!(range.start, next, "runs must be contiguous with no gaps");
next = range.end;
}
assert_eq!(next, line.text.len(), "runs must cover the whole line");
}
let fg = lines[0]
.runs
.iter()
.find(|(r, _)| lines[0].text[r.clone()] == *"fn")
.map(|(_, s)| s.fg);
assert_eq!(fg, Some(Some(Color::Rgb(255, 0, 0))));
}
#[test]
fn preserves_tabs_and_markers() {
let lines = parse_ansi_lines("\x1b[32m+\tlet x = 1;\x1b[0m\n").lines;
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "+\tlet x = 1;");
}
#[test]
fn parses_osc8_hyperlinks() {
let line = &parse_ansi_lines(
"plain \x1b]8;;file:///tmp/a.rs:12\x1b\\linked\x1b]8;;\x1b\\ plain\n",
)
.lines[0];
assert_eq!(line.text, "plain linked plain");
assert_eq!(
line.hyperlinks,
[Hyperlink {
range: 6..12,
uri: "file:///tmp/a.rs:12".into(),
}]
);
}
#[test]
fn captures_osc1717_handshake_and_drops_its_line() {
let out = parse_ansi_lines("\x1b]1717;1\x1b\\\ncontent\n");
assert_eq!(out.protocol_version, Some(1));
assert_eq!(out.lines.len(), 1);
assert_eq!(out.lines[0].text, "content");
assert_eq!(out.lines[0].records, vec![]);
}
#[test]
fn attaches_per_line_records_to_following_line() {
let out = parse_ansi_lines(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;c;8;;src/foo.rs\x1b\\fn a() {}\n\
\x1b]1717;1;d;10;11;src/foo.rs\x1b\\ let old = 1;\n\
\x1b]1717;1;a;10;;src/foo.rs\x1b\\ let new = 1;\n",
)
.lines;
assert_eq!(out.len(), 3);
assert_eq!(out[0].text, "fn a() {}");
assert_eq!(
out[0].records,
vec![LineMetadata {
kind: LineKind::Context,
new_line: 8,
old_line: None,
file: "src/foo.rs".into(),
}]
);
assert_eq!(
out[1].records,
vec![LineMetadata {
kind: LineKind::Deleted,
new_line: 10,
old_line: Some(11),
file: "src/foo.rs".into(),
}]
);
assert_eq!(out[2].records[0].kind, LineKind::Added);
}
#[test]
fn side_by_side_row_keeps_both_records_in_order() {
let out = parse_ansi_lines(
"\x1b]1717;1;d;10;10;f.rs\x1b\\ old \x1b]1717;1;a;10;;f.rs\x1b\\ new \n",
)
.lines;
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, " old new ");
assert_eq!(out[0].records.len(), 2);
assert_eq!(out[0].records[0].kind, LineKind::Deleted);
assert_eq!(out[0].records[0].old_line, Some(10));
assert_eq!(out[0].records[1].kind, LineKind::Added);
}
#[test]
fn file_field_may_contain_semicolons() {
let out = parse_ansi_lines("\x1b]1717;1;a;1;;weird;name.txt\x1b\\x\n").lines;
assert_eq!(out[0].records[0].file, "weird;name.txt");
}
fn commit_record(oid: &str) -> String {
format!("\x1b]1717;1;C;;;{oid}\x1b\\")
}
#[test]
fn keeps_a_handshake_line_that_also_carries_a_record() {
let out = parse_ansi_lines(&format!(
"\x1b]1717;1\x1b\\{}\n──\n▸ abc1234 summary\n",
commit_record("abc1234def")
));
assert_eq!(out.protocol_version, Some(1));
assert_eq!(commit_blocks(&out.lines)[0].commit, Some("abc1234def"));
}
#[test]
fn parses_commit_records() {
let out = parse_ansi_lines(&format!(
"{}â–¸ abc1234 summary\n",
commit_record("abc1234def")
));
assert_eq!(
out.lines[0].records,
vec![LineMetadata {
kind: LineKind::Commit,
new_line: 0,
old_line: None,
file: "abc1234def".into(),
}]
);
}
#[test]
fn commit_blocks_start_at_each_commit_record() {
let out = parse_ansi_lines(&format!(
"preamble\n\
{}\nâ–¸ aaa summary\n body\n\
{}\nâ–¸ bbb summary\n",
commit_record("aaa"),
commit_record("bbb"),
));
let blocks = commit_blocks(&out.lines);
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].commit, None);
assert_eq!(rows(&blocks[0]), vec!["preamble"]);
assert_eq!(blocks[1].commit, Some("aaa"));
assert_eq!(rows(&blocks[1]), vec!["", "â–¸ aaa summary", " body"]);
assert_eq!(blocks[2].commit, Some("bbb"));
assert_eq!(rows(&blocks[2]), vec!["", "â–¸ bbb summary"]);
}
#[test]
fn a_commit_named_again_by_the_renderer_is_the_same_commit() {
let out = parse_ansi_lines(&format!(
"{}\n{}â–¸ aaa123 summary\n body\n{}\nâ–¸ bbb456 summary\n",
commit_record("aaa123def"),
commit_record("aaa123"),
commit_record("bbb456789"),
));
let blocks = commit_blocks(&out.lines);
assert_eq!(blocks.len(), 2, "{blocks:?}");
assert_eq!(blocks[0].commit, Some("aaa123def"));
assert_eq!(rows(&blocks[0]), vec!["", "â–¸ aaa123 summary", " body"]);
assert_eq!(blocks[1].commit, Some("bbb456789"));
}
#[test]
fn commit_blocks_of_unmarked_output_is_a_single_headless_block() {
let out = parse_ansi_lines("no markers here\nat all\n");
let blocks = commit_blocks(&out.lines);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].commit, None);
assert_eq!(rows(&blocks[0]), vec!["no markers here", "at all"]);
}
fn rows<'a>(block: &'a CommitBlock) -> Vec<&'a str> {
block.rows.iter().map(|row| row.text.as_str()).collect()
}
fn diff_from(text: &str) -> crate::git::diff::Diff {
crate::git::diff::Diff {
text: text.to_string(),
diff_type: crate::git::diff::DiffType::WorkdirToIndex,
file_diffs: crate::gitu_diff::Parser::new(text).parse_diff().unwrap(),
commit: None,
}
}
#[test]
fn the_index_places_a_content_line_by_its_identity() {
let text = "diff --git a/src/foo.rs b/src/foo.rs\n\
index 1a2b3c4..5d6e7f8 100644\n\
--- a/src/foo.rs\n\
+++ b/src/foo.rs\n\
@@ -8,4 +8,4 @@ use std::io;\n\
\x20fn a() {}\n\
- let old = 1;\n\
- let old2 = 2;\n\
+ let new = 1;\n\
\x20ctx();\n";
let index = DiffIndex::of(&diff_from(text));
let m = |kind, new_line, old_line| LineMetadata {
kind,
new_line,
old_line,
file: "src/foo.rs".into(),
};
assert_eq!(index.line(&m(LineKind::Context, 8, None)), Some((0, 0, 0)));
assert_eq!(
index.line(&m(LineKind::Deleted, 9, Some(9))),
Some((0, 0, 1))
);
assert_eq!(
index.line(&m(LineKind::Deleted, 9, Some(10))),
Some((0, 0, 2))
);
assert_eq!(index.line(&m(LineKind::Added, 9, None)), Some((0, 0, 3)));
assert_eq!(index.line(&m(LineKind::Context, 10, None)), Some((0, 0, 4)));
assert_eq!(index.line(&m(LineKind::Added, 999, None)), None);
}
#[test]
fn parses_hunk_and_file_header_records() {
let out = parse_ansi_lines(
"\x1b]1717;1;f;;;src/foo.rs\x1b\\file header\n\
\x1b]1717;1;h;8;;src/foo.rs\x1b\\hunk header\n",
)
.lines;
assert_eq!(out[0].records[0].kind, LineKind::FileHeader);
assert_eq!(out[0].records[0].old_line, None);
assert_eq!(out[1].records[0].kind, LineKind::HunkHeader);
assert_eq!(out[1].records[0].new_line, 8);
}
#[test]
fn the_index_places_a_hunk_by_its_first_new_line() {
let text = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -8,2 +8,2 @@\n\
-a\n\
+b\n\
@@ -40,2 +40,2 @@\n\
-c\n\
+d\n";
let index = DiffIndex::of(&diff_from(text));
let h = |new_line| LineMetadata {
kind: LineKind::HunkHeader,
new_line,
old_line: None,
file: "f.rs".into(),
};
assert_eq!(index.hunk(&h(8)), Some((0, 0)));
assert_eq!(index.hunk(&h(40)), Some((0, 1)));
assert_eq!(index.hunk(&h(999)), None);
}
#[test]
fn format_lines_patch_stages_a_fused_change_both_sides() {
let text = "diff --git a/src/foo.rs b/src/foo.rs\n\
index 1a2b3c4..5d6e7f8 100644\n\
--- a/src/foo.rs\n\
+++ b/src/foo.rs\n\
@@ -8,4 +8,3 @@ use std::io;\n\
\x20fn a() {}\n\
- let old = 1;\n\
- let old2 = 2;\n\
+ let new = 1;\n\
\x20ctx();\n";
let diff = diff_from(text);
let patch = diff.format_lines_patch(0, 0, &[1, 3], crate::git::diff::PatchMode::Normal);
assert!(patch.contains("- let old = 1;"), "keeps the deletion");
assert!(patch.contains("+ let new = 1;"), "keeps the replacement");
assert!(
patch.contains(" let old2 = 2;"),
"other deletion -> context"
);
assert!(!patch.contains("- let old2 = 2;"));
}
}