#![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.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 { 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.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 { 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::>(); 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) { 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"]); 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 { 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_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::>(), ["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); } }