feat: dim whitespace markers and configure scope and newlines

This commit is contained in:
2026-10-13 23:18:39 +02:00
parent adcc4f2656
commit 11b31d2621
10 changed files with 964 additions and 112 deletions
+75 -8
View File
@@ -24,7 +24,7 @@ pub enum SyntaxKind {
Markup,
}
/// A syntax foreground range in the original line's bytes, excluding its newline.
/// A syntax range in original line bytes; string coverage may include its newline.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SyntaxSpan {
pub start_byte: usize,
@@ -37,6 +37,7 @@ pub struct SyntaxSpan {
pub struct SyntaxCache {
revision: Option<u64>,
path: Option<PathBuf>,
final_newline: bool,
syntax: Option<&'static LanguageSpec>,
source: String,
next_source: String,
@@ -89,6 +90,7 @@ impl SyntaxCache {
path,
revision,
std::slice::from_ref(&(0..lines.len())),
false,
);
}
@@ -101,7 +103,26 @@ impl SyntaxCache {
revision: u64,
visible: &[std::ops::Range<usize>],
) {
self.update_rows(lines, path, revision, visible);
self.update_rows(lines, path, revision, visible, false);
}
/// Diff sources store a final line break separately instead of a trailing empty row.
pub(crate) fn update_diff(
&mut self,
lines: &[String],
path: Option<&Path>,
revision: u64,
final_newline: bool,
visible: Option<&[std::ops::Range<usize>]>,
) {
let all_rows = 0..lines.len();
self.update_rows(
lines,
path,
revision,
visible.unwrap_or(std::slice::from_ref(&all_rows)),
final_newline && !lines.is_empty(),
);
}
fn update_rows(
@@ -110,9 +131,11 @@ impl SyntaxCache {
path: Option<&Path>,
revision: u64,
visible: &[std::ops::Range<usize>],
final_newline: bool,
) {
let path_changed = self.path.as_deref() != path;
let revision_changed = self.revision != Some(revision);
let revision_changed =
self.revision != Some(revision) || self.final_newline != final_newline;
let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str))
} else {
@@ -120,6 +143,7 @@ impl SyntaxCache {
};
let language_changed = !same_language(self.syntax, syntax);
self.revision = Some(revision);
self.final_newline = final_newline;
if path_changed {
self.path = path.map(Path::to_path_buf);
}
@@ -128,8 +152,9 @@ impl SyntaxCache {
let mut edit = None;
if revision_changed {
self.next_source.clear();
let length =
lines.iter().map(String::len).sum::<usize>() + lines.len().saturating_sub(1);
let length = lines.iter().map(String::len).sum::<usize>()
+ lines.len().saturating_sub(1)
+ usize::from(final_newline);
self.next_source.reserve(length);
for (row, line) in lines.iter().enumerate() {
if row > 0 {
@@ -137,6 +162,9 @@ impl SyntaxCache {
}
self.next_source.push_str(line);
}
if final_newline {
self.next_source.push('\n');
}
if self.source != self.next_source {
edit = Some(minimal_edit(&self.source, &self.next_source));
std::mem::swap(&mut self.source, &mut self.next_source);
@@ -147,6 +175,9 @@ impl SyntaxCache {
self.line_starts.push(start);
start += line.len() + 1;
}
if final_newline {
self.line_starts.push(self.source.len());
}
self.lines.resize_with(lines.len(), Vec::new);
self.string_spans.resize_with(lines.len(), Vec::new);
self.highlighted.resize(lines.len(), false);
@@ -185,6 +216,8 @@ impl SyntaxCache {
start_point: Point::new(start, 0),
end_point: if row < lines.len() {
Point::new(row, 0)
} else if final_newline {
Point::new(lines.len(), 0)
} else {
Point::new(
lines.len().saturating_sub(1),
@@ -228,6 +261,7 @@ impl SyntaxCache {
}
/// String coverage before foreground overrides, including embedded-language highlighting.
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
self.string_spans.get(row).map_or(&[], Vec::as_slice)
}
@@ -1135,10 +1169,10 @@ fn render_spans(
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);
append_global(source, starts, lines, low, high, kind, false);
}
if active_strings > 0 {
append_global(source, starts, strings, low, high, SyntaxKind::String);
append_global(source, starts, strings, low, high, SyntaxKind::String, true);
}
}
}
@@ -1166,6 +1200,7 @@ fn append_global(
mut start: usize,
end: usize,
kind: SyntaxKind,
include_newline: bool,
) {
if starts.is_empty() {
return;
@@ -1175,7 +1210,7 @@ fn append_global(
.saturating_sub(1);
while start < end && row < lines.len() {
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
let high = end.min(line_end);
let high = end.min(line_end + usize::from(include_newline && row + 1 < starts.len()));
if start < high {
let spans = &mut lines[row];
let low = start - starts[row];
@@ -1215,6 +1250,38 @@ mod tests {
}
}
#[test]
fn string_newlines_remain_literal_across_visible_row_caching_and_edits() {
let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n";
let input = lines(source);
let path = Path::new("strings.py");
let mut cache = SyntaxCache::new();
let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| {
cache
.string_spans(row)
.iter()
.any(|span| span.start_byte <= length && span.end_byte > length)
};
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1)));
assert!(includes_newline(&cache, 0, input[0].len()));
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4)));
assert!(includes_newline(&cache, 0, input[0].len()));
assert!(includes_newline(&cache, 1, input[1].len()));
assert!(!includes_newline(&cache, 2, input[2].len()));
assert!(!includes_newline(&cache, 3, input[3].len()));
for (row, line) in input.iter().enumerate() {
assert!(
cache
.line(row)
.iter()
.all(|span| span.end_byte <= line.len())
);
}
cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1);
assert!(!includes_newline(&cache, 0, 3));
assert!(!includes_newline(&cache, 1, 0));
}
#[test]
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
let cases = [