962 lines
33 KiB
Rust
962 lines
33 KiB
Rust
#![cfg(unix)]
|
|
|
|
use std::{
|
|
fs,
|
|
path::PathBuf,
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use serde_json::{Value, json};
|
|
use vamp::{
|
|
editor::{Cursor, Input, Mode},
|
|
plugin::DiagnosticSeverity,
|
|
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 = []
|
|
definition_count = 0
|
|
source_uri = uri(source)
|
|
loop do
|
|
if initialize_id && !initialized_sent && (!['gated', 'hover-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
|
|
result(initialize_id, {'capabilities' => {'positionEncoding' => 'utf-16',
|
|
'textDocumentSync' => sync, 'definitionProvider' => mode == 'unsupported' ? false : {'workDoneProgress' => false},
|
|
'hoverProvider' => mode == 'hover-unsupported' ? false : {'workDoneProgress' => false}}})
|
|
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
|
|
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')
|
|
if document['uri'] == source_uri
|
|
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')
|
|
if document['uri'] == source_uri && !['malformed', 'dead'].include?(mode)
|
|
diagnostics(document, 'obsolete must drop', old: true)
|
|
diagnostics(document, "revision #{document['version']}")
|
|
end
|
|
when 'textDocument/definition'
|
|
definition_count += 1
|
|
case mode
|
|
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 == '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 '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 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
|
|
])
|
|
};
|
|
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.clipboard_mode = 'separate'\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 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();
|
|
}
|
|
}
|
|
|
|
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_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.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::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", ""]);
|
|
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);
|
|
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()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
|
|
for mode in [
|
|
"spawn",
|
|
"malformed",
|
|
"dead",
|
|
"unsupported",
|
|
"remote",
|
|
"bounds",
|
|
"missing",
|
|
] {
|
|
let fixture = Fixture::new();
|
|
let mut session = fixture.open(mode);
|
|
if matches!(mode, "spawn" | "malformed" | "dead") {
|
|
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);
|
|
} 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}");
|
|
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);
|
|
}
|
|
|
|
#[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 });
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|