From adcc4f26569c77eb25afed496b74b2ee6805b4cc Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 23:05:39 +0200 Subject: [PATCH] fix: preserve literal whitespace inside strings --- docs/configuration.md | 2 + src/syntax.rs | 60 +++++++---- src/tui.rs | 240 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 255 insertions(+), 47 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8fc9e4b..e2055f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/src/syntax.rs b/src/syntax.rs index b573e2c..f547bd5 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -43,6 +43,7 @@ pub struct SyntaxCache { line_starts: Vec, lines: Vec>, highlighted: Vec, + string_spans: Vec>, context: Option, parser: Parser, tree: Option, @@ -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], + strings: &mut [Vec], 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 - { - 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, - ); + 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) + { + 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, diff --git a/src/tui.rs b/src/tui.rs index 473a7bc..60085dc 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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>, + 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::(), - ) - } 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::(), + ) + } 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 ";