Files
vamp/tests/lsp.rs
T
2026-10-16 18:53:01 +02:00

4700 lines
177 KiB
Rust

#![cfg(unix)]
use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use serde_json::{Value, json};
use vamp::{
diff::{DisplayRow, Focus, Phase},
editor::{Cursor, Input, Mode},
plugin::{DiagnosticSeverity, SemanticTokenKind, SemanticTokenModifier, TextPosition},
session::EditorSession,
};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
// A real subprocess speaking framed LSP to the native client.
// Files are explicit test-controlled gates; no wall-clock ordering assumptions.
const SERVER: &str = r#"
require 'json'
require 'uri'
STDIN.binmode
STDOUT.binmode
STDOUT.sync = true
mode, log, release, source, target, alternate = ARGV
def uri(path)
'file://' + URI::DEFAULT_PARSER.escape(File.expand_path(path), /[^A-Za-z0-9\-._~\/]/)
end
def emit(frame)
body = JSON.generate({'jsonrpc' => '2.0'}.merge(frame)).encode('UTF-8').b
header = "Content-Length: #{body.bytesize}\r\nContent-Type: application/vscode-jsonrpc; charset=utf8\r\n\r\n".b
# Both fragmented headers/bodies and multiple immediately adjacent frames.
STDOUT.write(header.byteslice(0, 7))
STDOUT.write(header.byteslice(7..))
STDOUT.write(body.byteslice(0, 3))
STDOUT.write(body.byteslice(3..))
end
def result(id, value)
emit({'id' => id, 'result' => value})
end
def range(row, start, finish)
{'start' => {'line' => row, 'character' => start},
'end' => {'line' => row, 'character' => finish}}
end
def location(path, row = 0, column = 3)
{'uri' => uri(path), 'range' => range(row, column, column + (row == 0 ? 1 : 0))}
end
def diagnostics(document, message, old: false, unversioned: false)
diagnostic = {'range' => old ? range(999, 0, 0) : range(0, 2, 4),
'severity' => 1, 'message' => message}
items = [diagnostic]
items << {'range' => range(1, 0, 0), 'severity' => 4, 'message' => 'newline boundary'} unless old
params = {'uri' => document['uri'], 'diagnostics' => items}
params['version'] = document['version'] - (old ? 1 : 0) unless unversioned
emit({'method' => 'textDocument/publishDiagnostics', 'params' => params})
end
initialize_id = nil
initialized_sent = false
held = []
semantic_held = []
position_held = []
completion_held = []
definition_count = 0
source_uri = uri(source)
documents = {}
control_id = nil
loop do
if initialize_id && !initialized_sent && (!['gated', 'hover-gated', 'completion-gated', 'controlled-gated'].include?(mode) || File.exist?(release))
sync = case mode
when 'numeric' then 1
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
when 'incremental' then {'openClose' => true, 'change' => 2, 'save' => {'includeText' => true}}
else {'openClose' => true, 'change' => 1, 'save' => {'includeText' => true}}
end
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}}
capabilities['completionProvider'] = {'resolveProvider' => true} unless ['completion-unsupported', 'controlled-unsupported'].include?(mode)
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
response = {'capabilities' => capabilities}
response['serverInfo'] = {'name' => "Fixture λ #{File.basename(log, '.jsonl')}"}
case mode
when 'name-absent' then response.delete('serverInfo')
when 'name-empty' then response['serverInfo']['name'] = ''
when 'name-blank' then response['serverInfo']['name'] = ' '
when 'name-controls' then response['serverInfo']['name'] = "bad\e\nname"
when 'name-edge-controls' then response['serverInfo']['name'] = "\nFixture\n"
when 'name-type' then response['serverInfo']['name'] = 42
when 'initialize-error'
emit({'id' => initialize_id, 'error' => {'code' => -32603, 'message' => 'fixture initialization failed'}})
when 'initialize-invalid' then response['capabilities'] = nil
end
result(initialize_id, response) unless mode == 'initialize-error'
initialized_sent = true
end
if !held.empty? && File.exist?(release) && ['stale', 'hover-stale'].include?(mode)
held.each do |request|
result(request['id'], request['method'] == 'textDocument/hover' ? {'contents' => 'stale hover'} : location(target))
end
held.clear
emit({'method' => 'window/showMessage', 'params' => {'type' => 3, 'message' => 'stale response complete'}})
end
control = release + '.control'
if File.exist?(control)
command = JSON.parse(File.read(control))
# A comparison can start another root-scoped server with these same fixture args.
# Only the process owning the referenced document/request consumes its gate.
ready = case command.fetch('action')
when 'publish', 'clear'
documents.key?(command.fetch('uri', source_uri))
when 'definition-result', 'hover-result'
method = command['action'] == 'definition-result' ? 'textDocument/definition' : 'textDocument/hover'
position_held.count { |request| request['method'] == method && request['params']['textDocument']['uri'] == command.fetch('request_uri') } > command.fetch('index', 0)
when 'semantic-result'
semantic_held.length > command.fetch('index', 0)
when 'completion-result'
completion_held.length > command.fetch('index', 0)
else
true
end
if command['id'] != control_id && ready
control_id = command.fetch('id')
case command.fetch('action')
when 'publish', 'clear'
document = documents.fetch(command.fetch('uri', source_uri))
params = {'uri' => document['uri'], 'version' => command.fetch('version', document['version']),
'diagnostics' => command['action'] == 'clear' ? [] :
[{'range' => range(0, 2, 4), 'severity' => 2, 'message' => command.fetch('message')}]}
emit({'method' => 'textDocument/publishDiagnostics', 'params' => params})
when 'definition-result', 'hover-result'
method = command['action'] == 'definition-result' ? 'textDocument/definition' : 'textDocument/hover'
indices = position_held.each_index.select { |index| position_held[index]['method'] == method && position_held[index]['params']['textDocument']['uri'] == command.fetch('request_uri') }
request = position_held.delete_at(indices.fetch(command.fetch('index', 0)))
response = if command.key?('response')
command.fetch('response')
else
target_uri = command.key?('uri') ? command.fetch('uri') : uri(command.fetch('path'))
row, column = command.fetch('row', 0), command.fetch('column', 0)
{'result' => {'uri' => target_uri, 'range' => range(row, column, command.fetch('end_column', column))}}
end
emit({'id' => request.fetch('id')}.merge(response))
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 'completion-result'
request = completion_held.delete_at(command.fetch('index', 0))
raise 'no held completion 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'
STDOUT.write("Content-Length: nope\r\n\r\n")
else
raise 'unknown fixture action'
end
# The response proves the worker consumed every preceding publication.
emit({'id' => "control-#{control_id}", 'method' => 'workspace/configuration',
'params' => {'items' => []}})
end
end
next unless IO.select([STDIN], nil, nil, 0.01)
headers = {}
loop do
line = STDIN.gets("\r\n")
exit if line.nil?
break if line == "\r\n"
name, value = line.chomp("\r\n").split(':', 2)
headers[name.downcase] = value.strip
end
length = Integer(headers.fetch('content-length'))
body = STDIN.read(length)
raise 'client body length mismatch' unless body && body.bytesize == length
frame = JSON.parse(body)
File.open(log, 'a') { |file| file.puts(JSON.generate(frame)); file.flush }
case frame['method']
when 'initialize'
initialize_id = frame.fetch('id')
emit({'id' => 'pre-init-message', 'method' => 'window/showMessageRequest',
'params' => {'type' => 3, 'message' => 'server initialization', 'actions' => []}})
when 'initialized'
emit({'id' => 'settings', 'method' => 'workspace/configuration',
'params' => {'items' => [{'section' => 'rust-analyzer'}, {'section' => 'missing'}]}})
emit({'id' => 0, 'method' => '$/unsupported', 'params' => {}})
when 'textDocument/didOpen'
document = frame.fetch('params').fetch('textDocument')
documents[document['uri']] = document
if document['uri'] == source_uri && !mode.start_with?('controlled')
if mode == 'malformed'
STDOUT.write("Content-Length: nope\r\n\r\n")
elsif mode == 'dead'
exit! 7
else
diagnostics(document, "initial\e diagnostic", unversioned: mode == 'unversioned')
end
end
when 'textDocument/didChange'
document = frame.fetch('params').fetch('textDocument')
documents[document['uri']] = document
if document['uri'] == source_uri && !['malformed', 'dead'].include?(mode) && !mode.start_with?('controlled')
diagnostics(document, 'obsolete must drop', old: true)
diagnostics(document, "revision #{document['version']}")
end
when 'textDocument/definition'
definition_count += 1
case mode
when 'controlled-diff'
position_held << frame
when 'stale'
held << frame
when 'superseded'
held << frame
if held.length == 2
result(held[0]['id'], location(alternate))
result(held[1]['id'], location(target))
held.clear
end
when 'remote'
result(frame['id'], {'uri' => 'https://example.invalid/source.rs', 'range' => range(0, 0, 1)})
when 'bounds'
result(frame['id'], location(target, 999, 0))
when 'missing'
result(frame['id'], location(target + '.missing'))
else
case definition_count
when 1
result(frame['id'], location(target))
when 2
link = {'targetUri' => uri(target), 'targetRange' => range(1, 0, 0),
'targetSelectionRange' => range(1, 0, 0)}
result(frame['id'], [link, location(alternate, 0, 0)])
else
result(frame['id'], nil)
end
end
when 'textDocument/hover'
if mode == 'controlled-diff'
position_held << frame
elsif mode == 'hover-stale'
held << frame
elsif mode == 'hover-superseded'
held << frame
if held.length == 2
result(held[0]['id'], {'contents' => 'old hover'})
result(held[1]['id'], {'contents' => 'new hover'})
held.clear
end
else
contents = case mode
when 'hover-markdown' then {'kind' => 'markdown', 'value' => "**Header**\n```rust\nfn main() {}\n```"}
when 'hover-marked' then ['explanation', {'language' => 'rust', 'value' => "fn main() {\n}"}, 'tail']
when 'hover-string' then "one\nsecond"
when 'hover-empty' then nil
when 'hover-malformed' then {'kind' => 'html', 'value' => 'bad'}
else {'kind' => 'plaintext', 'value' => "hover first\r\n😀e\u{301}\tsecond\e\u{85}\nlast"}
end
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
result(frame['id'], hover)
end
when 'textDocument/completion'
if mode.include?('completion-controlled') || mode == 'controlled-diff'
completion_held << frame
elsif mode == 'completion-empty'
result(frame['id'], nil)
else
result(frame['id'], [
{'label' => 'alps', 'detail' => 'second candidate', 'sortText' => '2'},
{'label' => 'alpha', 'detail' => 'first candidate', 'sortText' => '1'},
{'label' => 'unrelated', 'sortText' => '0'},
{'label' => 'alSnippet', 'insertTextFormat' => 2, 'insertText' => 'al${1:snippet}'},
{'label' => 'alCommand', 'command' => {'title' => 'unsupported', 'command' => 'fix'}},
{'label' => 'alIndented', 'insertTextMode' => 2}
])
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'
exit 0
end
end
"#;
struct Fixture {
root: PathBuf,
source: PathBuf,
target: PathBuf,
alternate: PathBuf,
log: PathBuf,
release: PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-lsp-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&root).unwrap();
let root = fs::canonicalize(root).unwrap();
let fixture = Self {
source: root.join("source space λ.rs"),
target: root.join("target space 😀.rs"),
alternate: root.join("alternate.rs"),
log: root.join("protocol.jsonl"),
release: root.join("release"),
root,
};
fs::write(&fixture.source, "A😀e\u{301}Z\n").unwrap();
fs::write(&fixture.target, "disk\n").unwrap();
fs::write(&fixture.alternate, "other\n").unwrap();
fixture
}
fn open(&self, mode: &str) -> EditorSession {
let server = self.root.join("server.rb");
fs::write(&server, SERVER).unwrap();
let mut command = if mode == "spawn" {
json!([self.root.join("unavailable-language-server")])
} else {
json!([
"ruby",
server,
mode,
self.log,
self.release,
self.source,
self.target,
self.alternate
])
};
if mode == "name-absent" {
command
.as_array_mut()
.unwrap()
.insert(0, json!("/usr/bin/env"));
}
let options = json!({
"extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] },
"command": command,
"initialization_options": {"fixture": true},
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
});
let config = format!(
"Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n",
serde_json::to_string(&options.to_string()).unwrap(),
);
let config_path = self.root.join("config.rb");
fs::write(&config_path, config).unwrap();
EditorSession::open(
Some(self.source.clone()),
vec!["text".into(), "diagnostics".into()],
Some(&config_path),
)
.unwrap()
}
fn worker_log(&self, worker: usize) -> PathBuf {
if worker == 0 {
self.log.clone()
} else {
self.root.join(format!("protocol-{worker}.jsonl"))
}
}
fn worker_release(&self, worker: usize) -> PathBuf {
if worker == 0 {
self.release.clone()
} else {
self.root.join(format!("release-{worker}"))
}
}
fn worker_frames(&self, worker: usize) -> Vec<Value> {
fs::read_to_string(self.worker_log(worker))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
fn worker_count(&self, worker: usize, method: &str) -> usize {
self.worker_frames(worker)
.iter()
.filter(|frame| frame["method"] == method)
.count()
}
fn open_stack(&self, modes: [&str; 2], overlap: bool) -> EditorSession {
self.open_stack_at(modes, overlap, &self.source)
}
fn open_stack_at(
&self,
modes: [&str; 2],
overlap: bool,
path: &std::path::Path,
) -> EditorSession {
let server = self.root.join("server.rb");
fs::write(&server, SERVER).unwrap();
let command = |worker: usize, mode: &str| {
if mode == "spawn" {
json!([self.root.join("unavailable-language-server")])
} else {
json!([
"ruby",
server,
mode,
self.worker_log(worker),
self.worker_release(worker),
self.source,
self.target,
self.alternate
])
}
};
let servers: Vec<_> = modes
.into_iter()
.enumerate()
.map(|(worker, mode)| json!({"command": command(worker, mode)}))
.collect();
let mut languages = json!({"python": {"extensions": ["rs"], "servers": servers}});
if overlap {
languages["zzz-other"] =
json!({"extensions": ["rs"], "command": command(2, "controlled")});
}
let config = format!(
"Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n JSON.parse({}).each {{ |language, options| config.lsp[language] = options }}\n config.bind 'K', config.core.lsp.hover\nend\n",
serde_json::to_string(&languages.to_string()).unwrap(),
);
let config_path = self.root.join("config.rb");
fs::write(&config_path, config).unwrap();
EditorSession::open(
Some(path.to_path_buf()),
vec!["text".into(), "diagnostics".into()],
Some(&config_path),
)
.unwrap()
}
fn control(&self, worker: usize, id: u64, mut command: Value) {
command["id"] = json!(id);
let path = self.worker_release(worker).with_extension("control");
let temporary = path.with_extension("pending");
fs::write(&temporary, command.to_string()).unwrap();
fs::rename(temporary, path).unwrap();
}
fn acknowledged(&self, worker: usize, id: u64) -> bool {
self.worker_frames(worker)
.iter()
.any(|frame| frame["id"] == format!("control-{id}") && frame.get("result").is_some())
}
fn frames(&self) -> Vec<Value> {
fs::read_to_string(&self.log)
.unwrap_or_default()
.lines()
// A concurrent final line can be incomplete; the next poll reads it again.
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
fn method_count(&self, method: &str) -> usize {
self.frames()
.iter()
.filter(|frame| frame["method"] == method)
.count()
}
fn release(&self) {
fs::write(&self.release, "release").unwrap();
}
}
fn repository(fixture: &Fixture, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(&fixture.root)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "LSP Diff Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "LSP Diff Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn comparison_text(name: &str, version: &str) -> String {
let mut lines = (0..80)
.map(|row| format!("A😀e\u{301}Z {name}_stable_{row:03}"))
.collect::<Vec<_>>();
for row in [19, 59] {
lines[row] = format!("A😀e\u{301}Z {name}_{version}_{row:03}");
}
if name == "target" && version == "after" {
lines.insert(10, "inserted target line".into());
}
lines.join("\n") + "\n"
}
fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option<String>) {
repository(fixture, &["init", "-q"]);
fs::write(
fixture.root.join(".gitignore"),
"config.rb\nserver.rb\nprotocol*.jsonl\nrelease*\n*.missing\n",
)
.unwrap();
fs::write(&fixture.source, comparison_text("source", "before")).unwrap();
fs::write(&fixture.target, comparison_text("target", "before")).unwrap();
repository(fixture, &["add", "."]);
repository(fixture, &["commit", "-qm", "before"]);
let before = repository(fixture, &["rev-parse", "HEAD"]);
fs::write(&fixture.source, comparison_text("source", "after")).unwrap();
fs::write(&fixture.target, comparison_text("target", "after")).unwrap();
let after = historical.then(|| {
repository(fixture, &["add", "."]);
repository(fixture, &["commit", "-qm", "after"]);
let after = repository(fixture, &["rev-parse", "HEAD"]);
repository(fixture, &["checkout", "--detach", &before]);
fs::write(&fixture.source, "disk is not the historical source\n").unwrap();
fs::write(&fixture.target, "disk is not the historical target\n").unwrap();
after
});
(before, after)
}
fn diff_focus(session: &mut EditorSession, focus: Focus) {
for _ in 0..3 {
if session.editor().diff().unwrap().focus == focus {
return;
}
session.handle(Input::Char('\t'));
}
assert_eq!(session.editor().diff().unwrap().focus, focus);
}
fn open_comparison(
session: &mut EditorSession,
fixture: &Fixture,
before: &str,
after: Option<&str>,
focus: Focus,
) {
session.execute("diff");
until(session, fixture, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
for ch in before.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
if let Some(after) = after {
for ch in after.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
}
session.handle(Input::Enter);
until(session, fixture, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
session.handle(Input::Enter);
diff_focus(session, focus);
}
fn active_lsp_document(session: &mut EditorSession, fixture: &Fixture) -> Value {
let text = session.editor().context().text;
let id = session.editor().buffer_id();
let snapshot = !session.editor().buffers().any(|buffer| buffer.id == id);
let matches_document = |frame: &Value| {
frame["method"] == "textDocument/didOpen"
&& frame["params"]["textDocument"]["text"] == text
&& frame["params"]["textDocument"]["uri"]
.as_str()
.is_some_and(|uri| uri.contains(".vamp-readonly-") == snapshot)
};
until(session, fixture, |_| {
fixture.frames().iter().any(matches_document)
});
fixture
.frames()
.into_iter()
.rev()
.find(matches_document)
.unwrap()["params"]["textDocument"]
.clone()
}
fn request_position(session: &mut EditorSession, fixture: &Fixture, method: &str) {
let count = fixture.method_count(method);
session.execute(if method == "textDocument/hover" {
"lsp.hover"
} else {
"lsp.definition"
});
until(session, fixture, |_| {
fixture.method_count(method) == count + 1
});
}
fn position_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, mut command: Value) {
let method = match command["action"].as_str() {
Some("definition-result") => Some("textDocument/definition"),
Some("hover-result") => Some("textDocument/hover"),
_ => None,
};
if let Some(method) = method {
let request = fixture
.frames()
.into_iter()
.rev()
.find(|frame| frame["method"] == method)
.unwrap();
command["request_uri"] = request["params"]["textDocument"]["uri"].clone();
}
fixture.control(0, id, command);
until(session, fixture, |_| fixture.acknowledged(0, id));
session.poll();
}
fn shown_diff_lines(session: &EditorSession, focus: Focus) -> Vec<usize> {
let diff = session.editor().diff().unwrap();
diff.display_rows
.iter()
.filter_map(|row| match *row {
DisplayRow::Line(index) => match focus {
Focus::Before => diff.rows[index].before,
Focus::After => diff.rows[index].after,
Focus::Files => None,
},
DisplayRow::Fold { .. } => None,
})
.collect()
}
fn palette_action(session: &mut EditorSession, action: &str) {
session.handle(Input::Ctrl('p'));
for ch in action.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
assert!(session.editor().palette().is_none());
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
// Process-isolated deadline only: all semantic ordering uses protocol/file gates.
fn until(
session: &mut EditorSession,
fixture: &Fixture,
predicate: impl Fn(&EditorSession) -> bool,
) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
session.poll();
if predicate(session) {
return;
}
assert!(
Instant::now() < deadline,
"LSP fixture did not reach state: {}\n{}",
session.editor().message(),
serde_json::to_string_pretty(&fixture.frames()).unwrap(),
);
thread::sleep(Duration::from_millis(2));
}
}
fn edit_target(session: &mut EditorSession, fixture: &Fixture) -> usize {
session.execute(&format!("edit {}", fixture.target.display()));
let id = session.editor().buffer_id();
session.handle(Input::Char('i'));
session.handle(Input::Paste("😀e\u{301}".into()));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.execute("buffer 1");
id
}
#[test]
fn lsp_ready_names_wait_for_initialization_and_follow_only_the_focused_buffer() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-gated");
assert!(session.editor().lsp_names().is_empty());
assert_eq!(session.editor().lsp_label(), "");
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
assert!(session.editor().lsp_names().is_empty());
let unserved = fixture.root.join("notes.txt");
fs::write(&unserved, "unserved\n").unwrap();
session.execute(&format!("edit {}", unserved.display()));
let unserved_id = session.editor().buffer_id();
fixture.release();
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didOpen") == 1
});
session.poll();
assert!(session.editor().lsp_names().is_empty());
assert_eq!(session.editor().lsp_label(), "");
session.execute("buffer 1");
assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]);
assert_eq!(session.editor().lsp_label(), "Fixture λ protocol");
let revision = session.editor().text_revision();
let cursor = session.editor().cursor();
session.poll();
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().mode(), Mode::Normal);
session.execute(&format!("buffer {unserved_id}"));
assert!(session.editor().lsp_names().is_empty());
assert_eq!(session.editor().lsp_label(), "");
session.execute("buffer 1");
assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]);
fixture.control(0, 1, json!({"action": "die"}));
until(&mut session, &fixture, |session| {
session.editor().message().contains("server stopped")
});
assert!(session.editor().lsp_names().is_empty());
assert_eq!(session.editor().lsp_label(), "");
}
#[test]
fn lsp_readiness_alone_requests_a_redraw_without_document_or_message_changes() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-gated");
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
assert!(session.editor().lsp_names().is_empty());
let message = session.editor().message().to_owned();
let revision = session.editor().text_revision();
let cursor = session.editor().cursor();
fixture.release();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let changed = session.poll();
if !session.editor().lsp_names().is_empty() {
assert!(changed, "readiness must redraw even without plugin effects");
break;
}
assert!(
Instant::now() < deadline,
"fixture server did not become ready"
);
thread::sleep(Duration::from_millis(2));
}
assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]);
assert_eq!(session.editor().message(), message);
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().diagnostics().count(), 0);
}
#[test]
fn lsp_ready_names_fall_back_to_executable_only_for_missing_or_invalid_server_names() {
for mode in [
"name-absent",
"name-empty",
"name-blank",
"name-controls",
"name-edge-controls",
"name-type",
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
!session.editor().lsp_names().is_empty()
});
let expected = if mode == "name-absent" { "env" } else { "ruby" };
assert_eq!(session.editor().lsp_names(), [expected], "{mode}");
assert_eq!(session.editor().lsp_label(), expected, "{mode}");
}
}
#[test]
fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
let fixture = Fixture::new();
let mut session = fixture.open("basic");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
&& fixture
.frames()
.iter()
.any(|frame| frame["id"] == "settings")
&& fixture.frames().iter().any(|frame| frame["id"] == 0)
});
let diagnostic = session.editor().diagnostics().next().unwrap();
assert_eq!(diagnostic.start.row, 0);
assert_eq!(diagnostic.start.column, 1); // inside emoji => whole emoji
assert_eq!(diagnostic.end.column, 3); // inside e + combining accent => whole cluster
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
assert_eq!(diagnostic.message, "initial diagnostic");
assert_eq!(session.editor().diagnostics().nth(1).unwrap().start.row, 1);
assert!(session.editor().diagnostic_at_cursor().is_none());
session.handle(Input::Right);
assert!(session.editor().diagnostic_at_cursor().is_some());
let frames = fixture.frames();
let initialize = &frames[0];
assert_eq!(initialize["method"], "initialize");
assert_eq!(
initialize["params"]["capabilities"]["general"]["positionEncodings"],
json!(["utf-16"])
);
assert_eq!(
initialize["params"]["initializationOptions"],
json!({"fixture": true})
);
assert_eq!(
initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"],
true
);
assert_eq!(
initialize["params"]["workspaceFolders"][0]["uri"],
initialize["params"]["rootUri"]
);
let open_index = frames
.iter()
.position(|frame| frame["method"] == "textDocument/didOpen")
.unwrap();
let initialized_index = frames
.iter()
.position(|frame| frame["method"] == "initialized")
.unwrap();
assert!(initialized_index < open_index);
assert_eq!(
frames[open_index]["params"]["textDocument"]["languageId"],
"fixture-language"
);
assert_eq!(
frames[open_index]["params"]["textDocument"]["text"],
"A😀e\u{301}Z\n"
);
assert!(
frames[open_index]["params"]["textDocument"]["uri"]
.as_str()
.unwrap()
.contains("%20")
);
assert!(
frames[open_index]["params"]["textDocument"]["uri"]
.as_str()
.unwrap()
.contains("%CE%BB")
);
assert_eq!(
frames
.iter()
.find(|frame| frame["id"] == "pre-init-message")
.unwrap()["result"],
Value::Null
);
assert_eq!(
frames
.iter()
.find(|frame| frame["id"] == "settings")
.unwrap()["result"],
json!([{"cargo": {"allFeatures": true}}, null])
);
assert_eq!(
frames.iter().find(|frame| frame["id"] == 0).unwrap()["error"]["code"],
-32601
);
let target_id = edit_target(&mut session, &fixture);
session.handle(Input::Home);
for _ in 0..3 {
session.handle(Input::Right);
}
session.handle(Input::Char(' '));
session.handle(Input::Char('d'));
until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id
});
assert_eq!(session.editor().buffer_count(), 2);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
let definition = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/definition")
.unwrap();
assert_eq!(
definition["params"]["position"],
json!({"line": 0, "character": 5})
);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
session.execute("buffer 1");
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id
});
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
assert!(
session
.editor()
.message()
.contains("first of 2 definitions")
);
session.execute("buffer 1");
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().message().contains("no definition found")
});
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), 1);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 3);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape);
until(&mut session, &fixture, |session| {
session
.editor()
.diagnostics()
.any(|diagnostic| diagnostic.message.starts_with("revision "))
});
assert!(
!session
.editor()
.diagnostics()
.any(|diagnostic| diagnostic.message.contains("obsolete"))
);
session.execute("w");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didSave") == 1
});
let save = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/didSave")
.unwrap();
assert_eq!(save["params"]["text"], "A😀e\u{301}Z!\n");
session.execute("bdelete 1");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didClose") == 1
});
assert_eq!(session.editor().buffer_id(), target_id);
assert!(session.editor().dirty());
}
#[test]
fn lsp_initialization_and_incremental_sync_do_not_block_native_typing() {
let fixture = Fixture::new();
let mut session = fixture.open("gated");
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
assert_eq!(fixture.method_count("textDocument/didOpen"), 0);
session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| {
session.editor().message().contains("server initializing")
});
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let opens: Vec<_> = fixture
.frames()
.into_iter()
.filter(|frame| frame["method"] == "textDocument/didOpen")
.collect();
assert_eq!(opens.len(), 1);
assert_eq!(
opens[0]["params"]["textDocument"]["text"],
"A😀e\u{301}Z!\n"
);
assert_eq!(opens[0]["params"]["textDocument"]["version"], 2);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("incremental");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didChange") == 1
});
let change = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/didChange")
.unwrap();
assert_eq!(
change["params"]["contentChanges"][0]["range"],
json!({"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}})
);
assert_eq!(
change["params"]["contentChanges"][0]["text"],
"A😀e\u{301}Z!\n"
);
assert_eq!(change["params"]["textDocument"]["version"], 2);
}
#[test]
fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
assert_eq!(session.editor().mode(), Mode::Insert);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didChange") == 1
});
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().mode(), Mode::Insert);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
let cursor = session.editor().cursor();
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), cursor);
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.execute(&format!("edit {}", fixture.alternate.display()));
let active_id = session.editor().buffer_id();
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), active_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let source_cursor = session.editor().cursor();
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.execute(&format!("edit {}", fixture.alternate.display()));
let alternate_id = session.editor().buffer_id();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("superseded");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.definition");
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().path() == Some(fixture.target.as_path())
});
assert_eq!(fixture.method_count("textDocument/definition"), 2);
assert_eq!(session.editor().buffer_count(), 2);
assert!(
!session
.editor()
.buffers()
.any(|buffer| buffer.path == Some(fixture.alternate.as_path()))
);
let target_id = session.editor().buffer_id();
let destination = session.editor().cursor();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), destination);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().buffer_count(), 2);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 2);
}
#[test]
fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
for mode in [
"spawn",
"malformed",
"dead",
"initialize-error",
"initialize-invalid",
"unsupported",
"remote",
"bounds",
"missing",
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
if matches!(
mode,
"spawn" | "malformed" | "dead" | "initialize-error" | "initialize-invalid"
) {
until(&mut session, &fixture, |session| {
session.editor().message().contains(if mode == "spawn" {
"failed to start"
} else {
"server stopped"
})
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(session.editor().lsp_names().is_empty(), "{mode}");
assert_eq!(session.editor().lsp_label(), "", "{mode}");
} else {
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains(if mode == "unsupported" {
"does not support"
} else {
"definition failed"
})
});
if mode == "unsupported" {
assert_eq!(fixture.method_count("textDocument/definition"), 0);
}
}
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
let cursor = session.editor().cursor();
let definitions = fixture.method_count("textDocument/definition");
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
assert_eq!(session.editor().cursor(), cursor, "{mode}");
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), definitions);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""], "{mode}");
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""], "{mode}");
}
}
#[test]
fn lsp_optional_configuration_numeric_sync_and_unversioned_diagnostics() {
let fixture = Fixture::new();
let session = EditorSession::open(
Some(fixture.source.clone()),
vec!["text".into(), "diagnostics".into()],
None,
)
.unwrap();
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(!fixture.log.exists());
drop(session);
for mode in ["numeric", "unversioned"] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
until(&mut session, &fixture, |session| {
session
.editor()
.diagnostics()
.any(|diagnostic| diagnostic.message == "revision 2")
});
let change = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/didChange")
.unwrap();
assert!(change["params"]["contentChanges"][0].get("range").is_none());
assert_eq!(change["params"]["textDocument"]["version"], 2);
}
let fixture = Fixture::new();
let mut session = fixture.open("none");
until(&mut session, &fixture, |_| {
fixture
.frames()
.iter()
.any(|frame| frame["id"] == "settings")
});
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute("w");
session.handle(Input::Char(' '));
session.handle(Input::Char('D'));
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("diagnostics update automatically")
});
assert_eq!(fixture.method_count("textDocument/didOpen"), 0);
assert_eq!(fixture.method_count("textDocument/didChange"), 0);
assert_eq!(fixture.method_count("textDocument/didSave"), 0);
}
#[test]
fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
let mut fixture = Fixture::new();
let target = fixture.target.with_extension("txt");
fs::rename(&fixture.target, &target).unwrap();
fixture.target = target;
let mut session = fixture.open("basic");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let target_id = edit_target(&mut session, &fixture);
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id
});
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["disk", ""]);
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
}
#[test]
fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.handle(Input::Right);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().buffer_count(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
}
#[test]
fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() {
let fixture = Fixture::new();
let mut session = fixture.open("irrelevant");
session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| {
session.editor().message().contains("no running server")
});
assert!(!fixture.log.exists());
let custom = fixture.root.join("custom.myl");
fs::write(&custom, "custom language\n").unwrap();
session.execute(&format!("edit {}", custom.display()));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didOpen") == 1
});
let frames = fixture.frames();
let open = frames
.iter()
.find(|frame| frame["method"] == "textDocument/didOpen")
.unwrap();
assert_eq!(
open["params"]["textDocument"]["languageId"],
"fixture-language"
);
assert_eq!(open["params"]["textDocument"]["text"], "custom language\n");
}
#[test]
fn lsp_session_shutdown_finishes_the_server_protocol_before_returning() {
let fixture = Fixture::new();
let mut session = fixture.open("basic");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
drop(session);
assert_eq!(fixture.method_count("shutdown"), 1);
}
#[test]
fn lsp_hover_typed_action_preserves_multiline_content_and_primary_utf16_position() {
for (mode, expected) in [
("hover-plain", "hover first\n😀e\u{301} second \nlast"),
("hover-markdown", "**Header**\n```rust\nfn main() {}\n```"),
("hover-marked", "explanation\n\nfn main() {\n}\n\ntail"),
("hover-string", "one\nsecond"),
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
for _ in 0..3 {
session.handle(Input::Right);
}
let before = session.editor().cursor();
session.handle(Input::Char('K'));
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
assert_eq!(session.editor().hover().unwrap().text(), expected);
assert_eq!(session.editor().cursor(), before);
assert_eq!(session.editor().mode(), Mode::Normal);
assert!(!session.editor().dirty());
let frames = fixture.frames();
let hover = frames
.iter()
.find(|frame| frame["method"] == "textDocument/hover")
.unwrap();
assert_eq!(
hover["params"]["position"],
json!({"line": 0, "character": 5})
);
let initialize = frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap();
assert_eq!(
initialize["params"]["capabilities"]["textDocument"]["hover"]["contentFormat"],
json!(["plaintext", "markdown"])
);
session.handle(Input::Down);
assert_eq!(session.editor().cursor(), before);
assert_eq!(session.editor().hover().unwrap().top, 1);
session.handle(Input::Escape);
assert!(session.editor().hover().is_none());
}
}
#[test]
fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_requests() {
let fixture = Fixture::new();
let mut session = fixture.open("hover-gated");
session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| {
session.editor().message().contains("server initializing")
});
assert_eq!(fixture.method_count("textDocument/hover"), 0);
session.execute("lsp.hover");
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
drop(session);
for input in [Input::Escape, Input::Right, Input::Char('i')] {
let fixture = Fixture::new();
let mut session = fixture.open("hover-stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.hover");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/hover") == 1
});
session.handle(input);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert!(session.editor().hover().is_none());
}
let fixture = Fixture::new();
let mut session = fixture.open("hover-superseded");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.hover");
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
assert_eq!(session.editor().hover().unwrap().text(), "new hover");
}
#[test]
fn lsp_hover_absent_unsupported_and_invalid_results_preserve_native_editing() {
for mode in [
"hover-empty",
"hover-unsupported",
"hover-malformed",
"hover-invalid-range",
] {
let fixture = Fixture::new();
let mut session = fixture.open(mode);
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains(if mode == "hover-empty" {
"no hover information"
} else if mode == "hover-unsupported" {
"does not support hover"
} else {
"hover failed"
})
});
assert!(session.editor().hover().is_none());
assert_eq!(
fixture.method_count("textDocument/hover"),
usize::from(mode != "hover-unsupported")
);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
assert_eq!(fixture.method_count("textDocument/definition"), 0);
}
}
fn diagnostic_messages(session: &EditorSession) -> Vec<&str> {
session
.editor()
.diagnostics()
.map(|diagnostic| diagnostic.message.as_str())
.collect()
}
fn wait_protocol(fixture: &Fixture, predicate: impl Fn() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !predicate() {
assert!(
Instant::now() < deadline,
"fixture protocol gate did not complete: {:?}",
fixture.frames()
);
thread::sleep(Duration::from_millis(2));
}
// Deliberately no session.poll(): outputs may be queued across a rename/close.
}
fn publish(
session: &mut EditorSession,
fixture: &Fixture,
worker: usize,
id: u64,
command: Value,
expected: &[&str],
) {
fixture.control(worker, id, command);
until(session, fixture, |session| {
fixture.acknowledged(worker, id) && diagnostic_messages(session) == expected
});
}
#[test]
fn lsp_python_stack_merges_in_assignment_order_and_routes_positions_only_to_ty() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled-gated", "controlled"], true);
until(&mut session, &fixture, |session| {
fixture.worker_count(0, "initialize") == 1
&& fixture.worker_count(1, "textDocument/didOpen") == 1
&& session.editor().lsp_names() == ["Fixture λ protocol-1"]
});
assert_eq!(fixture.worker_count(0, "textDocument/didOpen"), 0);
assert_eq!(session.editor().lsp_label(), "Fixture λ protocol-1");
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
session.execute("lsp.hover");
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
&& session.editor().lsp_names() == ["Fixture λ protocol", "Fixture λ protocol-1"]
});
assert_eq!(fixture.worker_count(0, "textDocument/hover"), 1);
assert_eq!(fixture.worker_count(1, "textDocument/hover"), 0);
assert_eq!(
session.editor().lsp_label(),
"Fixture λ protocol + Fixture λ protocol-1"
);
for worker in 0..2 {
let frames = fixture.worker_frames(worker);
let initialize = frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap();
assert!(
initialize["params"]["capabilities"]["textDocument"]
.get("diagnostic")
.is_none()
);
let open = frames
.iter()
.find(|frame| frame["method"] == "textDocument/didOpen")
.unwrap();
assert_eq!(open["params"]["textDocument"]["languageId"], "python");
}
publish(
&mut session,
&fixture,
1,
1,
json!({"action": "publish", "message": "ruff"}),
&["ruff"],
);
publish(
&mut session,
&fixture,
0,
1,
json!({"action": "publish", "message": "ty"}),
&["ty", "ruff"],
);
publish(
&mut session,
&fixture,
0,
2,
json!({"action": "publish", "message": "ty replaced"}),
&["ty replaced", "ruff"],
);
publish(
&mut session,
&fixture,
1,
2,
json!({"action": "clear"}),
&["ty replaced"],
);
publish(
&mut session,
&fixture,
1,
3,
json!({"action": "publish", "message": "duplicate"}),
&["ty replaced", "duplicate"],
);
publish(
&mut session,
&fixture,
0,
3,
json!({"action": "publish", "message": "duplicate"}),
&["duplicate", "duplicate"],
);
publish(
&mut session,
&fixture,
0,
4,
json!({"action": "clear"}),
&["duplicate"],
);
publish(
&mut session,
&fixture,
1,
4,
json!({"action": "clear"}),
&[],
);
publish(
&mut session,
&fixture,
0,
5,
json!({"action": "publish", "message": "ty first"}),
&["ty first"],
);
publish(
&mut session,
&fixture,
1,
5,
json!({"action": "publish", "message": "ruff second"}),
&["ty first", "ruff second"],
);
let target_id = edit_target(&mut session, &fixture);
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id
});
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(fixture.worker_count(0, "textDocument/definition"), 1);
assert_eq!(fixture.worker_count(1, "textDocument/definition"), 0);
session.execute("buffer 1");
assert_eq!(diagnostic_messages(&session), ["ty first", "ruff second"]);
session.handle(Input::Char('A'));
session.handle(Input::Char('?'));
session.handle(Input::Escape);
assert!(diagnostic_messages(&session).is_empty());
until(&mut session, &fixture, |_| {
(0..2).all(|worker| {
fixture.worker_frames(worker).iter().any(|frame| {
frame["method"] == "textDocument/didChange"
&& frame["params"]["contentChanges"][0]["text"] == "A😀e\u{301}Z!?\n"
})
})
});
publish(
&mut session,
&fixture,
1,
6,
json!({"action": "publish", "version": 1, "message": "obsolete"}),
&[],
);
publish(
&mut session,
&fixture,
1,
7,
json!({"action": "publish", "message": "new lint"}),
&["new lint"],
);
publish(
&mut session,
&fixture,
0,
6,
json!({"action": "clear"}),
&["new lint"],
);
session.execute("w");
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didSave") == 1)
});
session.execute("bdelete 1");
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didClose") == 1)
});
assert_eq!(session.editor().buffer_id(), target_id);
assert!(
!fixture.worker_log(2).exists(),
"overlapping losing language must never start"
);
drop(session);
for worker in 0..2 {
assert_eq!(fixture.worker_count(worker, "shutdown"), 1);
assert_eq!(fixture.worker_count(worker, "exit"), 1);
}
}
#[test]
fn lsp_python_stack_failures_retire_only_the_offender_in_active_and_parked_buffers() {
for failing in 0..2 {
for failure in ["spawn", "malformed", "die"] {
let fixture = Fixture::new();
fs::write(&fixture.target, "A😀e\u{301}Z\n").unwrap();
let mut modes = ["controlled"; 2];
if failure == "spawn" {
modes[failing] = "spawn";
}
let mut session = fixture.open_stack(modes, true);
let survivor = 1 - failing;
until(&mut session, &fixture, |session| {
fixture.worker_count(survivor, "textDocument/didOpen") == 1
&& if failure == "spawn" {
session.editor().message().contains("failed to start")
} else {
fixture.worker_count(failing, "textDocument/didOpen") == 1
}
});
publish(
&mut session,
&fixture,
survivor,
1,
json!({"action": "publish", "message": "healthy source"}),
&["healthy source"],
);
if failure != "spawn" {
let expected = if failing == 0 {
["failed source", "healthy source"]
} else {
["healthy source", "failed source"]
};
publish(
&mut session,
&fixture,
failing,
1,
json!({"action": "publish", "message": "failed source"}),
&expected,
);
}
session.execute(&format!("edit {}", fixture.target.display()));
until(&mut session, &fixture, |_| {
fixture.worker_count(survivor, "textDocument/didOpen") == 2
&& (failure == "spawn"
|| fixture.worker_count(failing, "textDocument/didOpen") == 2)
});
let target_uri = fixture
.worker_frames(survivor)
.into_iter()
.filter(|frame| frame["method"] == "textDocument/didOpen")
.nth(1)
.unwrap()["params"]["textDocument"]["uri"]
.clone();
publish(
&mut session,
&fixture,
survivor,
2,
json!({"action": "publish", "uri": target_uri, "message": "healthy target"}),
&["healthy target"],
);
if failure != "spawn" {
let expected = if failing == 0 {
["failed target", "healthy target"]
} else {
["healthy target", "failed target"]
};
publish(
&mut session,
&fixture,
failing,
2,
json!({"action": "publish", "uri": target_uri, "message": "failed target"}),
&expected,
);
fixture.control(failing, 3, json!({"action": failure}));
until(&mut session, &fixture, |session| {
session.editor().message().contains("server stopped")
&& diagnostic_messages(session) == ["healthy target"]
});
}
assert_eq!(diagnostic_messages(&session), ["healthy target"]);
let name = if survivor == 0 {
"Fixture λ protocol"
} else {
"Fixture λ protocol-1"
};
assert_eq!(session.editor().lsp_names(), [name]);
assert_eq!(session.editor().lsp_label(), name);
session.execute("buffer 1");
assert_eq!(diagnostic_messages(&session), ["healthy source"]);
assert_eq!(session.editor().lsp_names(), [name]);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
assert!(diagnostic_messages(&session).is_empty());
until(&mut session, &fixture, |_| {
fixture.worker_count(survivor, "textDocument/didChange") == 1
});
publish(
&mut session,
&fixture,
survivor,
3,
json!({"action": "publish", "version": 1, "message": "stale healthy"}),
&[],
);
publish(
&mut session,
&fixture,
survivor,
4,
json!({"action": "publish", "message": "healthy edit"}),
&["healthy edit"],
);
if failing == 0 {
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().message().contains("no running server")
});
assert_eq!(fixture.worker_count(1, "textDocument/definition"), 0);
} else {
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
}
assert!(!fixture.worker_log(2).exists());
}
}
}
#[test]
fn lsp_python_stack_discards_queued_same_revision_diagnostics_after_rename_and_close() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled", "controlled"], false);
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didOpen") == 1)
});
publish(
&mut session,
&fixture,
0,
1,
json!({"action": "publish", "message": "before rename"}),
&["before rename"],
);
let revision = session.editor().context().revision;
for worker in 0..2 {
fixture.control(
worker,
2,
json!({"action": "publish", "message": "queued old path"}),
);
}
wait_protocol(&fixture, || {
(0..2).all(|worker| fixture.acknowledged(worker, 2))
});
let renamed = fixture.root.join("renamed.rs");
session.execute(&format!("w {}", renamed.display()));
assert_eq!(session.editor().context().revision, revision);
until(&mut session, &fixture, |_| {
(0..2).all(|worker| {
fixture.worker_count(worker, "textDocument/didClose") == 1
&& fixture.worker_count(worker, "textDocument/didOpen") == 2
})
});
assert!(diagnostic_messages(&session).is_empty());
let old_uri = fixture
.worker_frames(0)
.into_iter()
.find(|frame| frame["method"] == "textDocument/didOpen")
.unwrap()["params"]["textDocument"]["uri"]
.clone();
let new_uri = fixture
.worker_frames(0)
.into_iter()
.filter(|frame| frame["method"] == "textDocument/didOpen")
.nth(1)
.unwrap()["params"]["textDocument"]["uri"]
.clone();
publish(
&mut session,
&fixture,
0,
3,
json!({"action": "publish", "uri": old_uri, "message": "closed old path"}),
&[],
);
publish(
&mut session,
&fixture,
1,
3,
json!({"action": "publish", "uri": new_uri, "message": "new path"}),
&["new path"],
);
fixture.control(
1,
4,
json!({"action": "publish", "uri": new_uri, "message": "queued closed buffer"}),
);
wait_protocol(&fixture, || fixture.acknowledged(1, 4));
session.execute("bdelete 1");
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didClose") == 2)
});
assert!(!session.editor().has_buffer(1));
assert!(diagnostic_messages(&session).is_empty());
}
#[test]
fn lsp_python_stack_aggregate_message_limit_preserves_the_healthy_contribution() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled", "controlled"], false);
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didOpen") == 1)
});
let large = "x".repeat(2 * 1024 * 1024 + 1);
fixture.control(0, 1, json!({"action": "publish", "message": large}));
until(&mut session, &fixture, |session| {
fixture.acknowledged(0, 1) && session.editor().diagnostics().count() == 1
});
assert_eq!(
session.editor().diagnostics().next().unwrap().message.len(),
large.len()
);
fixture.control(1, 1, json!({"action": "publish", "message": large}));
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("combined diagnostic messages exceed 4 MiB")
});
assert_eq!(session.editor().diagnostics().count(), 1);
assert_eq!(
session.editor().diagnostics().next().unwrap().message.len(),
large.len()
);
publish(
&mut session,
&fixture,
0,
2,
json!({"action": "publish", "message": "healthy replacement"}),
&["healthy replacement"],
);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
}
#[test]
fn lsp_python_stack_seeds_dirty_unserved_documents_before_lazy_startup() {
let mut fixture = Fixture::new();
fixture.target = fixture.root.join("target.txt");
fs::write(&fixture.target, "disk\n").unwrap();
let mut session = fixture.open_stack_at(["controlled", "controlled"], false, &fixture.target);
session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| {
session.editor().message().contains("no running server")
});
assert!(!fixture.worker_log(0).exists());
assert!(!fixture.worker_log(1).exists());
session.handle(Input::Char('i'));
session.handle(Input::Paste("😀e\u{301}".into()));
session.handle(Input::Escape);
session.execute(&format!("edit {}", fixture.source.display()));
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didOpen") == 1)
});
session.execute("lsp.definition");
until(&mut session, &fixture, |session| {
session.editor().buffer_id() == 1
});
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
assert_eq!(fixture.worker_count(0, "textDocument/definition"), 1);
assert_eq!(fixture.worker_count(1, "textDocument/definition"), 0);
for worker in 0..2 {
assert_eq!(
fixture.worker_count(worker, "textDocument/didOpen"),
1,
"the unserved target is cached, not opened on the wire"
);
}
}
#[test]
fn lsp_python_stack_does_not_fall_back_to_ruff_when_ty_lacks_position_capabilities() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled-unsupported", "controlled"], false);
until(&mut session, &fixture, |_| {
(0..2).all(|worker| fixture.worker_count(worker, "textDocument/didOpen") == 1)
});
publish(
&mut session,
&fixture,
1,
1,
json!({"action": "publish", "message": "lint remains available"}),
&["lint remains available"],
);
for command in ["lsp.definition", "lsp.hover"] {
let previous_message = session.editor().message().to_owned();
session.execute(command);
until(&mut session, &fixture, |session| {
session.editor().message() != previous_message
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(diagnostic_messages(&session), ["lint remains available"]);
}
for worker in 0..2 {
assert_eq!(fixture.worker_count(worker, "textDocument/definition"), 0);
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_semantic_colors_survive_edits_and_polling_until_a_current_reply() {
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, 4, 0]}}),
);
let original = session.editor().semantic_tokens().to_vec();
assert_eq!(original[0].kind, SemanticTokenKind::Type);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 2
});
assert_eq!(session.editor().semantic_tokens(), original);
session.handle(Input::Char('?'));
semantic_reply(
&mut session,
&fixture,
2,
json!({"result": {"data": ["stale malformed data"]}}),
);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 3
});
assert_eq!(session.editor().semantic_tokens(), original);
semantic_reply(
&mut session,
&fixture,
3,
json!({"result": {"data": [0, 1, 2, 2, 0]}}),
);
let tokens = session.editor().semantic_tokens();
assert_eq!(tokens[0].kind, SemanticTokenKind::Function);
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
}
#[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
);
}
}
fn completion_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, response: Value) {
fixture.control(
0,
id,
json!({"action": "completion-result", "response": response}),
);
until(session, fixture, |_| fixture.acknowledged(0, id));
// The acknowledgement is sent after the worker queued preceding result updates.
session.poll();
}
#[test]
fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
for (input, expected, mode) in [
(Input::Char('!'), "alpha! tail\n", Mode::Insert),
(Input::Enter, "alpha\n tail\n", Mode::Insert),
(Input::Char('\t'), "alpha\t tail\n", Mode::Insert),
(Input::Escape, "alpha tail\n", Mode::Normal),
] {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
assert!(session.editor().completion().is_none());
assert_eq!(fixture.method_count("textDocument/completion"), 0);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
let popup = session.editor().completion().unwrap();
assert_eq!(
popup
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
["alpha", "alps", "alSnippet"]
);
assert_eq!(popup.items[0].detail.as_deref(), Some("first candidate"));
assert_eq!(popup.selected, 1);
assert_eq!(session.editor().context().text, "alpha tail\n");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 5 });
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "alsnippet tail\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().completion().unwrap().selected, 0);
assert_eq!(session.editor().context().text, "al tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alsnippet tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alpha tail\n");
session.handle(input);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, expected);
assert_eq!(session.editor().mode(), mode);
assert_eq!(fixture.method_count("textDocument/completion"), 1);
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
if mode == Mode::Insert {
session.handle(Input::Escape);
}
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\n");
}
}
#[test]
fn lsp_completion_groups_typed_prefix_and_only_repeats_the_final_candidate() {
let fixture = Fixture::new();
fs::write(&fixture.source, " tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Char('a'));
session.handle(Input::Char('l'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
session.handle(Input::Ctrl('n'));
session.handle(Input::Ctrl('j'));
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, "alps tail\n");
assert_eq!(fixture.method_count("textDocument/completion"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
}
#[test]
fn lsp_completion_primary_only_translates_and_preserves_secondary_carets() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\nal tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Ctrl('e'));
assert_eq!(session.editor().carets().len(), 2);
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
let original_carets = session.editor().carets();
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 2 });
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "alpha",
"textEdit": {"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 2}}, "newText": "alpha"},
"additionalTextEdits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "use X;\n"}]
}]}),
);
assert_eq!(
session.editor().context().text,
"use X;\nal tail\nalpha tail\n"
);
assert_eq!(
session.editor().carets(),
[Cursor { row: 1, column: 2 }, Cursor { row: 2, column: 5 }]
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
assert_eq!(session.editor().carets(), original_carets);
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
let request = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 1, "character": 2})
);
}
#[test]
fn lsp_completion_utf16_text_edit_insert_replace_and_defaults_are_atomic() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
for _ in 0..3 {
session.handle(Input::Right);
}
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {
"isIncomplete": true,
"itemDefaults": {"editRange": {"insert": range(1, 5), "replace": range(1, 6)}, "insertTextFormat": 1, "insertTextMode": 1},
"items": [
{"label": "first", "sortText": "1", "textEdit": {"range": range(1, 5), "newText": "λ"}},
{"label": "second", "sortText": "2", "textEdit": {"insert": range(1, 5), "replace": range(1, 6), "newText": "omega"}, "additionalTextEdits": [
{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "tail"}
]},
{"label": "default", "sortText": "3", "textEditText": "δ"},
{"label": "filtered", "sortText": "0", "filterText": "does-not-match", "textEditText": "bad"}
]
}}),
);
assert_eq!(session.editor().context().text, "AλZ\n");
let popup = session.editor().completion().unwrap();
assert_eq!(popup.items.len(), 3);
assert_eq!(
popup.items[0].edits[0].start,
TextPosition { row: 0, column: 1 }
);
assert_eq!(
popup.items[0].edits[0].end,
TextPosition { row: 0, column: 3 }
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aomega\ntail");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 6 });
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aδ\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
session.handle(Input::Escape);
assert!(!session.editor().dirty());
let frames = fixture.frames();
let request = frames
.iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 0, "character": 5})
);
assert_eq!(request["params"]["context"], json!({"triggerKind": 1}));
let capabilities = &frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap()["params"]["capabilities"]["textDocument"]["completion"];
assert_eq!(capabilities["completionItem"]["snippetSupport"], true);
assert_eq!(capabilities["completionItem"]["insertReplaceSupport"], true);
assert_eq!(
capabilities["completionItem"]["resolveSupport"]["properties"],
json!([])
);
}
#[test]
fn lsp_completion_waits_for_initialize_and_stale_payloads_are_not_decoded() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
assert_eq!(fixture.method_count("textDocument/completion"), 0);
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
assert_eq!(session.editor().context().text, "alpha\n");
drop(session);
for input in [Input::Escape, Input::Right, Input::Char('!')] {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
session.handle(input);
let text = session.editor().context().text;
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {"invalid": "stale"}}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, text);
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
}
}
#[test]
fn lsp_completion_feature_errors_keep_healthy_services() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
let invalid = vec![
json!({"result": {"items": []}}),
json!({"result": [{"detail": "missing label"}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 99), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 2), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 4), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(1, 5), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": {"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "\u{1b}"}}]}),
json!({"result": [{"label": "x", "textEdit": {"insert": range(0, 3), "replace": range(0, 1), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 1), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 0), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 0), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "additionalTextEdits": {}}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:$1}"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:e}\u{301}"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": format!("{}x{}", "${1:".repeat(33), "}".repeat(33))}]}),
json!({"error": {"code": -32603, "message": "completion unavailable"}}),
json!({"error": {"message": "malformed error"}}),
];
for (index, response) in invalid.into_iter().enumerate() {
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 1
});
completion_reply(&mut session, &fixture, index as u64 + 1, response);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape);
assert_eq!(session.editor().diagnostics().count(), 2);
}
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
session.handle(Input::Escape);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
}
#[test]
fn lsp_completion_fallback_filter_text_insert_text_and_empty_shapes() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al suffix\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [
{"label": "display name", "filterText": "alpha", "insertText": "actual", "sortText": "1"},
{"label": "alLabel", "sortText": "2"},
{"label": "does not match", "filterText": "no", "insertText": "must not insert"}
]}),
);
assert_eq!(session.editor().context().text, "actual suffix\n");
assert_eq!(
session.editor().completion().unwrap().items[0].label,
"display name"
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "alLabel suffix\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
for (index, result) in [
Value::Null,
json!([]),
json!({"isIncomplete": false, "items": []}),
]
.into_iter()
.enumerate()
{
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 2
});
completion_reply(
&mut session,
&fixture,
index as u64 + 2,
json!({"result": result}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
}
}
#[test]
fn lsp_completion_superseded_and_preinitialize_changed_sources_never_replace_text() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 2
});
fixture.control(0, 1, json!({"action": "completion-result", "index": 1, "response": {"result": [{"label": "alLatest"}]}}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, 1));
assert_eq!(session.editor().context().text, "alLatest\n");
completion_reply(
&mut session,
&fixture,
2,
json!({"result": {"malformed": "superseded"}}),
);
assert_eq!(session.editor().context().text, "alLatest\n");
assert!(session.editor().completion().is_some());
drop(session);
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
session.handle(Input::Char('!'));
fixture.release();
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didOpen") == 1
});
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al!\n");
assert_eq!(fixture.method_count("textDocument/completion"), 0);
}
#[test]
fn lsp_completion_routes_only_to_first_configured_server_without_fallback() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled-unsupported", "controlled"], false);
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/didOpen") == 1
&& fixture.worker_count(1, "textDocument/didOpen") == 1
});
session.handle(Input::Char('i'));
let previous_message = session.editor().message().to_owned();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().message() != previous_message
});
assert!(session.editor().completion().is_none());
assert_eq!(fixture.worker_count(0, "textDocument/completion"), 0);
assert_eq!(fixture.worker_count(1, "textDocument/completion"), 0);
session.handle(Input::Char('!'));
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
}
#[test]
fn lsp_snippet_preview_mirrors_navigation_final_stop_undo_and_resolved_dot() {
let fixture = Fixture::new();
fs::write(&fixture.source, " tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Char('c'));
session.handle(Input::Char('a'));
let original_carets = session.editor().carets();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2,
"insertText": "call(${1:arg}, $1, ${2|left,right|})$0"
}]}),
);
assert_eq!(
session.editor().context().text,
"call(arg, arg, left) tail\n"
);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca tail\n");
assert_eq!(session.editor().carets(), original_carets);
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(arg, arg, left) tail\n"
);
session.handle(Input::Char('x'));
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(xy, xy, left) tail\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().snippet_selections().len(), 1);
session.handle(Input::BackTab);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('v'));
assert_eq!(session.editor().context().text, "call(v, v, left) tail\n");
session.handle(Input::Char('\t'));
session.handle(Input::Char('r'));
assert_eq!(session.editor().context().text, "call(v, v, r) tail\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 13 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('\t'));
session.handle(Input::Enter);
let resolved = "call(v, v, r)\t\n tail\n";
assert_eq!(session.editor().context().text, resolved);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, resolved);
assert_eq!(fixture.method_count("textDocument/completion"), 1);
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
}
#[test]
fn lsp_snippet_primary_edit_preserves_secondary_carets_and_escape_keeps_defaults() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca tail\nca tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Ctrl('e'));
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
let original = session.editor().carets();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg}, $1)$0",
"additionalTextEdits": [{
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
"newText": "use X;\n"
}]
}]}),
);
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(arg, arg) tail\n"
);
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
assert_eq!(session.editor().carets(), original);
session.handle(Input::Ctrl('n'));
session.handle(Input::Char('x'));
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(x, x) tail\n"
);
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
session.handle(Input::Escape);
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(x, x) tail\n"
);
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
}
#[test]
fn lsp_snippet_defaults_all_edit_shapes_context_and_unsupported_siblings() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca suffix\n// tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let range = json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 2}});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {
"isIncomplete": false,
"itemDefaults": {"editRange": range, "insertTextFormat": 2},
"items": [
{"label": "unsupported transform", "sortText": "0", "textEditText": "${1/a/b/}"},
{"label": "unknown context", "sortText": "0", "textEditText": "$CLIPBOARD"},
{"label": "replacement", "sortText": "1", "textEdit": {"range": range, "newText": "call(${1:λ})\r\n$0"},
"additionalTextEdits": [{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "// $1\n"}]},
{"label": "insert replace", "sortText": "2", "textEdit": {"insert": range, "replace": range, "newText": "call(${1:${2:arg}})$0"}},
{"label": "default text edit", "sortText": "3", "textEditText": "call(${1|one,two|})$0"},
{"label": "default insert text", "sortText": "4", "insertText": "call(${1:${UNKNOWN:${TM_LINE_NUMBER}}})$0"},
{"label": "default label ${1:arg}$0", "sortText": "5"}
]
}}),
);
assert_eq!(
session.editor().context().text,
"call(λ)\n suffix\n// $1\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(arg) suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(one) suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "call(1) suffix\n// tail\n");
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"default label arg suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca suffix\n// tail\n");
session.handle(Input::Escape);
assert!(!session.editor().dirty());
}
#[test]
fn lsp_snippet_escape_preserves_preview_and_ordinary_keys_remain_literal() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg})$0"
}]}),
);
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().context().text, "call(arg)\n");
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('A'));
session.handle(Input::Char('\t'));
session.handle(Input::Enter);
assert_eq!(session.editor().context().text, "call(arg)\t\n\n");
assert_eq!(fixture.method_count("textDocument/completion"), 1);
}
#[test]
fn lsp_snippet_nested_defaults_update_enclosing_mirrors_then_replace_outer() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2,
"insertText": "call(${2:outer ${1:inner}}, $2)$0"
}]}),
);
assert_eq!(
session.editor().context().text,
"call(outer inner, outer inner)\n"
);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('x'));
assert_eq!(session.editor().context().text, "call(outer x, outer x)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(y, y)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 10 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}
#[test]
fn lsp_snippet_ancestry_distinguishes_empty_child_from_adjacent_root() {
for (template, nested) in [("${1:foo${2}}-$3$0", true), ("${1:foo}$2-$3$0", false)] {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": template
}]}),
);
assert_eq!(session.editor().context().text, "foo-\n");
let snippet = session.editor().completion().unwrap().items[0]
.snippet
.as_ref()
.unwrap();
assert_eq!(
snippet.tabstops[1].ancestors,
[if nested { vec![1] } else { vec![] }]
);
session.handle(Input::Char('X'));
assert_eq!(session.editor().context().text, "X-\n");
session.handle(Input::Char('\t'));
if nested {
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
} else {
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "Xy-\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
}
session.handle(Input::Char('z'));
assert_eq!(
session.editor().context().text,
if nested { "X-z\n" } else { "Xy-z\n" }
);
session.handle(Input::Char('\t'));
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}
}
#[test]
fn lsp_snippet_coincident_empty_mirrors_insert_and_delete_each_occurrence() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1}$1)$0"
}]}),
);
assert_eq!(session.editor().context().text, "call()\n");
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('x'));
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(xyxy)\n");
session.handle(Input::Backspace);
assert_eq!(session.editor().context().text, "call(xx)\n");
session.handle(Input::Backspace);
assert_eq!(session.editor().context().text, "call()\n");
session.handle(Input::Char('z'));
assert_eq!(session.editor().context().text, "call(zz)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('\t'));
assert_eq!(session.editor().context().text, "call(zz)\t\n");
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}
#[test]
fn lsp_diff_historical_sides_use_immutable_unicode_documents_for_hover_and_diagnostics() {
for (historical, focus, version) in [
(false, Focus::Before, "before"),
(true, Focus::Before, "before"),
(true, Focus::After, "after"),
] {
let fixture = Fixture::new();
let (before, after) = diff_repository(&fixture, historical);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
let expected = comparison_text("source", version);
assert_eq!(session.editor().context().text, expected);
assert!(!session.editor().diff_editing());
let document = active_lsp_document(&mut session, &fixture);
assert!(
document["uri"]
.as_str()
.unwrap()
.contains(".vamp-readonly-")
);
assert_eq!(document["text"], expected);
for input in [Input::Char('g'), Input::Char('t'), Input::Down, Input::Home] {
session.handle(input);
}
for _ in 0..3 {
session.handle(Input::Right);
}
let cursor = session.editor().cursor();
assert_eq!(cursor, Cursor { row: 19, column: 3 });
request_position(&mut session, &fixture, "textDocument/hover");
let request = fixture
.frames()
.into_iter()
.rev()
.find(|frame| frame["method"] == "textDocument/hover")
.unwrap();
assert_eq!(request["params"]["textDocument"]["uri"], document["uri"]);
assert_eq!(
request["params"]["position"],
json!({"line": 19, "character": 5})
);
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "hover-result",
"response": {"result": {"contents": {"kind": "markdown", "value": "**Historical**\n```rust\nfn old() {}\n```"}}}
}),
);
assert_eq!(
session.editor().hover().unwrap().text(),
"**Historical**\n```rust\nfn old() {}\n```"
);
assert_eq!(session.editor().cursor(), cursor);
session.handle(Input::Escape);
publish(
&mut session,
&fixture,
0,
2,
json!({
"action": "publish", "uri": document["uri"], "message": "snapshot diagnostic"
}),
&["snapshot diagnostic"],
);
let diagnostic = session.editor().diagnostics().next().unwrap();
assert_eq!(diagnostic.start, TextPosition { row: 0, column: 1 });
assert_eq!(diagnostic.end, TextPosition { row: 0, column: 3 });
session.execute("lsp.diagnostics");
let completion_count = fixture.method_count("textDocument/completion");
for input in [
Input::Char('i'),
Input::Ctrl('n'),
Input::Paste("overwrite".into()),
Input::Escape,
Input::Char('x'),
Input::Char('u'),
Input::Char('U'),
] {
session.handle(input);
}
let forbidden = fixture.root.join("snapshot-copy.rs");
session.execute(&format!("save {}", forbidden.display()));
assert!(!forbidden.exists());
// A subsequent native request gates observation of all earlier document actions.
request_position(&mut session, &fixture, "textDocument/hover");
position_reply(
&mut session,
&fixture,
3,
json!({
"action": "hover-result", "response": {"result": null}
}),
);
assert_eq!(session.editor().context().text, expected);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(
fixture.method_count("textDocument/completion"),
completion_count
);
assert!(session.editor().completion().is_none());
assert!(!session.editor().dirty());
assert_eq!(session.editor().tab_count(), 1);
assert!(session.editor().diff().is_some());
}
}
#[test]
fn lsp_diff_working_after_completion_edits_the_shared_buffer_and_keeps_undo() {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
let comparison = session.editor().diff().unwrap().comparison_id;
let buffer = session.editor().buffer_id();
assert!(session.editor().diff_editing());
for input in [
Input::Char('g'),
Input::Char('t'),
Input::Down,
Input::Char('A'),
Input::Paste(" al".into()),
Input::Ctrl('n'),
] {
session.handle(input);
}
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{"label": "alpha"}]}),
);
assert!(
session
.editor()
.context()
.text
.contains("source_after_019 alpha\n")
);
assert!(session.editor().completion().is_some());
assert!(session.editor().dirty());
assert_eq!(session.editor().buffer_id(), buffer);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after").replace("source_after_019\n", "source_after_019 al\n")
);
// Literal paste and the following completion retain their ordinary separate groups.
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
session.handle(Input::Char('U'));
session.handle(Input::Char('U'));
assert!(
session
.editor()
.context()
.text
.contains("source_after_019 alpha\n")
);
diff_focus(&mut session, Focus::Before);
assert_eq!(
session.editor().context().text,
comparison_text("source", "before")
);
diff_focus(&mut session, Focus::After);
assert!(
session
.editor()
.context()
.text
.contains("source_after_019 alpha\n")
);
assert_eq!(
fs::read_to_string(&fixture.source).unwrap(),
comparison_text("source", "after")
);
}
#[test]
fn lsp_diff_definition_routes_exact_changed_lines_in_the_same_comparison() {
for (historical, focus, row, version) in [
(false, Focus::After, 60, "after"),
(false, Focus::Before, 59, "before"),
(true, Focus::After, 60, "after"),
] {
let fixture = Fixture::new();
let (before, after) = diff_repository(&fixture, historical);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
let tab = session.editor().tab_id();
let comparison = session.editor().diff().unwrap().comparison_id;
let source_buffer = session.editor().buffer_id();
let source_cursor = session.editor().cursor();
let source_file = session.editor().diff().unwrap().file;
let source_excerpts = shown_diff_lines(&session, focus);
let files = &session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap()
.files;
let target_index = files
.iter()
.position(|file| file.path.file_name() == fixture.target.file_name())
.unwrap();
assert!(!files[target_index].loaded);
// This canonical alias is deliberately outside the changed-path catalog.
let alias = fixture.root.join(".git/target-alias.rs");
std::os::unix::fs::symlink(&fixture.target, &alias).unwrap();
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": alias, "row": row, "column": 5
}),
);
until(&mut session, &fixture, |session| {
session.editor().diff().is_some_and(|diff| {
diff.file == target_index && diff.selected_file_loaded() && !diff.loading
})
});
assert_eq!(session.editor().tab_id(), tab);
assert_eq!(session.editor().tab_count(), 1);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().diff().unwrap().focus, focus);
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert_eq!(
session.editor().context().text,
comparison_text("target", version)
);
assert!(shown_diff_lines(&session, focus).contains(&row));
request_position(&mut session, &fixture, "textDocument/definition");
let same_row = if focus == Focus::Before { 19 } else { 20 };
position_reply(
&mut session,
&fixture,
2,
json!({
"action": "definition-result", "path": fixture.target, "row": same_row, "column": 3
}),
);
assert_eq!(
session.editor().cursor(),
Cursor {
row: same_row,
column: 2
}
);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().tab_count(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert_eq!(session.editor().diff().unwrap().file, target_index);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), source_buffer);
assert_eq!(session.editor().cursor(), source_cursor);
assert_eq!(session.editor().diff().unwrap().file, source_file);
assert_eq!(session.editor().diff().unwrap().focus, focus);
assert_eq!(shown_diff_lines(&session, focus), source_excerpts);
palette_action(&mut session, "core.jump_forward");
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert_eq!(session.editor().diff().unwrap().file, target_index);
palette_action(&mut session, "core.jump_forward");
assert_eq!(
session.editor().cursor(),
Cursor {
row: same_row,
column: 2
}
);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().tab_id(), tab);
assert_eq!(session.editor().tab_count(), 1);
}
}
#[test]
fn lsp_diff_external_definition_jump_history_restores_the_exact_comparison_view() {
for (historical, focus) in [
(false, Focus::Before),
(false, Focus::After),
(true, Focus::Before),
(true, Focus::After),
] {
let fixture = Fixture::new();
let external = Fixture::new();
let (before, after) = diff_repository(&fixture, historical);
let mut session = fixture.open("controlled-diff");
session.execute(&format!("edit {}", external.source.display()));
session.handle(Input::Char('i'));
session.handle(Input::Paste("dirty ".into()));
session.handle(Input::Escape);
let external_buffer = session.editor().buffer_id();
let external_text = session.editor().context().text;
session.execute("buffer 1");
let sibling = session.editor().pane_id();
let sibling_buffer = session.editor().buffer_id();
session.execute("pane.split_vertical");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
if !historical && focus == Focus::After {
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
}
let source_tab = session.editor().tab_id();
let source_pane = session.editor().pane_id();
let source_buffer = session.editor().buffer_id();
let source_cursor = session.editor().cursor();
let source_text = session.editor().context().text;
let source_dirty = session.editor().dirty();
let layout = format!("{:?}", session.editor().pane_layout());
let diff = session.editor().diff().unwrap();
let comparison = diff.comparison_id;
let file = diff.file;
let excerpts = shown_diff_lines(&session, focus);
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": external.source, "row": 0, "column": 11
}),
);
let external_tab = session.editor().tab_id();
let external_pane = session.editor().pane_id();
assert_ne!(external_tab, source_tab);
assert_eq!(session.editor().buffer_id(), external_buffer);
for _ in 0..2 {
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().tab_id(), source_tab);
assert_eq!(session.editor().pane_id(), source_pane);
assert_eq!(session.editor().buffer_id(), source_buffer);
assert_eq!(session.editor().cursor(), source_cursor);
assert_eq!(session.editor().context().text, source_text);
assert_eq!(session.editor().dirty(), source_dirty);
assert_eq!(format!("{:?}", session.editor().pane_layout()), layout);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().diff().unwrap().file, file);
assert_eq!(session.editor().diff().unwrap().focus, focus);
assert_eq!(shown_diff_lines(&session, focus), excerpts);
assert_eq!(
session.editor().pane_buffer_id(sibling),
Some(sibling_buffer)
);
palette_action(&mut session, "core.jump_forward");
assert_eq!(session.editor().tab_id(), external_tab);
assert_eq!(session.editor().pane_id(), external_pane);
assert_eq!(session.editor().buffer_id(), external_buffer);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 9 });
assert_eq!(session.editor().context().text, external_text);
assert!(session.editor().dirty());
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().tab_count(), 2);
assert!(session.editor().tab_is_diff(0));
assert!(!session.editor().tab_is_diff(1));
}
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
session.execute("tab.previous");
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().cursor(), source_cursor);
if source_dirty {
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
}
}
}
#[test]
fn lsp_diff_unchanged_context_and_omitted_symbols_open_shared_ordinary_tabs() {
for row in [18, 39] {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
for input in [
Input::Char('g'),
Input::Char('t'),
Input::Down,
Input::Char('A'),
Input::Char('!'),
Input::Escape,
] {
session.handle(input);
}
if row == 18 {
for input in [
Input::Char('g'),
Input::Char('t'),
Input::Right,
Input::Right,
Input::Right,
] {
session.handle(input);
}
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
}
let text = session.editor().context().text;
let source = session.editor().buffer_id();
let cursor = session.editor().cursor();
let tab = session.editor().tab_id();
let pane = session.editor().pane_id();
let layout = format!("{:?}", session.editor().pane_layout());
let comparison = session.editor().diff().unwrap().comparison_id;
let excerpts = shown_diff_lines(&session, Focus::After);
assert_eq!(excerpts.contains(&row), row == 18);
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": fixture.source, "row": row, "column": 5
}),
);
let ordinary_tab = session.editor().tab_id();
assert_eq!(session.editor().tab_count(), 2);
assert_ne!(session.editor().tab_id(), tab);
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().buffer_id(), source);
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert_eq!(session.editor().context().text, text);
assert!(session.editor().dirty());
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().tab_id(), tab);
assert_eq!(session.editor().pane_id(), pane);
assert_eq!(format!("{:?}", session.editor().pane_layout()), layout);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(shown_diff_lines(&session, Focus::After), excerpts);
palette_action(&mut session, "core.jump_forward");
assert_eq!(session.editor().tab_id(), ordinary_tab);
assert_eq!(session.editor().buffer_id(), source);
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().tab_count(), 2);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().tab_id(), tab);
assert_eq!(session.editor().cursor(), cursor);
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
session.execute("tab.next");
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
}
}
#[test]
fn lsp_diff_external_dirty_target_opens_a_tab_without_disturbing_panes_or_comparison() {
let fixture = Fixture::new();
let external = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
session.execute(&format!("edit {}", external.source.display()));
session.handle(Input::Char('i'));
session.handle(Input::Paste("dirty ".into()));
session.handle(Input::Escape);
let dirty = session.editor().buffer_id();
let text = session.editor().context().text;
session.execute("buffer 1");
session.execute("pane.split_vertical");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
let tab = session.editor().tab_id();
let pane = session.editor().pane_id();
let layout = format!("{:?}", session.editor().pane_layout());
let comparison = session.editor().diff().unwrap().comparison_id;
let cursor = session.editor().cursor();
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": external.source, "row": 0, "column": 11
}),
);
assert_eq!(session.editor().tab_count(), 2);
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().buffer_id(), dirty);
assert_eq!(session.editor().context().text, text);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 9 });
assert!(session.editor().dirty());
assert_eq!(
fs::read_to_string(&external.source).unwrap(),
"A😀e\u{301}Z\n"
);
session.execute("tab.previous");
assert_eq!(session.editor().tab_id(), tab);
assert_eq!(session.editor().pane_id(), pane);
assert_eq!(format!("{:?}", session.editor().pane_layout()), layout);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
session.execute("tab.next");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
}
#[test]
fn lsp_diff_unchanged_snapshot_uri_opens_an_immutable_tab_that_outlives_the_comparison() {
for (focus, version) in [(Focus::Before, "before"), (Focus::After, "after")] {
let fixture = Fixture::new();
let (before, after) = diff_repository(&fixture, true);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
let document = active_lsp_document(&mut session, &fixture);
let snapshot = session.editor().buffer_id();
let comparison = session.editor().diff().unwrap().comparison_id;
let cursor = session.editor().cursor();
let source_tab = session.editor().tab_id();
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "uri": document["uri"], "row": 39, "column": 5
}),
);
let ordinary_tab = session.editor().tab_id();
assert_eq!(session.editor().tab_count(), 2);
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().buffer_id(), snapshot);
assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 });
assert_eq!(
session.editor().context().text,
comparison_text("source", version)
);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().tab_id(), source_tab);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
palette_action(&mut session, "core.jump_forward");
assert_eq!(session.editor().tab_id(), ordinary_tab);
assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 });
assert!(session.editor().diff().is_none());
// Close the source without traversing away from the timeline's destination.
session.execute("tab.previous");
session.execute("diff.close");
session.execute("tab.next");
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().tab_id(), source_tab);
assert_eq!(session.editor().buffer_id(), snapshot);
assert_eq!(session.editor().cursor(), cursor);
assert!(session.editor().diff().is_none());
palette_action(&mut session, "core.jump_forward");
assert_eq!(session.editor().tab_id(), ordinary_tab);
assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 });
assert_eq!(session.editor().tab_count(), 2);
assert!(!session.editor().tab_is_diff(0));
assert!(!session.editor().tab_is_diff(1));
for input in [
Input::Char('i'),
Input::Paste("bad".into()),
Input::Escape,
Input::Char('x'),
] {
session.handle(input);
}
assert_eq!(session.editor().buffer_id(), snapshot);
assert_eq!(
session.editor().context().text,
comparison_text("source", version)
);
assert!(!session.editor().dirty());
assert_eq!(
fs::read_to_string(&fixture.source).unwrap(),
"disk is not the historical source\n"
);
}
}
#[test]
fn lsp_diff_failed_definitions_are_atomic_for_tabs_views_text_and_history() {
for failure in ["missing", "utf8", "row", "range-order", "remote"] {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
for input in [Input::Char('A'), Input::Char('!'), Input::Escape] {
session.handle(input);
}
let text = session.editor().context().text;
let buffer = session.editor().buffer_id();
let cursor = session.editor().cursor();
let pane = session.editor().pane_id();
let comparison = session.editor().diff().unwrap().comparison_id;
let excerpts = shown_diff_lines(&session, Focus::After);
let buffers = session.editor().buffer_count();
let bad = fixture.root.join(".git/invalid.rs");
fs::write(&bad, [0xff, 0xfe]).unwrap();
let command = match failure {
"missing" => {
json!({"action": "definition-result", "path": fixture.root.join("absent.rs.missing")})
}
"utf8" => json!({"action": "definition-result", "path": bad}),
"row" => json!({"action": "definition-result", "path": fixture.alternate, "row": 999}),
"range-order" => {
json!({"action": "definition-result", "path": fixture.alternate, "column": 3, "end_column": 1})
}
"remote" => {
json!({"action": "definition-result", "uri": "https://example.invalid/source.rs"})
}
_ => unreachable!(),
};
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(&mut session, &fixture, 1, command);
assert_eq!(session.editor().tab_count(), 1, "{failure}");
assert_eq!(session.editor().buffer_count(), buffers, "{failure}");
assert_eq!(session.editor().buffer_id(), buffer, "{failure}");
assert_eq!(session.editor().pane_id(), pane, "{failure}");
assert_eq!(session.editor().cursor(), cursor, "{failure}");
assert_eq!(session.editor().context().text, text, "{failure}");
assert_eq!(
session.editor().diff().unwrap().comparison_id,
comparison,
"{failure}"
);
assert_eq!(
shown_diff_lines(&session, Focus::After),
excerpts,
"{failure}"
);
assert!(session.editor().dirty());
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), buffer);
assert_eq!(session.editor().cursor(), cursor);
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
}
}
#[test]
fn lsp_diff_selector_and_files_focus_are_not_document_action_targets() {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
let document = active_lsp_document(&mut session, &fixture);
session.execute("diff");
until(&mut session, &fixture, |session| {
session.editor().diff().unwrap().catalog.is_some()
});
for stage in [Phase::Before, Phase::After] {
assert_eq!(session.editor().diff().unwrap().phase, stage);
for command in ["lsp.definition", "lsp.hover", "lsp.diagnostics"] {
session.execute(command);
}
if stage == Phase::Before {
for ch in before.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
}
session.handle(Input::Enter);
}
until(&mut session, &fixture, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
assert_eq!(session.editor().diff().unwrap().focus, Focus::Files);
for command in ["lsp.definition", "lsp.hover", "lsp.diagnostics"] {
session.execute(command);
}
position_reply(
&mut session,
&fixture,
1,
json!({"action": "clear", "uri": document["uri"]}),
);
assert_eq!(fixture.method_count("textDocument/definition"), 0);
assert_eq!(fixture.method_count("textDocument/hover"), 0);
assert!(session.editor().hover().is_none());
assert_eq!(session.editor().tab_count(), 1);
assert_eq!(session.editor().diff().unwrap().focus, Focus::Files);
session.handle(Input::Enter);
request_position(&mut session, &fixture, "textDocument/hover");
position_reply(
&mut session,
&fixture,
2,
json!({
"action": "hover-result", "response": {"result": {"contents": "text side works"}}
}),
);
assert_eq!(session.editor().hover().unwrap().text(), "text side works");
}
#[test]
fn lsp_diff_delayed_position_replies_cannot_revive_a_departed_source() {
for departure in [
"caret",
"revision",
"side",
"file",
"tab",
"inactive-tab",
"pane",
"close",
"replace",
] {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
request_position(&mut session, &fixture, "textDocument/definition");
request_position(&mut session, &fixture, "textDocument/hover");
match departure {
"caret" => {
session.handle(Input::Right);
session.handle(Input::Left);
}
"revision" => {
for input in [
Input::Char('A'),
Input::Char('!'),
Input::Escape,
Input::Char('u'),
] {
session.handle(input);
}
}
"side" => {
diff_focus(&mut session, Focus::Before);
diff_focus(&mut session, Focus::After);
}
"file" => {
diff_focus(&mut session, Focus::Files);
session.handle(Input::Ctrl('u'));
for ch in fixture
.target
.file_name()
.unwrap()
.to_str()
.unwrap()
.chars()
{
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
until(&mut session, &fixture, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
}
"tab" => {
session.execute("tab.new");
session.execute("tab.previous");
}
"inactive-tab" => session.execute("tab.new"),
"pane" => session.execute("pane.split_vertical"),
"close" => session.execute("diff.close"),
"replace" => session.execute("diff"),
_ => unreachable!(),
}
let tab = session.editor().tab_id();
let tabs = session.editor().tab_count();
let pane = session.editor().pane_id();
let buffer = session.editor().buffer_id();
let cursor = session.editor().cursor();
let text = session.editor().context().text;
let comparison = session.editor().diff().and_then(|diff| diff.comparison_id);
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": fixture.alternate
}),
);
position_reply(
&mut session,
&fixture,
2,
json!({
"action": "hover-result", "response": {"result": {"contents": "stale historical hover"}}
}),
);
assert_eq!(session.editor().tab_id(), tab, "{departure}");
assert_eq!(session.editor().tab_count(), tabs, "{departure}");
assert_eq!(session.editor().pane_id(), pane, "{departure}");
assert_eq!(session.editor().buffer_id(), buffer, "{departure}");
assert_eq!(session.editor().cursor(), cursor, "{departure}");
assert_eq!(session.editor().context().text, text, "{departure}");
assert_eq!(
session.editor().diff().and_then(|diff| diff.comparison_id),
comparison,
"{departure}"
);
assert!(session.editor().hover().is_none(), "{departure}");
}
}
#[test]
fn lsp_diff_added_deleted_and_renamed_targets_follow_their_own_side_coordinates() {
for (kind, focus) in [
("added", Focus::After),
("deleted", Focus::Before),
("renamed", Focus::Before),
("renamed-dirty", Focus::Before),
("renamed", Focus::After),
] {
let fixture = Fixture::new();
diff_repository(&fixture, false);
fs::write(&fixture.source, comparison_text("source", "before")).unwrap();
fs::write(&fixture.target, comparison_text("target", "before")).unwrap();
let deleted = fixture.root.join("z_deleted.rs");
let old = fixture.root.join("z_old.rs");
let renamed = fixture.root.join("z_renamed.rs");
let added = fixture.root.join("z_added.rs");
fs::write(&deleted, "A😀e\u{301}Z deleted\n").unwrap();
fs::write(&old, comparison_text("renamed", "before")).unwrap();
repository(&fixture, &["add", "."]);
repository(&fixture, &["commit", "-qm", "old paths"]);
let before = repository(&fixture, &["rev-parse", "HEAD"]);
fs::write(&fixture.source, comparison_text("source", "after")).unwrap();
fs::write(&fixture.target, comparison_text("target", "after")).unwrap();
fs::remove_file(&deleted).unwrap();
fs::rename(&old, &renamed).unwrap();
fs::write(&added, "A😀e\u{301}Z added\n").unwrap();
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, focus);
let comparison = session.editor().diff().unwrap().comparison_id;
if kind.starts_with("renamed") && (focus == Focus::After || kind == "renamed-dirty") {
// Rename discovery is exact-content. Edit only after capturing that catalog.
session.execute("tab.new");
session.execute(&format!("edit {}", renamed.display()));
for input in [
Input::Char('g'),
Input::Char('t'),
Input::Char('5'),
Input::Char('9'),
Input::Char('j'),
Input::Char('A'),
Input::Char('!'),
Input::Escape,
] {
session.handle(input);
}
assert!(session.editor().lines()[59].ends_with('!'));
session.execute("tab.previous");
}
let tabs = session.editor().tab_count();
let renamed_before = kind == "renamed" && focus == Focus::Before;
let (path, row, expected) = match (kind, focus) {
("added", _) => (&added, 0, "A😀e\u{301}Z added\n".to_owned()),
("deleted", _) => (&deleted, 0, "A😀e\u{301}Z deleted\n".to_owned()),
("renamed-dirty", Focus::Before) => (&old, 59, comparison_text("renamed", "before")),
("renamed", Focus::Before) => (&old, 59, comparison_text("renamed", "before")),
("renamed", Focus::After) => (
&renamed,
59,
comparison_text("renamed", "before")
.replace("renamed_before_059\n", "renamed_before_059!\n"),
),
_ => unreachable!(),
};
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": path, "row": row, "column": 5
}),
);
until(&mut session, &fixture, |session| {
session.editor().context().text == expected
&& if renamed_before {
session.editor().diff().is_none()
} else {
session.editor().diff().is_some_and(|diff| !diff.loading)
}
});
assert_eq!(
session.editor().tab_count(),
tabs + usize::from(renamed_before),
"{kind} {focus:?}"
);
if renamed_before {
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
let document = active_lsp_document(&mut session, &fixture);
assert!(document["uri"].as_str().unwrap().ends_with("/z_old.rs"));
session.handle(Input::Char('i'));
session.handle(Input::Paste("overwrite".into()));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, expected);
assert!(!session.editor().dirty());
session.execute("tab.previous");
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
continue;
}
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().diff().unwrap().focus, focus);
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
let diff = session.editor().diff().unwrap();
let file = &diff.comparison.as_ref().unwrap().files[diff.file];
assert!(file.loaded);
if kind.starts_with("renamed") {
assert_eq!(
file.old_path.as_deref(),
Some(std::path::Path::new("z_old.rs"))
);
assert_eq!(file.path, std::path::Path::new("z_renamed.rs"));
if focus == Focus::Before {
let document = active_lsp_document(&mut session, &fixture);
assert!(document["uri"].as_str().unwrap().ends_with("/z_old.rs"));
}
}
}
}
#[test]
fn lsp_diff_superseded_position_requests_only_apply_the_newest_result() {
for method in ["textDocument/definition", "textDocument/hover"] {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
let comparison = session.editor().diff().unwrap().comparison_id;
request_position(&mut session, &fixture, method);
request_position(&mut session, &fixture, method);
let action = if method == "textDocument/definition" {
"definition-result"
} else {
"hover-result"
};
let newest = if method == "textDocument/definition" {
json!({"action": action, "index": 1, "path": fixture.source, "row": 59, "column": 5})
} else {
json!({"action": action, "index": 1, "response": {"result": {"contents": "newest diff hover"}}})
};
position_reply(&mut session, &fixture, 1, newest);
position_reply(
&mut session,
&fixture,
2,
json!({
"action": action, "response": {"result": {"invalid": "stale payload must not be decoded"}}
}),
);
assert_eq!(session.editor().tab_count(), 1);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
if method == "textDocument/definition" {
assert_eq!(session.editor().cursor(), Cursor { row: 59, column: 3 });
} else {
assert_eq!(
session.editor().hover().unwrap().text(),
"newest diff hover"
);
}
assert_eq!(fixture.method_count(method), 2);
}
}
#[test]
fn lsp_diff_replaced_snapshot_diagnostics_do_not_leak_into_the_new_comparison() {
for focus in [Focus::Before, Focus::After] {
let fixture = Fixture::new();
let (before, after) = diff_repository(&fixture, true);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
let old = active_lsp_document(&mut session, &fixture);
session.execute("diff.close");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
until(&mut session, &fixture, |_| {
fixture.frames().iter().any(|frame| {
frame["method"] == "textDocument/didOpen"
&& frame["params"]["textDocument"]["text"] == old["text"]
&& frame["params"]["textDocument"]["uri"] != old["uri"]
})
});
let current = active_lsp_document(&mut session, &fixture);
assert_ne!(old["uri"], current["uri"]);
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "publish", "uri": old["uri"], "message": "obsolete comparison"
}),
);
assert_eq!(diagnostic_messages(&session), Vec::<&str>::new());
publish(
&mut session,
&fixture,
0,
2,
json!({
"action": "publish", "uri": current["uri"], "message": "current comparison"
}),
&["current comparison"],
);
assert_eq!(
session.editor().context().text,
current["text"].as_str().unwrap()
);
}
}
#[test]
fn lsp_diff_pending_completion_cannot_apply_edits_to_a_readonly_side() {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
for input in [
Input::Char('A'),
Input::Paste(" al".into()),
Input::Ctrl('n'),
] {
session.handle(input);
}
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let working = session.editor().context().text;
session.handle(Input::Escape);
diff_focus(&mut session, Focus::Before);
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "bad", "textEdit": {"range": {
"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}
}, "newText": "overwrite historical text"}
}]}),
);
assert_eq!(
session.editor().context().text,
comparison_text("source", "before")
);
assert!(session.editor().completion().is_none());
assert!(!session.editor().dirty());
diff_focus(&mut session, Focus::After);
assert_eq!(session.editor().context().text, working);
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
comparison_text("source", "after")
);
}
#[test]
fn lsp_diff_definition_preserves_normal_overlong_utf16_column_clamping() {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": fixture.alternate, "row": 0, "column": 999
}),
);
assert_eq!(session.editor().tab_count(), 2);
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
}
#[test]
fn lsp_diff_symbol_ranges_route_by_changed_body_and_reveal_the_exact_selection() {
for shape in [
"link-overlap",
"location-overlap",
"link-unchanged",
"exclusive-end",
] {
let fixture = Fixture::new();
let (before, _) = diff_repository(&fixture, false);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, None, Focus::After);
let document = active_lsp_document(&mut session, &fixture);
let comparison = session.editor().diff().unwrap().comparison_id;
let (start, end, selection) = match shape {
"link-overlap" | "location-overlap" => (39, 60, 39),
"link-unchanged" => (30, 40, 39),
"exclusive-end" => (40, 59, 40),
_ => unreachable!(),
};
assert!(!shown_diff_lines(&session, Focus::After).contains(&selection));
let range = json!({
"start": {"line": start, "character": 0},
"end": {"line": end, "character": 0}
});
let result = if shape == "location-overlap" {
json!({"uri": document["uri"], "range": range})
} else {
json!([{
"targetUri": document["uri"], "targetRange": range,
"targetSelectionRange": {
"start": {"line": selection, "character": 5},
"end": {"line": selection, "character": 6}
}
}])
};
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "response": {"result": result}
}),
);
let overlapping = matches!(shape, "link-overlap" | "location-overlap");
assert_eq!(
session.editor().tab_count(),
if overlapping { 1 } else { 2 },
"{shape}"
);
assert_eq!(
session.editor().cursor(),
Cursor {
row: selection,
column: if shape == "location-overlap" { 0 } else { 3 }
},
"{shape}"
);
if overlapping {
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert!(shown_diff_lines(&session, Focus::After).contains(&selection));
} else {
assert!(session.editor().diff().is_none());
session.execute("tab.previous");
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert!(!shown_diff_lines(&session, Focus::After).contains(&selection));
}
}
}
#[test]
fn lsp_diff_lazy_unchanged_historical_target_opens_its_revision_in_an_ordinary_tab() {
for (focus, row, version) in [(Focus::Before, 39, "before"), (Focus::After, 40, "after")] {
let fixture = Fixture::new();
let (before, after) = diff_repository(&fixture, true);
let mut session = fixture.open("controlled-diff");
open_comparison(&mut session, &fixture, &before, after.as_deref(), focus);
let source = session.editor().buffer_id();
let cursor = session.editor().cursor();
let comparison = session.editor().diff().unwrap().comparison_id;
let file = session.editor().diff().unwrap().file;
request_position(&mut session, &fixture, "textDocument/definition");
position_reply(
&mut session,
&fixture,
1,
json!({
"action": "definition-result", "path": fixture.target, "row": row, "column": 5
}),
);
until(&mut session, &fixture, |session| {
session.editor().tab_count() == 2
});
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().cursor(), Cursor { row, column: 3 });
assert_eq!(
session.editor().context().text,
comparison_text("target", version)
);
let document = active_lsp_document(&mut session, &fixture);
assert!(
document["uri"]
.as_str()
.unwrap()
.contains(".vamp-readonly-")
);
session.handle(Input::Char('i'));
session.handle(Input::Paste("bad".into()));
session.handle(Input::Escape);
assert_eq!(
session.editor().context().text,
comparison_text("target", version)
);
assert!(!session.editor().dirty());
session.execute("tab.previous");
assert_eq!(session.editor().buffer_id(), source);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().diff().unwrap().comparison_id, comparison);
assert_eq!(session.editor().diff().unwrap().file, file);
}
}