fix: preserve literal whitespace inside strings
This commit is contained in:
@@ -58,6 +58,8 @@ The setting applies to ordinary and historical buffers, split/unified diff panes
|
||||
|
||||
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽" }` for spaces, tabs, and nonbreaking spaces. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, or tabs, and values must be printable one-cell characters. Line breaks and other control characters, zero-width markers, and wide markers reject the configuration.
|
||||
|
||||
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
|
||||
|
||||
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; empty lines and padding do not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
|
||||
|
||||
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||
|
||||
+36
-14
@@ -43,6 +43,7 @@ pub struct SyntaxCache {
|
||||
line_starts: Vec<usize>,
|
||||
lines: Vec<Vec<SyntaxSpan>>,
|
||||
highlighted: Vec<bool>,
|
||||
string_spans: Vec<Vec<SyntaxSpan>>,
|
||||
context: Option<LayerContext>,
|
||||
parser: Parser,
|
||||
tree: Option<Tree>,
|
||||
@@ -147,10 +148,11 @@ impl SyntaxCache {
|
||||
start += line.len() + 1;
|
||||
}
|
||||
self.lines.resize_with(lines.len(), Vec::new);
|
||||
self.string_spans.resize_with(lines.len(), Vec::new);
|
||||
self.highlighted.resize(lines.len(), false);
|
||||
}
|
||||
if edit.is_some() || language_changed {
|
||||
for spans in &mut self.lines {
|
||||
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||
spans.clear();
|
||||
}
|
||||
self.highlighted.fill(false);
|
||||
@@ -209,7 +211,7 @@ impl SyntaxCache {
|
||||
self.injections.clear();
|
||||
self.context = None;
|
||||
self.highlighted.fill(false);
|
||||
for spans in &mut self.lines {
|
||||
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||
spans.clear();
|
||||
}
|
||||
} else {
|
||||
@@ -225,6 +227,11 @@ impl SyntaxCache {
|
||||
self.lines.get(row).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// String coverage before foreground overrides, including embedded-language highlighting.
|
||||
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
|
||||
self.string_spans.get(row).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Bundled grammar name, or `None` for unknown languages and plain text.
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.syntax.map(|syntax| syntax.name)
|
||||
@@ -415,6 +422,7 @@ impl SyntaxCache {
|
||||
&self.source,
|
||||
&self.line_starts,
|
||||
&mut self.lines,
|
||||
&mut self.string_spans,
|
||||
&self.highlights,
|
||||
&mut self.events,
|
||||
visible,
|
||||
@@ -1089,6 +1097,7 @@ fn render_spans(
|
||||
source: &str,
|
||||
starts: &[usize],
|
||||
lines: &mut [Vec<SyntaxSpan>],
|
||||
strings: &mut [Vec<SyntaxSpan>],
|
||||
highlights: &[Highlight],
|
||||
events: &mut Vec<(usize, bool, usize)>,
|
||||
visible: &[Range],
|
||||
@@ -1111,24 +1120,34 @@ fn render_spans(
|
||||
};
|
||||
let mut active: BTreeSet<(_, usize)> = BTreeSet::new();
|
||||
let mut previous = 0;
|
||||
let mut active_strings = 0;
|
||||
for &(byte, entering, index) in events.iter() {
|
||||
if previous < byte
|
||||
&& let Some((_, current)) = active.last()
|
||||
&& let Some(kind) = highlights[*current].kind
|
||||
{
|
||||
if previous < byte {
|
||||
let kind = active
|
||||
.last()
|
||||
.and_then(|(_, current)| highlights[*current].kind);
|
||||
if kind.is_some() || active_strings > 0 {
|
||||
let start = visible.partition_point(|range| range.end_byte <= previous);
|
||||
for range in visible[start..]
|
||||
.iter()
|
||||
.take_while(|range| range.start_byte < byte)
|
||||
{
|
||||
append_global(
|
||||
source,
|
||||
starts,
|
||||
lines,
|
||||
previous.max(range.start_byte),
|
||||
byte.min(range.end_byte),
|
||||
kind,
|
||||
);
|
||||
let low = previous.max(range.start_byte);
|
||||
let high = byte.min(range.end_byte);
|
||||
if let Some(kind) = kind {
|
||||
append_global(source, starts, lines, low, high, kind);
|
||||
}
|
||||
if active_strings > 0 {
|
||||
append_global(source, starts, strings, low, high, SyntaxKind::String);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if highlights[index].kind == Some(SyntaxKind::String) {
|
||||
if entering {
|
||||
active_strings += 1;
|
||||
} else {
|
||||
active_strings -= 1;
|
||||
}
|
||||
}
|
||||
if entering {
|
||||
@@ -1893,6 +1912,7 @@ mod tests {
|
||||
source,
|
||||
&[0],
|
||||
&mut spans,
|
||||
&mut vec![Vec::new(); 1],
|
||||
&highlights,
|
||||
&mut events,
|
||||
&[source_range(source)],
|
||||
@@ -1963,6 +1983,7 @@ mod tests {
|
||||
source,
|
||||
&starts,
|
||||
&mut spans,
|
||||
&mut vec![Vec::new(); 3],
|
||||
&highlights,
|
||||
&mut Vec::new(),
|
||||
&[source_range(source)],
|
||||
@@ -2004,6 +2025,7 @@ mod tests {
|
||||
source,
|
||||
&starts,
|
||||
&mut visible_spans,
|
||||
&mut vec![Vec::new(); 3],
|
||||
&visible_highlights,
|
||||
&mut Vec::new(),
|
||||
&window,
|
||||
|
||||
+212
-28
@@ -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 ";
|
||||
|
||||
Reference in New Issue
Block a user