diff --git a/src/preview/data.rs b/src/preview/data.rs index 6d659fe..82ef0d1 100644 --- a/src/preview/data.rs +++ b/src/preview/data.rs @@ -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 { @@ -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 { @@ -170,6 +167,23 @@ pub struct ContextLine { pub content: Box, } +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 { @@ -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"; diff --git a/src/search.rs b/src/search.rs index 7bdd5fc..bd45499 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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(®ex::escape(pattern)) .case_insensitive(true) @@ -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"; diff --git a/src/ui/preview.rs b/src/ui/preview.rs index 434962b..cbdd94a 100644 --- a/src/ui/preview.rs +++ b/src/ui/preview.rs @@ -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( @@ -564,6 +569,33 @@ mod tests { assert_eq!(count_match_lines(&info, &preview, ""), 3); } + fn preview_lines(preview: &PreviewMatch) -> Box<[Box]> { + 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()];