fix: preserve literal whitespace inside strings

This commit is contained in:
2026-10-13 23:05:39 +02:00
parent 2357b5be46
commit adcc4f2656
3 changed files with 255 additions and 47 deletions
+212 -28
View File
@@ -995,7 +995,7 @@ fn display_column(line: &str, grapheme_column: usize) -> usize {
}
fn visible_line(line: &str, left: usize, width: usize) -> String {
visible_text(line, left, width, None)
visible_text(line, left, width, None, &[], false)
}
fn visible_text(
@@ -1003,12 +1003,19 @@ fn visible_text(
left: usize,
width: usize,
whitespace: Option<&BTreeMap<char, char>>,
strings: &[SyntaxSpan],
sanitize_controls: bool,
) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
for grapheme in line.graphemes(true) {
let cells = if grapheme == "\t" {
let whitespace = whitespace.filter(|characters| !characters.is_empty());
let mut strings = strings.iter().peekable();
for (byte, grapheme) in line.grapheme_indices(true) {
let control = sanitize_controls && grapheme.chars().any(|ch| ch.is_control() && ch != '\t');
let cells = if control {
grapheme.chars().count()
} else if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
@@ -1020,9 +1027,19 @@ fn visible_text(
if end > left || (cells == 0 && column >= left) {
let marker = whitespace.and_then(|characters| {
let ch = grapheme.chars().next()?;
(grapheme.len() == ch.len_utf8())
let marker = (grapheme.len() == ch.len_utf8())
.then(|| characters.get(&ch))
.flatten()
.flatten()?;
while strings.peek().is_some_and(|span| span.end_byte <= byte) {
strings.next();
}
if strings
.peek()
.is_some_and(|span| span.start_byte < byte + grapheme.len())
{
return None;
}
Some(marker)
});
if let Some(&marker) = marker {
for cell in column.max(left)..end.min(right) {
@@ -1035,6 +1052,10 @@ fn visible_text(
for _ in column.max(left)..end.min(right) {
visible.push(' ');
}
} else if control {
for _ in 0..cells {
visible.push('�');
}
} else {
visible.push_str(grapheme);
}
@@ -1861,6 +1882,8 @@ fn draw_document(
segment.start,
segment.end.saturating_sub(segment.start).min(width),
Some(editor.whitespace_characters()),
syntax.string_spans(row),
false,
))
})
.collect();
@@ -3246,28 +3269,14 @@ fn draw_diff_pane(
} else {
Color::Reset
};
let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') {
Cow::Owned(
source_text
.chars()
.map(|ch| {
if ch.is_control() && ch != '\t' {
'�'
} else {
ch
}
})
.collect::<String>(),
)
} else {
Cow::Borrowed(source_text.as_str())
};
frame.render_widget(
Paragraph::new(visible_text(
&text,
source_text,
left,
text_width.min(width),
whitespace_characters,
syntax[usize::from(!before)].string_spans(index),
true,
))
.style(Style::default().fg(Color::Reset).bg(background)),
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
@@ -3334,6 +3343,22 @@ fn draw_diff_pane(
}
}
if highlight_trailing_whitespace {
let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') {
Cow::Owned(
source_text
.chars()
.map(|ch| {
if ch.is_control() && ch != '\t' {
'�'
} else {
ch
}
})
.collect::<String>(),
)
} else {
Cow::Borrowed(source_text.as_str())
};
paint_trailing_whitespace(frame, &text, text_area, left);
}
}
@@ -4914,20 +4939,179 @@ mod tests {
}
}
#[test]
fn whitespace_markers_preserve_multiline_raw_and_injected_strings() {
let directory = Directory::new();
for (path, source, expected) in [
(
"raw.rs",
"let text = r#\"a b\"#; ",
&["let·text·=·r#\"a b\"#;·"][..],
),
(
"unicode.rs",
"let text = \"α β\"; ",
&["let·text·=·\"α β\";·"][..],
),
(
"multiline.py",
"text = \"\"\"a b\nc d\"\"\" ",
&["text·=·\"\"\"a b", "c d\"\"\"·"][..],
),
(
"template.js",
"const text = `a b ${ x + y }`; ",
&["const·text·=·`a b ${ x + y }`;·"][..],
),
(
"injected.cpp",
"auto text = R\"cpp(int x = 1;)cpp\"; ",
&["auto·text·=·R\"cpp(int x = 1;)cpp\";·"][..],
),
(
"heredoc.rb",
"text = <<~TEXT\n a b\nTEXT\n",
&["text·=·<<~TEXT", " a b"][..],
),
] {
let mut editor = Editor::open(Some(directory.file(path, source)), vec![]).unwrap();
editor.set_whitespace_characters(BTreeMap::from([(' ', '·')]));
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
for expected in expected {
assert!(rendered.contains(expected), "{path}: {rendered}");
}
}
let characters =
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]);
for (source, left, width, expected) in [
("let text = \"α β\"; ", 12, 3, "α β"),
(
"let s = \"\t \u{3000}\u{a0} \"; ",
0,
80,
"let·s·=·\" \u{3000}\u{a0} \";·",
),
(
"let s = \"\t \u{3000}\u{a0} \"; ",
10,
7,
" \u{3000}\u{a0} ",
),
] {
let mut syntax = SyntaxCache::new();
syntax.update_text(source, std::path::Path::new("strings.rs"));
assert_eq!(
visible_text(
source,
left,
width,
Some(&characters),
syntax.string_spans(0),
false
),
expected,
);
}
}
#[test]
fn whitespace_markers_leave_string_contents_literal_in_buffers_and_diffs() {
let directory = Directory::new();
let source = "let text = \"a b\"; ";
let mut editor = Editor::open(Some(directory.file("strings.rs", source)), vec![]).unwrap();
editor.set_whitespace_characters(BTreeMap::from([(' ', '·')]));
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("let·text·=·\"a b\";·"), "{rendered}");
editor
.apply(vec![crate::editor::Effect::Replace(
"let text = a + b; ".into(),
)])
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("let·text·=·a·+·b;·"), "{rendered}");
for mode in [ViewMode::Split, ViewMode::Unified] {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_diff_display_options(mode, SplitAxis::Vertical);
install_readonly_diff(
&mut editor,
syntax_diff_file("strings.rs", source, "\0let text = \"c d\"; "),
);
editor.set_whitespace_characters(BTreeMap::from([(' ', '·')]));
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
for expected in ["let·text·=·\"a b\";·", "�let·text·=·\"c d\";·"] {
assert!(rendered.contains(expected), "{mode:?}: {rendered}");
}
}
}
#[test]
fn whitespace_characters_preserve_source_geometry_and_overlay_styles() {
let characters =
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]);
let source = "a \tb\u{a0}\u{3000} c";
assert_eq!(visible_text(source, 0, 20, Some(&characters)), "a·→ b⍽_ ·c");
assert_eq!(visible_text(source, 3, 6, Some(&characters)), " b⍽_ ·");
assert_eq!(visible_text(source, 7, 2, Some(&characters)), " ·");
assert_eq!(visible_text(source, 6, 1, Some(&characters)), "_");
assert_eq!(
visible_text(" \u{301}", 0, 4, Some(&characters)),
visible_text(source, 0, 20, Some(&characters), &[], false),
"a·→ b⍽_ ·c"
);
assert_eq!(
visible_text(source, 3, 6, Some(&characters), &[], false),
" b⍽_ ·"
);
assert_eq!(
visible_text(source, 7, 2, Some(&characters), &[], false),
" ·"
);
assert_eq!(
visible_text(source, 6, 1, Some(&characters), &[], false),
"_"
);
assert_eq!(
visible_text(" \u{301}", 0, 4, Some(&characters), &[], false),
" \u{301}"
);
assert_eq!(visible_text("", 0, 4, Some(&characters)), "");
assert_eq!(visible_text("", 0, 4, Some(&characters), &[], false), "");
let directory = Directory::new();
let source = "a b\t \u{3000}\n \t\n\nabcdefg ";