Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 72 additions & 10 deletions src/preview/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,13 @@ impl PreviewMatch {
let get_line = |idx: usize| -> &str {
let start = line_starts[idx];
let end = line_starts.get(idx + 1).map_or(content.len(), |&s| s - 1);
content[start..end].trim_end_matches('\n')
content[start..end].trim_end_matches(['\r', '\n'])
};

let line_number = line_idx + 1;

let context_before: Box<[ContextLine]> = (line_idx.saturating_sub(CONTEXT_LINES)..line_idx)
.map(|i| ContextLine {
line_number: i + 1,
content: truncate_right(get_line(i), 0),
})
.map(|i| ContextLine::from_content(content, line_starts, i))
.collect();

let line_idx_end = if byte_end - byte_start > 1024 {
Expand All @@ -97,16 +94,16 @@ impl PreviewMatch {

let context_after: Box<[ContextLine]> = ((line_idx_end + 1)
..=(line_idx_end + CONTEXT_LINES).min(num_lines.saturating_sub(1)))
.map(|i| ContextLine {
line_number: i + 1,
content: truncate_right(get_line(i), 0),
})
.map(|i| ContextLine::from_content(content, line_starts, i))
.collect();

let line_start_byte = line_starts[line_idx];
let last_line_byte = line_starts[line_idx_end];
let first_line_str = get_line(line_idx);
let last_line_str = get_line(line_idx_end);
let mut match_col_start = byte_start - line_start_byte;
// clamp to the trimmed line length so a match landing on `\r` (e.g. from
// a multiline regex) doesn't produce an out-of-range slice index downstream
let mut match_col_start = (byte_start - line_start_byte).min(first_line_str.len());
let mut match_col_end = (byte_end - last_line_byte).min(last_line_str.len());

let kind = if line_idx_end == line_idx {
Expand Down Expand Up @@ -170,6 +167,23 @@ pub struct ContextLine {
pub content: Box<str>,
}

impl ContextLine {
fn new(line_idx: usize, line: &str) -> Self {
Self {
line_number: line_idx + 1,
content: truncate_right(line, 0),
}
}

fn from_content(content: &str, line_starts: &[usize], line_idx: usize) -> Self {
let start = line_starts[line_idx];
let end = line_starts
.get(line_idx + 1)
.map_or(content.len(), |&s| s - 1);
Self::new(line_idx, content[start..end].trim_end_matches(['\r', '\n']))
}
}

#[derive(Debug, Clone)]
pub enum PreviewMatchKind {
SingleLine {
Expand Down Expand Up @@ -282,6 +296,54 @@ mod tests {
assert_eq!(after.len(), MAX_CONTEXT_CHARS);
}

#[test]
fn build_preview_strips_crlf_line_endings() {
let content = "a\r\nb\r\nc\r\nmatch\r\nd\r\ne\r\n";
let pos = content.find("match").unwrap_or_else(|| unreachable!());
let data = PreviewData::new(content, &[(pos, pos + 5)]);
let m = &data.matches[0];

assert_eq!(&*m.context_before[0].content, "b");
assert_eq!(&*m.context_before[1].content, "c");
assert_eq!(&*m.context_after[0].content, "d");
assert_eq!(&*m.context_after[1].content, "e");

let PreviewMatchKind::SingleLine { line_content, .. } = &m.kind else {
panic!("expected SingleLine");
};
assert_eq!(&**line_content, "match");
assert_eq!(m.match_col_end, 5);
}

#[test]
fn build_preview_clamps_match_col_start_when_match_starts_on_line_terminator() {
// a match whose byte_start lands on `\n` produces a column past the
// \r-stripped line length; without clamping, downstream slicing of
// matched_lines[0] would panic
let content = "foo bar\r\nbaz\r\n";
let lf_byte = 8;
let baz_end = 12;
let data = PreviewData::new(content, &[(lf_byte, baz_end)]);
let m = &data.matches[0];
let PreviewMatchKind::MultiLine { matched_lines, .. } = &m.kind else {
panic!("expected MultiLine");
};
assert!(m.match_col_start <= matched_lines[0].len());
}

#[test]
fn build_preview_strips_crlf_in_multiline_match() {
let content = "foo\r\nbar\r\nbaz\r\n";
let data = PreviewData::new(content, &[(0, 8)]);
let m = &data.matches[0];
let PreviewMatchKind::MultiLine { matched_lines, .. } = &m.kind else {
panic!("expected MultiLine");
};
assert_eq!(matched_lines.len(), 2);
assert_eq!(&*matched_lines[0], "foo");
assert_eq!(&*matched_lines[1], "bar");
}

#[test]
fn build_preview_size_bytes_nonzero() {
let content = "hello world\n";
Expand Down
23 changes: 22 additions & 1 deletion src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ impl<'a> Pattern<'a> {
}
Ok(match mode {
MatchMode::Literal => Pattern::Literal(pattern.as_bytes()),
MatchMode::Regex => Pattern::Regex(regex::Regex::new(pattern)?),
MatchMode::Regex => {
Pattern::Regex(regex::RegexBuilder::new(pattern).crlf(true).build()?)
}
MatchMode::CaseAware => Pattern::Regex(
regex::RegexBuilder::new(&regex::escape(pattern))
.case_insensitive(true)
Expand Down Expand Up @@ -377,6 +379,25 @@ mod tests {
);
}

#[test]
fn regex_dot_does_not_match_carriage_return() {
// on CRLF content, greedy `.` patterns must stop before `\r` so that
// a subsequent replacement doesn't strip the `\r` and corrupt the line ending
let content = "foo bar\r\nbaz\r\n";
let matches = find_matches_in_content(
content,
&Pattern::new("foo.*", MatchMode::Regex).unwrap(),
&AtomicUsize::new(0),
usize::MAX,
)
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(
&content[matches[0].byte_offset_start..matches[0].byte_offset_end],
"foo bar"
);
}

#[test]
fn empty_pattern_returns_no_matches() {
let content = "hello world\n";
Expand Down
34 changes: 33 additions & 1 deletion src/ui/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,12 @@ fn build_multiline_match_lines(
out.push(Line::from(Span::styled(format!("- {line}"), removed_style)));
}
if !effective_replacement.is_empty() || !prefix.is_empty() || !suffix.is_empty() {
let repl_lines: Vec<&str> = effective_replacement.split('\n').collect();
// strip trailing `\r` from each piece so a CRLF replacement renders
// cleanly (without leaking a control char into the displayed span)
let repl_lines: Vec<&str> = effective_replacement
.split('\n')
.map(|s| s.trim_end_matches('\r'))
.collect();
let last_idx = repl_lines.len() - 1;
if last_idx == 0 {
out.push(Line::from(Span::styled(
Expand Down Expand Up @@ -564,6 +569,33 @@ mod tests {
assert_eq!(count_match_lines(&info, &preview, ""), 3);
}

fn preview_lines(preview: &PreviewMatch) -> Box<[Box<str>]> {
match &preview.kind {
PreviewMatchKind::MultiLine { matched_lines, .. } => matched_lines.clone(),
PreviewMatchKind::SingleLine { .. } => unreachable!(),
}
}

#[test]
fn multiline_replacement_strips_carriage_return_from_rendered_spans() {
// a CRLF replacement (e.g. typed as `\r\n` in RegexMultiline mode and expanded)
// must not leak the trailing `\r` into the rendered diff spans
let info = make_info();
let preview = make_preview_multiline(&[" foo", "bar"], 2, 3);
let lines =
build_multiline_match_lines(&info, &preview, &preview_lines(&preview), "a\r\nb");
for line in &lines {
for span in &line.spans {
assert!(
!span.content.contains('\r'),
"rendered span leaked a '\\r': {:?}",
span.content
);
}
}
assert_eq!(lines.len(), 4);
}

#[test]
fn multiline_single_replacement_line() {
let matches = vec![make_info()];
Expand Down