From 23378e0cc29af56a3fc92c87f6e15924ec19c631 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:01:07 +0800 Subject: [PATCH] test(lsp): smoke-test parameter info, structure, auto-import, Optimize Imports and file moves (#1355) The language smoke now also checks, per language and against the server the app starts: parameter info in a call being typed, the Structure view's symbols, completing a name that isn't imported yet (the import comes with it, through the item's edits or its command), Optimize Imports on a typed import, and moving a file updating the files that reference it (where the server supports it). --- crates/ide-host/tests/language_smoke.rs | 284 +++++++++++++++++++++++- 1 file changed, 282 insertions(+), 2 deletions(-) diff --git a/crates/ide-host/tests/language_smoke.rs b/crates/ide-host/tests/language_smoke.rs index da76a6e62..8e32b39ee 100644 --- a/crates/ide-host/tests/language_smoke.rs +++ b/crates/ide-host/tests/language_smoke.rs @@ -1,6 +1,7 @@ //! Language smoke tests: the everyday code intelligence of each language — completion, go to -//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and -//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the +//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename, +//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a +//! file — on its `fixtures/` project, through the `ops` the app's commands call and the //! language server the app starts by default. //! //! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs @@ -68,6 +69,35 @@ struct Checks { indent: Indent, /// Renaming the symbol to the new name edits these files. rename: (At, &'static str, &'static [&'static str]), + /// Parameter info in a call being typed: a signature naming this parameter. + signature: (Typed, &'static str), + /// The Structure view of a file: these symbols, at any depth. + outline: (&'static str, &'static [&'static str]), + /// Completing a name the file doesn't import yet also imports it. + auto_import: AutoImport, + /// Optimize Imports, with an import typed after a line. + organize_imports: Option<(Typed, Imports)>, + /// Moving a file updates its references: (file, new path, a file that then contains this). + file_move: Option<(&'static str, &'static str, &'static str, &'static str)>, +} + +#[derive(Clone, Copy, Debug)] +struct AutoImport { + /// The line typed, ending in the start of the name. + typed: Typed, + /// The completion item. + label: &'static str, + /// What the item's import adds to the file. + result: &'static str, +} + +/// What Optimize Imports does with the typed import. +#[derive(Clone, Copy, Debug)] +enum Imports { + /// Removes it (unused). + Removed, + /// Sorts it before this line. + Before(&'static str), } #[derive(Clone, Copy, Debug)] @@ -432,6 +462,146 @@ impl Smoke { } } + async fn signature(&self, call: Typed, parameter: &str) { + let (doc, pos, before) = self.type_line(call).await; + self.retry("parameter info", self.warm_up, || async { + let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await; + match &found { + Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()), + _ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::>()))), + } + }) + .await; + self.restore(doc, &before).await; + } + + async fn outline(&self, file: &str, symbols: &[&str]) { + let (doc, _) = self.doc(file).await; + fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet) { + for item in items { + out.insert(item.name.clone()); + names(&item.children, out); + } + } + self.retry("structure", self.warm_up, || async { + let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await; + let mut shown = BTreeSet::new(); + if let Ok(items) = &found { + names(items, &mut shown); + } + if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) } + }) + .await; + } + + async fn auto_import(&self, a: AutoImport) { + let (doc, pos, before) = self.type_line(a.typed).await; + assert!(!before.contains(a.result), "{a:?}: the file already contains the result"); + self.retry("auto-import completion", self.warm_up, || async { + let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await; + let items = match &list { + Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::>(), + Err(e) => return Err(format!("{e}")), + }; + let text = self.doc(a.typed.file).await.1; + let mut imports = Vec::new(); + for item in items { + // Servers compute the import when the item is resolved (on selection). + let item = if item.additional_edits.is_empty() { + ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item) + } else { + item + }; + let mut document = ide_text::Document::new(&text); + if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) { + return Ok(()); + } + // Or accepting it runs a server command that edits the file (kotlin-lsp). + if let Some(command) = item.command.clone() { + let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await; + let text = self.doc(a.typed.file).await.1; + if ran.is_ok() && text.contains(a.result) { + return Ok(()); + } + } + imports.push(format!("{:?} {:?}", item.additional_edits, item.command)); + } + let labels: Vec = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap(); + Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label)) + }) + .await; + self.restore(doc, &before).await; + } + + async fn organize_imports(&self, import: Typed, expect: Imports) { + let (doc, _, before) = self.type_line(import).await; + let typed = import.text.trim(); + let organized = |text: &str| match expect { + Imports::Removed => !text.lines().any(|l| l.trim() == typed), + Imports::Before(line) => { + let at = |needle: &str| text.lines().position(|l| l.trim() == needle); + matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b) + } + }; + self.retry("optimize imports", self.warm_up, || async { + let text = self.doc(import.file).await.1; + // The whole file, as Optimize Imports asks (positions past the end fail on jdtls). + let last = text.split('\n').count() as u32 - 1; + let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or("")))); + let kind = "source.organizeImports".to_string(); + let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await; + let actions: Vec<_> = match &found { + Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(), + Err(e) => return Err(format!("{e}")), + }; + let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else { + return Err(format!( + "no {kind} action: {:?}", + found.map(|a| a.into_iter().map(|a| a.title).collect::>()) + )); + }; + ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?; + let text = self.doc(import.file).await.1; + if organized(&text) { Ok(()) } else { Err(text) } + }) + .await; + self.restore(doc, &before).await; + } + + async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) { + // The servers know the referencing file (opened, as in an editor). + let _ = self.doc(referencing).await; + let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }]; + let edit = self + .retry("move file", self.warm_up, || async { + let found = + ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await; + let want = self.path(referencing); + match &found { + Ok(e) + if e.edit + .files + .iter() + .any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) => + { + Ok(e.edit.clone()) + } + _ => Err(found), + } + }) + .await; + ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into()) + .await + .unwrap_or_else(|e| self.fail("move file", e)); + if !self.path(to).is_file() { + self.fail("move file", format_args!("{to} doesn't exist after the move")); + } + let text = self.doc(referencing).await.1; + if !text.contains(result) { + self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}")); + } + } + async fn run(&self, c: &Checks) { self.definition(c.definition.0, c.definition.1).await; self.references(c.references.0, c.references.1).await; @@ -440,7 +610,17 @@ impl Smoke { self.quick_fix(c.quick_fix).await; self.intention(c.intention.0, c.intention.1).await; self.format(c.format.0, c.indent, c.format.1).await; + self.signature(c.signature.0, c.signature.1).await; + self.outline(c.outline.0, c.outline.1).await; + self.auto_import(c.auto_import).await; + if let Some((import, expect)) = c.organize_imports { + self.organize_imports(import, expect).await; + } self.rename(c.rename.0, c.rename.1, c.rename.2).await; + // Last: it moves a file the other checks use. + if let Some((from, to, referencing, result)) = c.file_move { + self.file_move(from, to, referencing, result).await; + } let shutdown = self.host.project(WINDOW).unwrap().lsp.clone(); let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await; } @@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks { "formatPrice", &["src/index.ts", "src/money.ts"], ), + signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"), + outline: ("src/cart.ts", &["Cart", "add", "total"]), + auto_import: AutoImport { + typed: typed("src/index.ts", "const total = cart.total();", " addMon"), + label: "addMoney", + result: "addMoney", + }, + organize_imports: Some(( + typed( + "src/index.ts", + "import { cents, formatMoney } from \"./money.ts\";", + "import { readFileSync } from \"node:fs\";", + ), + Imports::Removed, + )), + file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")), }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -521,6 +717,19 @@ const PYTHON: Checks = Checks { "TaskBoard", &["src/tasks/cli.py", "src/tasks/models.py"], ), + signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"), + outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]), + auto_import: AutoImport { + typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"), + label: "datetime", + result: "import datetime", + }, + organize_imports: Some(( + typed("src/tasks/models.py", "from enum import IntEnum", "import abc"), + Imports::Before("import os"), + )), + // basedpyright has no `workspace/willRenameFiles`. + file_move: None, }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -558,6 +767,16 @@ const RUST: Checks = Checks { ), indent: Indent::Spaces(4), rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]), + signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"), + outline: ("src/lib.rs", &["Inventory", "Item", "take"]), + auto_import: AutoImport { + typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"), + label: "HashSet", + result: "use std::collections::HashSet;", + }, + // rust-analyzer has no `source.organizeImports`. + organize_imports: None, + file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")), }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -597,6 +816,16 @@ const GO: Checks = Checks { "Open", &["cmd/server/main.go", "internal/store/store.go"], ), + signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"), + outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]), + auto_import: AutoImport { + typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"), + label: "ToUpper", + result: "\t\"strings\"", + }, + organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)), + // gopls renames packages, not files: moving a Go file changes no references. + file_move: None, }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -653,6 +882,37 @@ const JAVA: Checks = Checks { "submit", &["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"], ), + signature: ( + typed( + "src/main/java/com/example/orders/App.java", + "long revenue = book.revenueCents();", + " book.place(", + ), + "customer", + ), + outline: ( + "src/main/java/com/example/orders/OrderBook.java", + &["OrderBook", "place(String, String, int, long)", "revenueCents()"], + ), + auto_import: AutoImport { + typed: typed( + "src/main/java/com/example/orders/App.java", + "long revenue = book.revenueCents();", + " ArrayLis", + ), + label: "ArrayList", + result: "import java.util.ArrayList;", + }, + organize_imports: Some(( + typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"), + Imports::Removed, + )), + file_move: Some(( + "src/main/java/com/example/orders/OrderBook.java", + "src/main/java/com/example/orders/Ledger.java", + "src/main/java/com/example/orders/App.java", + "Ledger book", + )), }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks { "find", &["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"], ), + signature: ( + typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("), + "isbn", + ), + outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]), + auto_import: AutoImport { + typed: typed( + "src/main/kotlin/com/example/library/App.kt", + "val library = Library()", + " val stamp = LocalDateTi", + ), + label: "LocalDateTime", + result: "import java.time.LocalDateTime", + }, + organize_imports: Some(( + typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"), + Imports::Removed, + )), + // kotlin-lsp has no `workspace/willRenameFiles`. + file_move: None, }; #[tokio::test(flavor = "multi_thread", worker_threads = 4)]