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
+507 -3
View File
@@ -11,7 +11,7 @@ use std::{
use serde_json::{Value, json};
use vamp::{
editor::{Cursor, Input, Mode},
plugin::DiagnosticSeverity,
plugin::{DiagnosticSeverity, SemanticTokenKind, SemanticTokenModifier, TextPosition},
session::EditorSession,
};
@@ -67,6 +67,7 @@ end
initialize_id = nil
initialized_sent = false
held = []
semantic_held = []
definition_count = 0
source_uri = uri(source)
documents = {}
@@ -79,9 +80,16 @@ loop do
when 'incremental' then {'openClose' => true, 'change' => 2, 'save' => {'includeText' => true}}
else {'openClose' => true, 'change' => 1, 'save' => {'includeText' => true}}
end
result(initialize_id, {'capabilities' => {'positionEncoding' => 'utf-16',
capabilities = {'positionEncoding' => 'utf-16',
'textDocumentSync' => sync, 'definitionProvider' => ['unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false},
'hoverProvider' => ['hover-unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}}})
'hoverProvider' => ['hover-unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}}
if mode.include?('semantic')
capabilities['semanticTokensProvider'] = {
'legend' => {'tokenTypes' => ['custom', 'variable', 'function', 'comment', 'type'],
'tokenModifiers' => ['custom', 'readonly', 'declaration', 'async', 'defaultLibrary']},
'range' => true, 'full' => mode.include?('range') ? false : {'delta' => true}}
end
result(initialize_id, {'capabilities' => capabilities})
initialized_sent = true
end
if !held.empty? && File.exist?(release) && ['stale', 'hover-stale'].include?(mode)
@@ -103,6 +111,12 @@ loop do
'diagnostics' => command['action'] == 'clear' ? [] :
[{'range' => range(0, 2, 4), 'severity' => 2, 'message' => command.fetch('message')}]}
emit({'method' => 'textDocument/publishDiagnostics', 'params' => params})
when 'semantic-result'
request = semantic_held.delete_at(command.fetch('index', 0))
raise 'no held semantic request' unless request
emit({'id' => request.fetch('id')}.merge(command.fetch('response')))
when 'refresh'
emit({'id' => "refresh-#{control_id}", 'method' => 'workspace/semanticTokens/refresh'})
when 'die'
exit! 7
when 'malformed'
@@ -209,6 +223,19 @@ loop do
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
result(frame['id'], hover)
end
when 'textDocument/semanticTokens/full', 'textDocument/semanticTokens/range'
if mode.start_with?('controlled')
semantic_held << frame
else
result(frame['id'], {'data' => [
0, 0, 1, 4, 0,
0, 1, 2, 1, 2,
0, 2, 1, 2, 12,
0, 1, 1, 3, 16,
0, 1, 1, 0, 1,
1, 0, 4, 4, 0
]})
end
when 'shutdown'
result(frame['id'], nil)
when 'exit'
@@ -1621,3 +1648,480 @@ fn lsp_python_stack_does_not_fall_back_to_ruff_when_ty_lacks_position_capabiliti
assert_eq!(fixture.worker_count(worker, "textDocument/hover"), 0);
}
}
fn semantic_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, response: Value) {
fixture.control(
0,
id,
json!({"action": "semantic-result", "response": response}),
);
wait_protocol(fixture, || fixture.acknowledged(0, id));
session.poll();
}
#[test]
fn lsp_semantics_are_automatic_full_or_whole_document_range_with_canonical_legends() {
for mode in ["semantic-auto", "semantic-range"] {
let fixture = Fixture::new();
fs::write(&fixture.source, "A😀e\u{301}Z\nnext\n").unwrap();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().semantic_tokens().len() == 5
&& session.editor().diagnostics().count() == 2
});
let tokens = session.editor().semantic_tokens();
assert_eq!(tokens[0].kind, SemanticTokenKind::Type);
assert_eq!(tokens[1].kind, SemanticTokenKind::Variable);
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 1 });
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 2 });
assert_eq!(tokens[1].modifiers, SemanticTokenModifier::Readonly as u16);
assert_eq!(tokens[2].kind, SemanticTokenKind::Function);
assert_eq!(
tokens[2].modifiers,
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
);
assert_eq!(tokens[3].kind, SemanticTokenKind::Comment);
assert_eq!(
tokens[3].modifiers,
SemanticTokenModifier::DefaultLibrary as u16
);
assert_eq!(tokens[2].start, tokens[3].start);
assert_eq!(tokens[2].end, tokens[3].end);
assert_eq!(tokens[4].start, TextPosition { row: 1, column: 0 });
assert_eq!(tokens[4].end, TextPosition { row: 1, column: 4 });
let frames = fixture.frames();
let request = frames
.iter()
.find(|frame| {
frame["method"]
== if mode == "semantic-range" {
"textDocument/semanticTokens/range"
} else {
"textDocument/semanticTokens/full"
}
})
.unwrap();
if mode == "semantic-range" {
assert_eq!(
request["params"]["range"],
json!({"start": {"line": 0, "character": 0},
"end": {"line": 2, "character": 0}})
);
assert_eq!(fixture.method_count("textDocument/semanticTokens/full"), 0);
} else {
assert!(request["params"].get("range").is_none());
assert_eq!(fixture.method_count("textDocument/semanticTokens/range"), 0);
}
assert_eq!(
fixture.method_count("textDocument/semanticTokens/full/delta"),
0
);
let initialize = frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap();
assert_eq!(
initialize["params"]["capabilities"]["textDocument"]["semanticTokens"]["requests"]["full"],
true
);
assert_eq!(
initialize["params"]["capabilities"]["workspace"]["semanticTokens"]["refreshSupport"],
true
);
}
}
#[test]
fn lsp_semantics_never_request_unsupported_primary_or_supporting_sibling() {
for modes in [
["controlled", "controlled-semantic"],
["controlled-semantic", "controlled-semantic"],
] {
let fixture = Fixture::new();
let mut session = fixture.open_stack(modes, false);
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didOpen") == 1)
&& (modes[0] == "controlled"
|| fixture.worker_count(0, "textDocument/semanticTokens/full") == 1)
});
for worker in 0..2 {
fixture.control(worker, 1, json!({"action": "refresh"}));
}
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.acknowledged(worker, 1))
});
assert_eq!(
fixture.worker_count(1, "textDocument/semanticTokens/full"),
0
);
assert_eq!(
fixture.worker_count(1, "textDocument/semanticTokens/range"),
0
);
if modes[0] == "controlled" {
assert_eq!(
fixture.worker_count(0, "textDocument/semanticTokens/full"),
0
);
assert_eq!(
fixture.worker_count(0, "textDocument/semanticTokens/range"),
0
);
} else {
semantic_reply(
&mut session,
&fixture,
2,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/semanticTokens/full") == 2
});
assert!(session.editor().semantic_tokens().is_empty());
semantic_reply(
&mut session,
&fixture,
3,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
assert_eq!(session.editor().semantic_tokens().len(), 1);
}
}
}
#[test]
fn lsp_semantics_coalesce_changed_text_and_refresh_without_decoding_stale_payloads() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-semantic");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 1
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Char('?'));
session.handle(Input::Escape);
until(&mut session, &fixture, |_| {
fixture.frames().iter().any(|frame| {
frame["method"] == "textDocument/didChange"
&& frame["params"]["contentChanges"][0]["text"] == "A😀e\u{301}Z!?\n"
})
});
assert_eq!(fixture.method_count("textDocument/semanticTokens/full"), 1);
semantic_reply(
&mut session,
&fixture,
1,
json!({"result": {"data": ["stale malformed data"]}}),
);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 2
});
assert!(
!session
.editor()
.message()
.contains("semantic tokens failed")
);
assert!(session.editor().semantic_tokens().is_empty());
fixture.control(0, 2, json!({"action": "refresh"}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, 2));
assert_eq!(fixture.method_count("textDocument/semanticTokens/full"), 2);
semantic_reply(
&mut session,
&fixture,
3,
json!({"result": {"data": [0, 0, 1, 4, 0]}}),
);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 3
});
assert!(session.editor().semantic_tokens().is_empty());
semantic_reply(
&mut session,
&fixture,
4,
json!({"result": {"data": [0, 6, 2, 1, 0]}}),
);
let tokens = session.editor().semantic_tokens();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 4 });
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 6 });
let frames = fixture.frames();
let change = frames
.iter()
.rposition(|frame| frame["method"] == "textDocument/didChange")
.unwrap();
let request = frames
.iter()
.rposition(|frame| frame["method"] == "textDocument/semanticTokens/full")
.unwrap();
assert!(change < request);
assert!(frames.iter().any(|frame| frame["id"] == "refresh-2"
&& frame["result"].is_null()
&& frame.get("result").is_some()));
}
#[test]
fn lsp_semantics_discard_queued_same_revision_rename_and_close_outputs() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-semantic");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 1
});
fixture.control(
0,
1,
json!({"action": "semantic-result",
"response": {"result": {"data": [0, 0, 1, 1, 0]}}}),
);
wait_protocol(&fixture, || fixture.acknowledged(0, 1));
let revision = session.editor().context().revision;
let renamed = fixture.root.join("renamed.rs");
session.execute(&format!("w {}", renamed.display()));
assert_eq!(session.editor().context().revision, revision);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 2
&& fixture.method_count("textDocument/didClose") == 1
});
assert!(session.editor().semantic_tokens().is_empty());
semantic_reply(
&mut session,
&fixture,
2,
json!({"result": {"data": [0, 0, 1, 4, 0]}}),
);
assert_eq!(
session.editor().semantic_tokens()[0].kind,
SemanticTokenKind::Type
);
fixture.control(0, 3, json!({"action": "refresh"}));
until(&mut session, &fixture, |_| {
fixture.acknowledged(0, 3) && fixture.method_count("textDocument/semanticTokens/full") == 3
});
let renamed_again = fixture.root.join("renamed-again.rs");
session.execute(&format!("w {}", renamed_again.display()));
assert_eq!(session.editor().context().revision, revision);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didClose") == 2
});
assert_eq!(fixture.method_count("textDocument/semanticTokens/full"), 3);
semantic_reply(
&mut session,
&fixture,
4,
json!({"result": {"data": ["old path malformed"]}}),
);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 4
});
assert!(session.editor().semantic_tokens().is_empty());
assert!(
!session
.editor()
.message()
.contains("semantic tokens failed")
);
semantic_reply(
&mut session,
&fixture,
5,
json!({"result": {"data": [0, 0, 1, 4, 0]}}),
);
fixture.control(0, 6, json!({"action": "refresh"}));
until(&mut session, &fixture, |_| {
fixture.acknowledged(0, 6) && fixture.method_count("textDocument/semanticTokens/full") == 5
});
fixture.control(
0,
7,
json!({"action": "semantic-result",
"response": {"result": {"data": [0, 0, 1, 1, 0]}}}),
);
wait_protocol(&fixture, || fixture.acknowledged(0, 7));
session.execute("bdelete 1");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didClose") == 3
});
assert!(!session.editor().has_buffer(1));
assert!(session.editor().semantic_tokens().is_empty());
session.execute(&format!("edit {}", renamed_again.display()));
let reopened = session.editor().buffer_id();
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 6
});
session.execute(&format!("bdelete {reopened}"));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didClose") == 4
});
semantic_reply(
&mut session,
&fixture,
8,
json!({"result": {"data": ["closed malformed"]}}),
);
assert!(session.editor().semantic_tokens().is_empty());
assert!(
!session
.editor()
.message()
.contains("semantic tokens failed")
);
}
#[test]
fn lsp_semantic_empty_null_and_feature_errors_keep_diagnostics_hover_and_definition() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-semantic");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 1
});
semantic_reply(
&mut session,
&fixture,
1,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
assert_eq!(session.editor().semantic_tokens().len(), 1);
publish(
&mut session,
&fixture,
0,
2,
json!({"action": "publish", "message": "lint intact"}),
&["lint intact"],
);
for (index, response) in [
json!({"result": null}),
json!({"result": {"data": []}}),
json!({"result": {"data": [0, 0, 1, 1]}}),
json!({"result": {"data": [0, 2, 1, 0, 0]}}),
json!({"result": {"data": [0, 0, 1, 1, 32]}}),
json!({"error": {"code": -32603, "message": "feature error"}}),
json!({"error": {"code": "bad", "message": "malformed error"}}),
json!({"result": null, "error": {"code": -1, "message": "both"}}),
json!({}),
]
.into_iter()
.enumerate()
{
let id = 3 + index as u64 * 4;
fixture.control(0, id, json!({"action": "refresh"}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, id));
assert_eq!(
session.editor().semantic_tokens().len(),
1,
"same-text refresh retains the existing overlay until its result"
);
semantic_reply(&mut session, &fixture, id + 1, response);
assert!(session.editor().semantic_tokens().is_empty());
assert_eq!(diagnostic_messages(&session), ["lint intact"]);
if index >= 2 {
assert!(
session
.editor()
.message()
.contains("semantic tokens failed")
);
}
fixture.control(0, id + 2, json!({"action": "refresh"}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, id + 2));
semantic_reply(
&mut session,
&fixture,
id + 3,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
assert_eq!(session.editor().semantic_tokens().len(), 1);
}
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
assert_eq!(diagnostic_messages(&session), ["lint intact"]);
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().buffer_id() != 1
});
assert_eq!(session.editor().lines(), &["disk", ""]);
session.execute("buffer 1");
assert_eq!(diagnostic_messages(&session), ["lint intact"]);
assert_eq!(session.editor().semantic_tokens().len(), 1);
}
#[test]
fn lsp_semantic_primary_retirement_clears_active_and_parked_without_sibling_takeover() {
for failing in 0..2 {
let fixture = Fixture::new();
fs::write(&fixture.target, "A😀e\u{301}Z\n").unwrap();
let mut session = fixture.open_stack(["controlled-semantic"; 2], false);
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/semanticTokens/full") == 1
&& fixture.worker_count(1, "textDocument/didOpen") == 1
});
semantic_reply(
&mut session,
&fixture,
1,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
for worker in 0..2 {
fixture.control(
worker,
2,
json!({"action": "publish",
"message": if worker == failing { "retiring" } else { "healthy" }}),
);
}
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.acknowledged(worker, 2))
});
session.execute(&format!("edit {}", fixture.target.display()));
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/semanticTokens/full") == 2
&& fixture.worker_count(1, "textDocument/didOpen") == 2
});
semantic_reply(
&mut session,
&fixture,
3,
json!({"result": {"data": [0, 0, 1, 1, 0]}}),
);
let target_uri = fixture
.worker_frames(0)
.into_iter()
.filter(|frame| frame["method"] == "textDocument/didOpen")
.nth(1)
.unwrap()["params"]["textDocument"]["uri"]
.clone();
for worker in 0..2 {
fixture.control(
worker,
4,
json!({"action": "publish", "uri": target_uri,
"message": if worker == failing { "retiring" } else { "healthy" }}),
);
}
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.acknowledged(worker, 4))
});
fixture.control(failing, 5, json!({"action": "die"}));
until(&mut session, &fixture, |session| {
session.editor().message().contains("server stopped")
&& diagnostic_messages(session) == ["healthy"]
});
assert_eq!(
session.editor().semantic_tokens().len(),
usize::from(failing != 0)
);
session.execute("buffer 1");
assert_eq!(diagnostic_messages(&session), ["healthy"]);
assert_eq!(
session.editor().semantic_tokens().len(),
usize::from(failing != 0)
);
assert_eq!(
fixture.worker_count(1, "textDocument/semanticTokens/full"),
0
);
}
}