feat: enable LSP semantic token highlighting by default

This commit is contained in:
2026-10-09 22:17:30 +02:00
parent 548711980c
commit d418d670ee
13 changed files with 1714 additions and 84 deletions
+252 -1
View File
@@ -1,6 +1,6 @@
use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{Diagnostic, TextPosition};
use crate::plugin::{Diagnostic, SemanticToken, TextPosition};
use serde::Serialize;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -10,6 +10,7 @@ use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
use unicode_width::UnicodeWidthStr;
@@ -773,6 +774,7 @@ struct Buffer {
text_revision: u64,
save_revision: u64,
diagnostics: BTreeMap<String, AnnotationSet>,
semantic_tokens: Option<Arc<[SemanticToken]>>,
targets: Vec<Target>,
primary: usize,
mode: Mode,
@@ -814,6 +816,7 @@ impl Buffer {
text_revision: 0,
save_revision: 0,
diagnostics: BTreeMap::new(),
semantic_tokens: None,
targets: vec![Target::caret(Cursor::default())],
primary: 0,
mode: Mode::Normal,
@@ -855,6 +858,7 @@ impl Buffer {
fn text_changed(&mut self) {
self.text_revision = self.text_revision.wrapping_add(1);
self.diagnostics.clear();
self.semantic_tokens = None;
}
}
@@ -1074,6 +1078,101 @@ impl Editor {
}
}
pub fn semantic_tokens(&self) -> &[SemanticToken] {
self.active.semantic_tokens.as_deref().unwrap_or_default()
}
pub(crate) fn validate_semantic_tokens(
&self,
buffer_id: usize,
revision: u64,
tokens: Option<&[SemanticToken]>,
) -> Result<bool, String> {
let Some(buffer) = self.buffer(buffer_id) else {
return Ok(false);
};
if buffer.text_revision != revision {
return Ok(false);
}
let Some(tokens) = tokens else {
return Ok(true);
};
if tokens.len() > MAX_EDIT_BYTES / std::mem::size_of::<SemanticToken>() {
return Err("Semantic tokens exceed 4 MiB".into());
}
let mut previous = None;
let mut row_length = None;
for token in tokens {
if previous.is_some_and(|start| token.start < start) {
return Err("Semantic token starts must be sorted".into());
}
if token.start.row != token.end.row || token.start >= token.end {
return Err("Semantic tokens must have nonempty single-line ranges".into());
}
if token.modifiers & !1023 != 0 {
return Err("Semantic token modifiers are not canonical".into());
}
if row_length.is_none_or(|(row, _)| row != token.start.row) {
let line = buffer
.lines
.get(token.start.row)
.ok_or("Semantic token position is outside the document")?;
row_length = Some((token.start.row, grapheme_count(line)));
}
if token.end.column > row_length.unwrap().1 {
return Err("Semantic token position is outside the document".into());
}
previous = Some(token.start);
}
Ok(true)
}
pub fn replace_semantic_tokens(
&mut self,
buffer_id: usize,
revision: u64,
tokens: Option<Arc<[SemanticToken]>>,
) -> Result<bool, String> {
if !self.validate_semantic_tokens(buffer_id, revision, tokens.as_deref())? {
return Ok(false);
}
Ok(self.replace_validated_semantic_tokens(buffer_id, revision, tokens))
}
/// Only session's fully prevalidated batch may use this storage path.
pub(crate) fn replace_validated_semantic_tokens(
&mut self,
buffer_id: usize,
revision: u64,
tokens: Option<Arc<[SemanticToken]>>,
) -> bool {
let buffer = if buffer_id == self.active.id {
&mut self.active
} else if let Some(buffer) = self.parked.get_mut(&buffer_id) {
buffer
} else {
return false;
};
if buffer.text_revision != revision {
return false;
}
let tokens = tokens.filter(|tokens| !tokens.is_empty());
match (&buffer.semantic_tokens, &tokens) {
(None, None) => return false,
(Some(current), Some(next)) if Arc::ptr_eq(current, next) => return false,
_ => {}
}
buffer.semantic_tokens = tokens;
true
}
pub fn clear_semantic_tokens(&mut self) {
self.active.semantic_tokens = None;
for buffer in self.parked.values_mut() {
buffer.semantic_tokens = None;
}
}
pub fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
self.active
.diagnostics
@@ -2754,6 +2853,9 @@ impl Editor {
Ok(()) => {
self.active.saved_lines.clone_from(&self.active.lines);
self.active.save_revision = self.active.save_revision.wrapping_add(1);
if self.active.path.as_ref() != Some(&path) {
self.active.semantic_tokens = None;
}
self.active.path = Some(path);
self.active.identity = Some(identity);
self.set_message("Written");
@@ -4316,6 +4418,155 @@ mod tests {
editor
}
#[test]
fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() {
use crate::plugin::SemanticTokenKind;
let directory = Directory::new();
let a = directory.path("a.py");
let b = directory.path("b.py");
fs::write(&a, "e\u{301}界").unwrap();
fs::write(&b, "other").unwrap();
let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
let tokens: Arc<[SemanticToken]> = Arc::from([SemanticToken {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 2 },
kind: SemanticTokenKind::Variable,
modifiers: 1023,
}]);
assert!(
editor
.replace_semantic_tokens(1, 0, Some(tokens.clone()))
.unwrap()
);
assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref()));
assert!(!editor.dirty());
assert!(editor.active.undo.is_empty());
editor.execute(&format!("edit {}", b.display()));
assert!(editor.semantic_tokens().is_empty());
assert!(
editor
.replace_semantic_tokens(2, 0, Some(tokens.clone()))
.unwrap()
);
editor.execute("buffer 1");
assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref()));
assert!(
!editor
.replace_semantic_tokens(1, 0, Some(tokens.clone()))
.unwrap()
);
editor.execute("w");
assert_eq!(editor.semantic_tokens(), tokens.as_ref());
editor.execute(&format!("w {}", directory.path("renamed.py").display()));
assert_eq!(editor.text_revision(), 0);
assert!(editor.semantic_tokens().is_empty());
assert!(
editor
.replace_semantic_tokens(1, 0, Some(tokens.clone()))
.unwrap()
);
editor.handle(Input::Char('x'));
assert!(editor.semantic_tokens().is_empty());
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), ["e\u{301}界"]);
assert!(editor.semantic_tokens().is_empty());
assert!(
!editor
.replace_semantic_tokens(1, 0, Some(tokens.clone()))
.unwrap()
);
editor.execute("buffer 2");
assert_eq!(editor.semantic_tokens(), tokens.as_ref());
editor.clear_semantic_tokens();
assert!(editor.semantic_tokens().is_empty());
editor.execute("buffer 1");
assert!(editor.semantic_tokens().is_empty());
}
#[test]
fn semantic_validation_rejects_malformed_live_tokens_but_ignores_stale_and_unloaded() {
use crate::plugin::SemanticTokenKind;
let mut editor = editor();
editor
.apply(vec![Effect::Replace("e\u{301}界\nx".into())])
.unwrap();
let revision = editor.text_revision();
let token = SemanticToken {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 2 },
kind: SemanticTokenKind::Variable,
modifiers: 0,
};
let invalid = [
SemanticToken {
end: TextPosition { row: 0, column: 3 },
..token
},
SemanticToken {
end: token.start,
..token
},
SemanticToken {
end: TextPosition { row: 1, column: 1 },
..token
},
SemanticToken {
start: TextPosition { row: 9, column: 0 },
end: TextPosition { row: 9, column: 1 },
..token
},
SemanticToken {
modifiers: 1024,
..token
},
];
for token in invalid {
assert!(
editor
.validate_semantic_tokens(1, revision, Some(&[token]))
.is_err()
);
assert!(
!editor
.validate_semantic_tokens(1, revision - 1, Some(&[token]))
.unwrap()
);
assert!(
!editor
.validate_semantic_tokens(999, revision, Some(&[token]))
.unwrap()
);
}
let later = SemanticToken {
start: TextPosition { row: 0, column: 1 },
..token
};
assert!(
editor
.validate_semantic_tokens(1, revision, Some(&[later, token]))
.is_err()
);
// Raw adjacent LSP tokens may map outward to the same grapheme.
assert!(
editor
.validate_semantic_tokens(1, revision, Some(&[token, token, later]))
.unwrap()
);
let oversized = vec![token; MAX_EDIT_BYTES / std::mem::size_of::<SemanticToken>() + 1];
assert!(
editor
.validate_semantic_tokens(1, revision, Some(&oversized))
.is_err()
);
let tokens: Arc<[SemanticToken]> = Arc::from([token]);
editor
.replace_semantic_tokens(1, revision, Some(tokens))
.unwrap();
assert!(editor.replace_semantic_tokens(1, revision, None).unwrap());
assert!(editor.semantic_tokens().is_empty());
}
#[test]
fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() {
let mut editor = editor();