mirror of
https://github.com/Hmbown/DeepSeek-TUI.git
synced 2026-09-03 06:50:13 +08:00
Merge pull request #5813 from Hmbown/feat/diff-intraline-20260901
feat(tui): diff cards emphasise the changed words within a line
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -550,6 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -5144,6 +5145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -70,7 +70,7 @@ rusqlite.workspace = true
|
||||
rmcp = { version = "2.2.0", default-features = false, features = ["auth", "client"] }
|
||||
rustls.workspace = true
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
similar = "3"
|
||||
similar = { version = "3", features = ["unicode"] }
|
||||
ansi-to-tui = { version = "8.0.1", default-features = false }
|
||||
syntect = { version = "5.2", default-features = false, features = ["default-fancy"] }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::palette;
|
||||
|
||||
const LINE_NUMBER_WIDTH: usize = 4;
|
||||
|
||||
/// Below this word-level similarity a replaced line pair is rewritten, not
|
||||
/// edited, and emphasising the changed words would light up the whole row.
|
||||
const INTRALINE_MIN_RATIO: f32 = 0.5;
|
||||
|
||||
/// Pairing stops here so a huge hunk cannot buffer word segments for every
|
||||
/// replaced line; longer runs render line by line.
|
||||
const INTRALINE_MAX_RUN: usize = 64;
|
||||
|
||||
/// A run of text inside a changed line and whether it is part of the change.
|
||||
type Segment = (String, bool);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiffFileSummary {
|
||||
pub path: String,
|
||||
@@ -49,15 +61,19 @@ pub fn render_diff_bounded(diff: &str, width: u16, max_body_rows: usize) -> Boun
|
||||
/// this form so the bounded preview budget is spent on the actual red/green
|
||||
/// evidence instead of a second, generic summary.
|
||||
/// Render at most `max_rows` body rows while counting every omitted wrapped
|
||||
/// row. Allocation is bounded by the retained preview plus one source line's
|
||||
/// wrapped representation, rather than by the size of the complete diff.
|
||||
/// row. Allocation is bounded by the retained preview, one source line's
|
||||
/// wrapped representation, and the current `-`/`+` run: its line slices
|
||||
/// (borrowed, pointer-sized) plus word segments for at most
|
||||
/// `INTRALINE_MAX_RUN` replaced pairs — never by the size of the complete
|
||||
/// diff.
|
||||
#[must_use]
|
||||
pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> BoundedDiffRender {
|
||||
let mut collector = BoundedLineCollector::new(max_rows);
|
||||
let mut old_line: Option<usize> = None;
|
||||
let mut new_line: Option<usize> = None;
|
||||
|
||||
for raw in diff.lines() {
|
||||
let mut lines = diff.lines().peekable();
|
||||
'line: while let Some(raw) = lines.next() {
|
||||
if raw.starts_with("diff --git") || raw.starts_with("index ") {
|
||||
collector.extend(render_header_line(raw, width));
|
||||
continue;
|
||||
@@ -77,7 +93,12 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
|
||||
continue;
|
||||
}
|
||||
|
||||
if raw.starts_with('+') && !raw.starts_with("+++") {
|
||||
if is_no_newline_marker(raw) {
|
||||
collector.extend(render_header_line(raw, width));
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_added(raw) {
|
||||
let content = raw.trim_start_matches('+');
|
||||
collector.extend(render_diff_line(
|
||||
content,
|
||||
@@ -85,9 +106,8 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
|
||||
old_line,
|
||||
new_line,
|
||||
'+',
|
||||
Style::default()
|
||||
.fg(palette::DIFF_ADDED)
|
||||
.bg(palette::DIFF_ADDED_BG),
|
||||
added_style(),
|
||||
None,
|
||||
));
|
||||
if let Some(line) = new_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
@@ -95,20 +115,137 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
|
||||
continue;
|
||||
}
|
||||
|
||||
if raw.starts_with('-') && !raw.starts_with("---") {
|
||||
let content = raw.trim_start_matches('-');
|
||||
collector.extend(render_diff_line(
|
||||
content,
|
||||
width,
|
||||
old_line,
|
||||
new_line,
|
||||
'-',
|
||||
Style::default()
|
||||
.fg(palette::STATUS_ERROR)
|
||||
.bg(palette::DIFF_DELETED_BG),
|
||||
));
|
||||
if let Some(line) = old_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
if is_deleted(raw) {
|
||||
// A deleted run followed by an added run of the same length is a
|
||||
// set of replaced lines: emphasise the words that changed within
|
||||
// each pair. Any other shape renders line by line as before.
|
||||
let mut removed = vec![raw.trim_start_matches('-')];
|
||||
let mut added: Vec<&str> = Vec::new();
|
||||
let mut removed_markers: Vec<(usize, &str)> = Vec::new();
|
||||
let mut added_markers: Vec<(usize, &str)> = Vec::new();
|
||||
loop {
|
||||
if let Some(marker) = lines.next_if(|next| is_no_newline_marker(next)) {
|
||||
if added.is_empty() {
|
||||
removed_markers.push((removed.len() - 1, marker));
|
||||
} else {
|
||||
added_markers.push((added.len() - 1, marker));
|
||||
}
|
||||
} else if added.is_empty()
|
||||
&& let Some(next) = lines.next_if(|next| is_deleted(next))
|
||||
{
|
||||
if removed.len() < INTRALINE_MAX_RUN {
|
||||
removed.push(next.trim_start_matches('-'));
|
||||
} else {
|
||||
flush_plain_run(
|
||||
&mut collector,
|
||||
&removed,
|
||||
&removed_markers,
|
||||
'-',
|
||||
width,
|
||||
&mut old_line,
|
||||
&mut new_line,
|
||||
);
|
||||
render_plain_diff_line(
|
||||
&mut collector,
|
||||
next.trim_start_matches('-'),
|
||||
width,
|
||||
&mut old_line,
|
||||
&mut new_line,
|
||||
'-',
|
||||
);
|
||||
continue 'line;
|
||||
}
|
||||
} else if let Some(next) = lines.next_if(|next| is_added(next)) {
|
||||
if added.len() < INTRALINE_MAX_RUN {
|
||||
added.push(next.trim_start_matches('+'));
|
||||
} else {
|
||||
flush_plain_run(
|
||||
&mut collector,
|
||||
&removed,
|
||||
&removed_markers,
|
||||
'-',
|
||||
width,
|
||||
&mut old_line,
|
||||
&mut new_line,
|
||||
);
|
||||
flush_plain_run(
|
||||
&mut collector,
|
||||
&added,
|
||||
&added_markers,
|
||||
'+',
|
||||
width,
|
||||
&mut old_line,
|
||||
&mut new_line,
|
||||
);
|
||||
render_plain_diff_line(
|
||||
&mut collector,
|
||||
next.trim_start_matches('+'),
|
||||
width,
|
||||
&mut old_line,
|
||||
&mut new_line,
|
||||
'+',
|
||||
);
|
||||
continue 'line;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let pairs: Vec<Option<(Vec<Segment>, Vec<Segment>)>> =
|
||||
if removed.len() == added.len() && removed.len() <= INTRALINE_MAX_RUN {
|
||||
removed
|
||||
.iter()
|
||||
.zip(&added)
|
||||
.map(|(old, new)| intraline_segments(old, new))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for (idx, content) in removed.iter().enumerate() {
|
||||
let emphasis = pairs
|
||||
.get(idx)
|
||||
.and_then(|pair| pair.as_ref().map(|(old, _)| old.as_slice()));
|
||||
collector.extend(render_diff_line(
|
||||
content,
|
||||
width,
|
||||
old_line,
|
||||
new_line,
|
||||
'-',
|
||||
deleted_style(),
|
||||
emphasis,
|
||||
));
|
||||
if let Some(line) = old_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
}
|
||||
for &(marker_idx, marker) in &removed_markers {
|
||||
if marker_idx == idx {
|
||||
collector.extend(render_header_line(marker, width));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (idx, content) in added.iter().enumerate() {
|
||||
let emphasis = pairs
|
||||
.get(idx)
|
||||
.and_then(|pair| pair.as_ref().map(|(_, new)| new.as_slice()));
|
||||
collector.extend(render_diff_line(
|
||||
content,
|
||||
width,
|
||||
old_line,
|
||||
new_line,
|
||||
'+',
|
||||
added_style(),
|
||||
emphasis,
|
||||
));
|
||||
if let Some(line) = new_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
}
|
||||
for &(marker_idx, marker) in &added_markers {
|
||||
if marker_idx == idx {
|
||||
collector.extend(render_header_line(marker, width));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -122,6 +259,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
|
||||
new_line,
|
||||
' ',
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
None,
|
||||
));
|
||||
if let Some(line) = old_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
@@ -338,6 +476,196 @@ fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
|
||||
wrap_with_style(line, style, width)
|
||||
}
|
||||
|
||||
fn is_added(raw: &str) -> bool {
|
||||
raw.starts_with('+') && !raw.starts_with("+++")
|
||||
}
|
||||
|
||||
fn is_deleted(raw: &str) -> bool {
|
||||
raw.starts_with('-') && !raw.starts_with("---")
|
||||
}
|
||||
|
||||
fn is_no_newline_marker(raw: &str) -> bool {
|
||||
raw.starts_with("\\ No newline")
|
||||
}
|
||||
|
||||
fn added_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::DIFF_ADDED)
|
||||
.bg(palette::DIFF_ADDED_BG)
|
||||
}
|
||||
|
||||
fn deleted_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::STATUS_ERROR)
|
||||
.bg(palette::DIFF_DELETED_BG)
|
||||
}
|
||||
|
||||
/// Split a replaced line pair into word runs (unicode word boundaries, so
|
||||
/// punctuation stays out of the emphasis), flagging the runs that differ.
|
||||
///
|
||||
/// Returns `None` when the pair shares too few *words* to read as an edit —
|
||||
/// whitespace and punctuation tokens do not count, so two lines that agree
|
||||
/// only on syntax are painted whole, the way they always were.
|
||||
fn intraline_segments(old: &str, new: &str) -> Option<(Vec<Segment>, Vec<Segment>)> {
|
||||
let diff = TextDiff::from_unicode_words(old, new);
|
||||
let mut old_segments: Vec<Segment> = Vec::new();
|
||||
let mut new_segments: Vec<Segment> = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut shared_words = 0usize;
|
||||
let mut total_words = 0usize;
|
||||
for change in diff.iter_all_changes() {
|
||||
let text = change.value();
|
||||
let is_word = text.chars().any(char::is_alphanumeric);
|
||||
match change.tag() {
|
||||
ChangeTag::Equal => {
|
||||
if is_word {
|
||||
shared_words += 2;
|
||||
total_words += 2;
|
||||
}
|
||||
push_segment(&mut old_segments, text, false);
|
||||
push_segment(&mut new_segments, text, false);
|
||||
}
|
||||
ChangeTag::Delete => {
|
||||
changed = true;
|
||||
total_words += usize::from(is_word);
|
||||
push_segment(&mut old_segments, text, true);
|
||||
}
|
||||
ChangeTag::Insert => {
|
||||
changed = true;
|
||||
total_words += usize::from(is_word);
|
||||
push_segment(&mut new_segments, text, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed || total_words == 0 {
|
||||
return None;
|
||||
}
|
||||
let ratio = shared_words as f32 / total_words as f32;
|
||||
(ratio >= INTRALINE_MIN_RATIO).then_some((old_segments, new_segments))
|
||||
}
|
||||
|
||||
fn push_segment(segments: &mut Vec<Segment>, text: &str, emphasised: bool) {
|
||||
match segments.last_mut() {
|
||||
Some((run, flag)) if *flag == emphasised => run.push_str(text),
|
||||
_ => segments.push((text.to_string(), emphasised)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the wrapped chunks of a changed line, mapping every non-whitespace
|
||||
/// character back to its source flag by position. `wrap_text` only drops,
|
||||
/// collapses, or re-inserts whitespace (the indent lead comes back on every
|
||||
/// continuation chunk), so the non-whitespace sequence is the invariant.
|
||||
/// Whitespace joins an emphasised run only when both its neighbours are in
|
||||
/// it. Returns `None` if the invariant ever fails, and the caller paints the
|
||||
/// whole line plainly rather than emphasising the wrong word.
|
||||
fn emphasised_chunks(
|
||||
chunks: &[String],
|
||||
style: Style,
|
||||
segments: &[Segment],
|
||||
) -> Option<Vec<Vec<Span<'static>>>> {
|
||||
let source: Vec<(char, bool)> = segments
|
||||
.iter()
|
||||
.flat_map(|(run, flag)| {
|
||||
run.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.map(move |ch| (ch, *flag))
|
||||
})
|
||||
.collect();
|
||||
let emphasis = style.add_modifier(Modifier::BOLD | Modifier::REVERSED);
|
||||
let mut cursor = 0usize;
|
||||
let mut out = Vec::with_capacity(chunks.len());
|
||||
for chunk in chunks {
|
||||
let mut spans = Vec::new();
|
||||
let mut run = String::new();
|
||||
let mut run_flag = false;
|
||||
let mut prev_flag = false;
|
||||
for ch in chunk.chars() {
|
||||
let flag = if ch.is_whitespace() {
|
||||
prev_flag && source.get(cursor).is_some_and(|(_, next)| *next)
|
||||
} else {
|
||||
let (expected, flag) = *source.get(cursor)?;
|
||||
debug_assert_eq!(
|
||||
expected, ch,
|
||||
"wrapped chunk diverged from its source line at {cursor}"
|
||||
);
|
||||
if expected != ch {
|
||||
return None;
|
||||
}
|
||||
cursor += 1;
|
||||
prev_flag = flag;
|
||||
flag
|
||||
};
|
||||
if flag != run_flag && !run.is_empty() {
|
||||
let painted = if run_flag { emphasis } else { style };
|
||||
spans.push(Span::styled(std::mem::take(&mut run), painted));
|
||||
}
|
||||
run_flag = flag;
|
||||
run.push(ch);
|
||||
}
|
||||
if !run.is_empty() {
|
||||
let painted = if run_flag { emphasis } else { style };
|
||||
spans.push(Span::styled(run, painted));
|
||||
}
|
||||
out.push(spans);
|
||||
}
|
||||
debug_assert_eq!(
|
||||
cursor,
|
||||
source.len(),
|
||||
"wrapped chunks did not consume the whole source line"
|
||||
);
|
||||
(cursor == source.len()).then_some(out)
|
||||
}
|
||||
|
||||
fn render_plain_diff_line(
|
||||
collector: &mut BoundedLineCollector,
|
||||
content: &str,
|
||||
width: u16,
|
||||
old_line: &mut Option<usize>,
|
||||
new_line: &mut Option<usize>,
|
||||
marker: char,
|
||||
) {
|
||||
let style = match marker {
|
||||
'-' => deleted_style(),
|
||||
'+' => added_style(),
|
||||
_ => Style::default().fg(palette::TEXT_PRIMARY),
|
||||
};
|
||||
collector.extend(render_diff_line(
|
||||
content, width, *old_line, *new_line, marker, style, None,
|
||||
));
|
||||
match marker {
|
||||
'-' => {
|
||||
if let Some(line) = old_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
}
|
||||
}
|
||||
'+' => {
|
||||
if let Some(line) = new_line.as_mut() {
|
||||
*line = line.saturating_add(1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_plain_run(
|
||||
collector: &mut BoundedLineCollector,
|
||||
lines: &[&str],
|
||||
markers: &[(usize, &str)],
|
||||
sign: char,
|
||||
width: u16,
|
||||
old_line: &mut Option<usize>,
|
||||
new_line: &mut Option<usize>,
|
||||
) {
|
||||
for (idx, content) in lines.iter().enumerate() {
|
||||
render_plain_diff_line(collector, content, width, old_line, new_line, sign);
|
||||
for &(marker_idx, marker) in markers {
|
||||
if marker_idx == idx {
|
||||
collector.extend(render_header_line(marker, width));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(
|
||||
content: &str,
|
||||
width: u16,
|
||||
@@ -345,25 +673,27 @@ fn render_diff_line(
|
||||
new_line: Option<usize>,
|
||||
marker: char,
|
||||
style: Style,
|
||||
emphasis: Option<&[Segment]>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let prefix = format_line_numbers(old_line, new_line, marker);
|
||||
let prefix_width = prefix.width();
|
||||
let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
|
||||
let wrapped = wrap_text(content, available);
|
||||
let mut painted = emphasis.and_then(|segments| emphasised_chunks(&wrapped, style, segments));
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (idx, chunk) in wrapped.into_iter().enumerate() {
|
||||
if idx == 0 {
|
||||
out.push(Line::from(vec![
|
||||
Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(chunk, style),
|
||||
]));
|
||||
let gutter = if idx == 0 {
|
||||
Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED))
|
||||
} else {
|
||||
out.push(Line::from(vec![
|
||||
Span::raw(" ".repeat(prefix_width)),
|
||||
Span::styled(chunk, style),
|
||||
]));
|
||||
Span::raw(" ".repeat(prefix_width))
|
||||
};
|
||||
let mut spans = vec![gutter];
|
||||
match painted.as_mut() {
|
||||
Some(rows) => spans.append(&mut rows[idx]),
|
||||
None => spans.push(Span::styled(chunk, style)),
|
||||
}
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
@@ -491,7 +821,223 @@ mod tests {
|
||||
}
|
||||
|
||||
fn diff_content_text(line: &Line<'static>) -> Option<String> {
|
||||
line.spans.get(1).map(|span| span.content.to_string())
|
||||
line.spans
|
||||
.get(1..)
|
||||
.filter(|rest| !rest.is_empty())
|
||||
.map(|rest| rest.iter().map(|span| span.content.as_ref()).collect())
|
||||
}
|
||||
|
||||
fn emphasised_text(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.filter(|span| span.style.add_modifier.contains(Modifier::REVERSED))
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rendered_body(diff: &str, width: u16) -> Vec<Line<'static>> {
|
||||
render_diff_body_bounded(diff, width, usize::MAX).lines
|
||||
}
|
||||
|
||||
/// Text between the gutter and the first emphasised span, so a test can
|
||||
/// pin where on the row the emphasis starts.
|
||||
fn text_before_emphasis(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take_while(|span| !span.style.add_modifier.contains(Modifier::REVERSED))
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emphasis_per_row(diff: &str, width: u16) -> Vec<String> {
|
||||
rendered_body(diff, width)
|
||||
.iter()
|
||||
.skip(1) // hunk header
|
||||
.map(emphasised_text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_line_pair_emphasises_only_the_changed_words() {
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
- let total = price * quantity;
|
||||
+ let total = price * count;
|
||||
";
|
||||
let rendered = rendered_body(diff, 80);
|
||||
assert_eq!(emphasis_per_row(diff, 80), vec!["quantity", "count"]);
|
||||
let content = rendered
|
||||
.iter()
|
||||
.filter_map(diff_content_text)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
content,
|
||||
vec![
|
||||
" let total = price * quantity;".to_string(),
|
||||
" let total = price * count;".to_string()
|
||||
],
|
||||
"emphasis must not alter the line text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pair_in_a_replaced_run_is_emphasised_in_order() {
|
||||
let diff = "\
|
||||
@@ -1,2 +1,2 @@
|
||||
-let alpha = 1;
|
||||
-let beta = 2;
|
||||
+let alpha = 10;
|
||||
+let beta = 20;
|
||||
";
|
||||
assert_eq!(emphasis_per_row(diff, 80), vec!["1", "2", "10", "20"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewrite_inside_a_replaced_run_stays_plain_while_its_neighbour_is_emphasised() {
|
||||
let diff = "\
|
||||
@@ -1,2 +1,2 @@
|
||||
-let x = 1;
|
||||
-fn old_name() {}
|
||||
+let x = 2;
|
||||
+return None;
|
||||
";
|
||||
assert_eq!(emphasis_per_row(diff, 80), vec!["1", "", "2", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_sharing_only_syntax_are_not_emphasised() {
|
||||
// Whitespace, `=` and `;` agree; every word differs but `let`.
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
-let alpha = beta;
|
||||
+let gamma = delta;
|
||||
";
|
||||
assert_eq!(emphasis_per_row(diff, 80), vec!["", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_runs_render_without_emphasis() {
|
||||
let unequal = "\
|
||||
@@ -1,2 +1,1 @@
|
||||
-let a = 1;
|
||||
-let b = 2;
|
||||
+let a = 1; let b = 2;
|
||||
";
|
||||
assert_eq!(emphasis_per_row(unequal, 80), vec!["", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_follows_unicode_word_boundaries() {
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
-café au lait, naïve
|
||||
+café au thé, naïve
|
||||
";
|
||||
assert_eq!(emphasis_per_row(diff, 80), vec!["lait", "thé"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_newline_marker_does_not_break_pairing() {
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
-old line
|
||||
\\ No newline at end of file
|
||||
+new line
|
||||
\\ No newline at end of file
|
||||
";
|
||||
let rendered = rendered_body(diff, 80);
|
||||
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
|
||||
assert_eq!(rows, vec!["", "old", "", "new", ""]);
|
||||
let text: Vec<String> = rendered.iter().map(line_text).collect();
|
||||
assert_eq!(
|
||||
text.iter()
|
||||
.filter(|row| row.contains("No newline at end of file"))
|
||||
.count(),
|
||||
2,
|
||||
"markers are still shown: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_insertion_no_newline_marker_is_a_header_row() {
|
||||
let diff = "\
|
||||
@@ -1,0 +1,1 @@
|
||||
+inserted
|
||||
\\ No newline at end of file
|
||||
context
|
||||
";
|
||||
let rendered = rendered_body(diff, 80);
|
||||
let marker = rendered
|
||||
.iter()
|
||||
.find(|line| line_text(line).contains("No newline at end of file"))
|
||||
.expect("marker row");
|
||||
assert!(marker.spans[0].style.add_modifier.contains(Modifier::BOLD));
|
||||
|
||||
let context = rendered
|
||||
.iter()
|
||||
.find(|line| line_text(line).contains("context"))
|
||||
.expect("context row");
|
||||
assert!(
|
||||
line_text(context).starts_with(" 1 2 "),
|
||||
"context numbering was changed by the marker: {context:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_lands_on_the_right_chunk_of_a_wrapped_indented_line() {
|
||||
// Gutter is 12 columns; width 40 leaves 28 for text. With the 8-space
|
||||
// indent re-inserted on every continuation chunk, the line wraps as
|
||||
// " let total =" / " compute(alpha, beta," /
|
||||
// " gamma, quantity);" — the change sits on the third chunk.
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
- let total = compute(alpha, beta, gamma, quantity);
|
||||
+ let total = compute(alpha, beta, gamma, count);
|
||||
";
|
||||
let rendered = rendered_body(diff, 40);
|
||||
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
|
||||
assert_eq!(rows, vec!["", "", "", "quantity", "", "", "count"]);
|
||||
assert_eq!(
|
||||
diff_content_text(&rendered[3]).as_deref(),
|
||||
Some(" gamma, quantity);")
|
||||
);
|
||||
assert_eq!(text_before_emphasis(&rendered[3]), " gamma, ");
|
||||
assert_eq!(text_before_emphasis(&rendered[6]), " gamma, ");
|
||||
// Row text is untouched by the emphasis.
|
||||
assert_eq!(
|
||||
diff_content_text(&rendered[6]).as_deref(),
|
||||
Some(" gamma, count);")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_survives_wrapping_without_changing_text() {
|
||||
let diff = "\
|
||||
@@ -1,1 +1,1 @@
|
||||
-alpha beta gamma delta epsilon zeta eta theta iota kappa
|
||||
+alpha beta gamma delta epsilon zeta eta THETA iota kappa
|
||||
";
|
||||
let rendered = rendered_body(diff, 30);
|
||||
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
|
||||
// 18 text columns: "alpha beta gamma" / "delta epsilon zeta" /
|
||||
// "eta theta iota" / "kappa" — the change sits on the third chunk.
|
||||
assert_eq!(rows, vec!["", "", "", "theta", "", "", "", "THETA", ""]);
|
||||
assert_eq!(text_before_emphasis(&rendered[3]), "eta ");
|
||||
assert_eq!(text_before_emphasis(&rendered[7]), "eta ");
|
||||
// Wrapping drops the space at each break; everything else survives.
|
||||
let body: String = rendered
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(diff_content_text)
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
body,
|
||||
"alphabetagammadeltaepsilonzetaetathetaiotakappa\
|
||||
alphabetagammadeltaepsilonzetaetaTHETAiotakappa"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user