mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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).
995 lines
41 KiB
Rust
995 lines
41 KiB
Rust
//! 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,
|
|
//! 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
|
|
//! pinned versions and runs one language per job:
|
|
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>`.
|
|
|
|
use ide_core::{ChangeSet, DocId, IdeEvent, Pos, Range, RequestId, TextChange};
|
|
use ide_host::{Host, HostConfig, ops};
|
|
use ide_lsp::types::{FormatOptions, GotoKind};
|
|
use std::collections::BTreeSet;
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
const WINDOW: &str = "main";
|
|
|
|
/// `word` on the first line of `file` that contains `line`.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct At {
|
|
file: &'static str,
|
|
line: &'static str,
|
|
word: &'static str,
|
|
}
|
|
|
|
const fn at(file: &'static str, line: &'static str, word: &'static str) -> At {
|
|
At { file, line, word }
|
|
}
|
|
|
|
/// A line typed after the first line of `file` containing `after`.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Typed {
|
|
file: &'static str,
|
|
after: &'static str,
|
|
text: &'static str,
|
|
}
|
|
|
|
const fn typed(file: &'static str, after: &'static str, text: &'static str) -> Typed {
|
|
Typed { file, after, text }
|
|
}
|
|
|
|
/// One language's checklist. Every check names what it looks for, so a failure says which
|
|
/// operation broke.
|
|
struct Checks {
|
|
fixture: &'static str,
|
|
/// Files written into the project copy before it opens.
|
|
files: &'static [(&'static str, &'static str)],
|
|
/// How long the first answer may take (project import and indexing).
|
|
warm_up: Duration,
|
|
/// Go to definition from the first spot lands on the second.
|
|
definition: (At, At),
|
|
/// Find Usages of the symbol lists usages in these files.
|
|
references: (At, &'static [&'static str]),
|
|
/// Hover shows this text.
|
|
hover: (At, &'static str),
|
|
/// Completion at the end of the typed line offers this item.
|
|
completion: (Typed, &'static str),
|
|
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
|
|
/// file contain the last text.
|
|
quick_fix: QuickFix,
|
|
/// With the word selected, this intention is offered with an edit or command.
|
|
intention: (At, &'static str),
|
|
/// Formatting turns the typed line into the expected one.
|
|
format: (Typed, &'static str),
|
|
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)]
|
|
struct QuickFix {
|
|
typed: Typed,
|
|
diagnostic: &'static str,
|
|
title: &'static str,
|
|
result: &'static str,
|
|
}
|
|
|
|
/// The editor's indentation, sent with a format request.
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum Indent {
|
|
Spaces(u32),
|
|
Tabs,
|
|
}
|
|
|
|
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
|
|
|
struct Smoke {
|
|
language: &'static str,
|
|
host: Arc<Host>,
|
|
root: PathBuf,
|
|
events: Events,
|
|
warm_up: Duration,
|
|
_tmp: tempfile::TempDir,
|
|
}
|
|
|
|
fn fixture_dir(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(name)
|
|
}
|
|
|
|
fn copy_dir(from: &Path, to: &Path) {
|
|
std::fs::create_dir_all(to).unwrap();
|
|
for entry in std::fs::read_dir(from).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let name = entry.file_name();
|
|
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
|
|
continue;
|
|
}
|
|
let target = to.join(&name);
|
|
if entry.file_type().unwrap().is_dir() {
|
|
copy_dir(&entry.path(), &target);
|
|
} else {
|
|
std::fs::copy(entry.path(), target).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn utf16_len(s: &str) -> u32 {
|
|
s.encode_utf16().count() as u32
|
|
}
|
|
|
|
/// The 0-based line index and text of the first line containing `needle`.
|
|
fn find_line<'a>(text: &'a str, needle: &str, what: &str) -> (u32, &'a str) {
|
|
text.lines()
|
|
.enumerate()
|
|
.find(|(_, l)| l.contains(needle))
|
|
.map(|(i, l)| (i as u32, l))
|
|
.unwrap_or_else(|| panic!("{what}: no line containing {needle:?}"))
|
|
}
|
|
|
|
/// The byte offset of `word` in `line`, not as part of a longer identifier (`parse` in `parser::parse`).
|
|
fn find_word(line: &str, word: &str) -> Option<usize> {
|
|
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
|
line.match_indices(word).map(|(i, _)| i).find(|&i| {
|
|
let before = line[..i].chars().next_back();
|
|
let after = line[i + word.len()..].chars().next();
|
|
!(word.starts_with(ident) && before.is_some_and(ident)) && !(word.ends_with(ident) && after.is_some_and(ident))
|
|
})
|
|
}
|
|
|
|
impl Smoke {
|
|
async fn start(language: &'static str, checks: &Checks) -> Self {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap().join(checks.fixture);
|
|
copy_dir(&fixture_dir(checks.fixture), &root);
|
|
for (path, text) in checks.files {
|
|
let path = root.join(path);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, text).unwrap();
|
|
}
|
|
let events: Events = Arc::default();
|
|
let app = tmp.path().join("app");
|
|
let host = Arc::new(Host::new(HostConfig {
|
|
config_dir: app.join("config"),
|
|
data_dir: app.join("data"),
|
|
local_data_dir: app.join("data"),
|
|
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
|
syntax: Arc::default(),
|
|
sink_for: Box::new({
|
|
let events = events.clone();
|
|
move |_| {
|
|
let events = events.clone();
|
|
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
|
}
|
|
}),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}));
|
|
host.trust.decide(&root, true, false).unwrap();
|
|
host.open_project(WINDOW, &root).unwrap();
|
|
Smoke { language, host, root, events, warm_up: checks.warm_up, _tmp: tmp }
|
|
}
|
|
|
|
fn fail(&self, check: &str, message: impl std::fmt::Display) -> ! {
|
|
let servers = ops::lsp::lsp_servers(&self.host, WINDOW).unwrap_or_default();
|
|
let servers: Vec<String> = servers.iter().map(|s| format!("{} ({:?})", s.name, s.status)).collect();
|
|
panic!("{} — {check}: {message}\nservers: {servers:?}", self.language)
|
|
}
|
|
|
|
/// Retries `f` until it succeeds (servers answer emptily while importing and indexing); on
|
|
/// timeout, fails with its last answer.
|
|
async fn retry<T, D: std::fmt::Debug, F: Future<Output = Result<T, D>>>(
|
|
&self,
|
|
check: &str,
|
|
timeout: Duration,
|
|
mut f: impl FnMut() -> F,
|
|
) -> T {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let last = match f().await {
|
|
Ok(v) => return v,
|
|
Err(last) => last,
|
|
};
|
|
if Instant::now() > deadline {
|
|
self.fail(check, format_args!("timed out after {timeout:?}; last answer: {last:?}"));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
|
|
async fn doc(&self, file: &str) -> (DocId, String) {
|
|
let info = ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap();
|
|
let info = ops::doc::snapshot(&self.host, WINDOW, info.id).await.unwrap();
|
|
(info.id, info.text)
|
|
}
|
|
|
|
async fn locate(&self, at: At) -> (DocId, Pos) {
|
|
let (doc, text) = self.doc(at.file).await;
|
|
let (line, content) = find_line(&text, at.line, at.file);
|
|
let col = find_word(content, at.word).unwrap_or_else(|| panic!("{at:?}: word not on the line"));
|
|
(doc, Pos::new(line, utf16_len(&content[..col])))
|
|
}
|
|
|
|
async fn word_range(&self, at: At) -> (DocId, Range) {
|
|
let (doc, start) = self.locate(at).await;
|
|
(doc, Range::new(start, Pos::new(start.line, start.col + utf16_len(at.word))))
|
|
}
|
|
|
|
/// Types a new line; returns the position at its end and the text before it.
|
|
async fn type_line(&self, t: Typed) -> (DocId, Pos, String) {
|
|
let (doc, text) = self.doc(t.file).await;
|
|
let (line, content) = find_line(&text, t.after, t.file);
|
|
let line_start: usize = text.lines().take(line as usize).map(|l| l.len() + 1).sum();
|
|
let end = utf16_len(&text[..line_start + content.len()]);
|
|
self.replace(doc, end, end, &format!("\n{}", t.text)).await;
|
|
let col = utf16_len(t.text);
|
|
(doc, Pos::new(line + 1, col), text)
|
|
}
|
|
|
|
async fn replace(&self, doc: DocId, from: u32, to: u32, insert: &str) {
|
|
let version = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().version;
|
|
let changes = ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] };
|
|
ops::doc::edit(&self.host, WINDOW, doc, version, changes).unwrap();
|
|
}
|
|
|
|
async fn restore(&self, doc: DocId, text: &str) {
|
|
let current = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text;
|
|
self.replace(doc, 0, utf16_len(¤t), text).await;
|
|
}
|
|
|
|
fn path(&self, file: &str) -> PathBuf {
|
|
self.root.join(file)
|
|
}
|
|
|
|
fn files<P: AsRef<Path>>(&self, paths: impl IntoIterator<Item = P>) -> BTreeSet<String> {
|
|
paths
|
|
.into_iter()
|
|
.filter_map(|p| {
|
|
let p = p.as_ref().strip_prefix(&self.root).ok()?;
|
|
Some(p.to_string_lossy().replace('\\', "/"))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn definition(&self, from: At, to: At) {
|
|
let (doc, pos) = self.locate(from).await;
|
|
let (_, target) = self.locate(to).await;
|
|
let want = self.path(to.file);
|
|
self.retry("go to definition", self.warm_up, || async {
|
|
let found = ops::lsp::lsp_goto(&self.host, WINDOW, doc, pos, GotoKind::Definition).await;
|
|
match &found {
|
|
Ok(locs) if locs.iter().any(|l| *l.path == *want && l.range.start.line == target.line) => Ok(()),
|
|
_ => Err(found),
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn references(&self, of: At, files: &[&str]) {
|
|
let (doc, pos) = self.locate(of).await;
|
|
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
|
self.retry("find usages", self.warm_up, || async {
|
|
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
|
|
match &found {
|
|
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
|
|
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn hover(&self, over: At, shows: &str) {
|
|
let (doc, pos) = self.locate(over).await;
|
|
self.retry("hover", self.warm_up, || async {
|
|
let found = ops::lsp::lsp_hover(&self.host, WINDOW, RequestId(2), doc, pos).await;
|
|
match &found {
|
|
Ok(Some(h)) if h.contents.value.contains(shows) => Ok(()),
|
|
_ => Err(found.map(|h| h.map(|h| h.contents.value))),
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn completion(&self, line: Typed, offers: &str) {
|
|
let (doc, pos, before) = self.type_line(line).await;
|
|
let trigger = line.text.ends_with('.').then(|| ".".to_string());
|
|
self.retry("completion", self.warm_up, || async {
|
|
let found = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(3), doc, pos, trigger.clone()).await;
|
|
match &found {
|
|
Ok(list)
|
|
if list.items.iter().any(|i| i.label == offers || i.filter_text.as_deref() == Some(offers)) =>
|
|
{
|
|
Ok(())
|
|
}
|
|
_ => Err(found.map(|l| l.items.into_iter().take(30).map(|i| i.label).collect::<Vec<_>>())),
|
|
}
|
|
})
|
|
.await;
|
|
self.restore(doc, &before).await;
|
|
}
|
|
|
|
async fn diagnostic(&self, file: &str, message: &str) -> Range {
|
|
let path = self.path(file);
|
|
self.retry("diagnostics", self.warm_up, || async {
|
|
let events = self.events.lock().unwrap();
|
|
let latest = events.iter().rev().filter_map(|e| match e {
|
|
IdeEvent::Diagnostics { path: p, items, source } if **p == *path => Some((source, items)),
|
|
_ => None,
|
|
});
|
|
let mut seen = Vec::new();
|
|
for (source, items) in latest {
|
|
if let Some(d) = items.iter().find(|d| d.message.contains(message)) {
|
|
return Ok(d.range);
|
|
}
|
|
seen.push(format!("{source}: {:?}", items.iter().map(|d| &d.message).collect::<Vec<_>>()));
|
|
}
|
|
Err(seen.into_iter().take(4).collect::<Vec<_>>())
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// The code actions over `range` (all kinds, as the ⌥⏎ popup asks), the one titled `title`.
|
|
async fn action(&self, check: &str, doc: DocId, range: Range, title: &str) -> ide_lsp::types::CodeAction {
|
|
let action = self
|
|
.retry(check, self.warm_up, || async {
|
|
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, range, vec![]).await;
|
|
match found.as_ref().ok().and_then(|a| a.iter().find(|a| a.title.contains(title))) {
|
|
Some(action) => Ok(action.clone()),
|
|
None => Err(found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())),
|
|
}
|
|
})
|
|
.await;
|
|
if action.edit.is_some() || action.command.is_some() {
|
|
return action;
|
|
}
|
|
ops::lsp::lsp_resolve_code_action(&self.host, WINDOW, doc, action)
|
|
.await
|
|
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
|
|
}
|
|
|
|
async fn quick_fix(&self, fix: QuickFix) {
|
|
let (doc, _, before) = self.type_line(fix.typed).await;
|
|
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
|
|
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
|
|
let action = self.action("quick fix", doc, range, fix.title).await;
|
|
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
|
|
.await
|
|
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
|
|
self.retry("quick fix", Duration::from_secs(10), || async {
|
|
let text = self.doc(fix.typed.file).await.1;
|
|
if text.contains(fix.result) { Ok(()) } else { Err(text) }
|
|
})
|
|
.await;
|
|
self.restore(doc, &before).await;
|
|
}
|
|
|
|
async fn intention(&self, on: At, title: &str) {
|
|
let (doc, range) = self.word_range(on).await;
|
|
let action = self.action("intention", doc, range, title).await;
|
|
let edits = action.edit.as_ref().is_some_and(|e| !e.files.is_empty());
|
|
if !edits && action.command.is_none() {
|
|
self.fail("intention", format_args!("{title:?} has neither an edit nor a command: {action:?}"));
|
|
}
|
|
}
|
|
|
|
async fn format(&self, line: Typed, indent: Indent, formatted: &str) {
|
|
let (doc, _, before) = self.type_line(line).await;
|
|
assert!(!before.lines().any(|l| l == formatted), "{line:?}: the file already has {formatted:?}");
|
|
let (tab_size, insert_spaces) = match indent {
|
|
Indent::Spaces(n) => (n, true),
|
|
Indent::Tabs => (4, false),
|
|
};
|
|
let options = FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
|
|
let text = self
|
|
.retry("format", self.warm_up, || async {
|
|
let edits = ops::lsp::lsp_format(&self.host, WINDOW, doc, None, options.clone()).await;
|
|
let text = self.doc(line.file).await.1;
|
|
let mut document = ide_text::Document::new(&text);
|
|
match &edits {
|
|
Ok(edits) if !edits.is_empty() && document.apply_edits(edits).is_ok() => Ok(document.text()),
|
|
_ => Err(edits),
|
|
}
|
|
})
|
|
.await;
|
|
if !text.lines().any(|l| l == formatted) {
|
|
self.fail("format", format_args!("no line {formatted:?} in:\n{text}"));
|
|
}
|
|
self.restore(doc, &before).await;
|
|
}
|
|
|
|
async fn rename(&self, symbol: At, to: &str, files: &[&str]) {
|
|
let (doc, pos) = self.locate(symbol).await;
|
|
let prepared = ops::lsp::lsp_prepare_rename(&self.host, WINDOW, doc, pos).await;
|
|
if !matches!(prepared, Ok(Some(_))) {
|
|
self.fail("rename", format_args!("prepare rename: {prepared:?}"));
|
|
}
|
|
let renamed = ops::lsp::lsp_rename(&self.host, WINDOW, RequestId(5), doc, pos, to.into())
|
|
.await
|
|
.unwrap_or_else(|e| self.fail("rename", e));
|
|
let edited = self.files(renamed.edit.files.iter().filter_map(|f| match f {
|
|
ide_core::FileEdit::Edit { path, .. } => Some(path.clone()),
|
|
_ => None,
|
|
}));
|
|
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
|
if !want.is_subset(&edited) {
|
|
self.fail("rename", format_args!("edits {edited:?}, expected at least {want:?}: {:?}", renamed.edit));
|
|
}
|
|
let mut before = Vec::new();
|
|
for file in files {
|
|
before.push(self.doc(file).await.1.matches(to).count());
|
|
}
|
|
ops::lsp::apply_workspace_edit(&self.host, WINDOW, renamed.edit)
|
|
.await
|
|
.unwrap_or_else(|e| self.fail("rename", format_args!("applying the edit: {e}")));
|
|
for (file, before) in files.iter().zip(before) {
|
|
if self.doc(file).await.1.matches(to).count() <= before {
|
|
self.fail("rename", format_args!("{file} has no new {to:?} after the rename"));
|
|
}
|
|
}
|
|
}
|
|
|
|
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::<Vec<_>>()))),
|
|
}
|
|
})
|
|
.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<String>) {
|
|
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::<Vec<_>>(),
|
|
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<String> = 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::<Vec<_>>())
|
|
));
|
|
};
|
|
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;
|
|
self.hover(c.hover.0, c.hover.1).await;
|
|
self.completion(c.completion.0, c.completion.1).await;
|
|
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;
|
|
}
|
|
}
|
|
|
|
async fn smoke(language: &'static str, checks: Checks) {
|
|
let smoke = Smoke::start(language, &checks).await;
|
|
smoke.run(&checks).await;
|
|
}
|
|
|
|
const TYPESCRIPT: Checks = Checks {
|
|
fixture: "ts-app",
|
|
files: &[],
|
|
warm_up: Duration::from_secs(120),
|
|
definition: (
|
|
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
|
at("src/money.ts", "export function formatMoney", "formatMoney"),
|
|
),
|
|
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
|
|
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
|
|
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
|
|
quick_fix: QuickFix {
|
|
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
|
|
diagnostic: "Cannot find name 'addMoney'",
|
|
title: "import from \"./money.ts\"",
|
|
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
|
|
},
|
|
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
|
|
format: (
|
|
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
|
|
" const shown = { total, count: cart.count }",
|
|
),
|
|
indent: Indent::Spaces(2),
|
|
rename: (
|
|
// From a usage in another file: the declaration is renamed too, not aliased in the import.
|
|
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
|
"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)]
|
|
#[ignore = "language smoke: needs vtsls"]
|
|
async fn typescript() {
|
|
smoke("typescript", TYPESCRIPT).await;
|
|
}
|
|
|
|
const PYTHON: Checks = Checks {
|
|
fixture: "python-app",
|
|
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
|
|
files: &[("ruff.toml", "")],
|
|
warm_up: Duration::from_secs(120),
|
|
definition: (
|
|
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
|
|
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
|
),
|
|
references: (
|
|
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
|
&["src/tasks/cli.py", "src/tasks/models.py"],
|
|
),
|
|
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
|
|
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
|
|
quick_fix: QuickFix {
|
|
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
|
|
diagnostic: "\"Task\" is not defined",
|
|
title: "from .models import Task",
|
|
result: "from .models import Priority, Task, TaskList",
|
|
},
|
|
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
|
|
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
|
|
format: (
|
|
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
|
|
" shown2 = {\"a\": 1, \"b\": 2}",
|
|
),
|
|
indent: Indent::Spaces(4),
|
|
rename: (
|
|
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
|
"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)]
|
|
#[ignore = "language smoke: needs basedpyright and ruff"]
|
|
async fn python() {
|
|
smoke("python", PYTHON).await;
|
|
}
|
|
|
|
const RUST: Checks = Checks {
|
|
fixture: "rust-app",
|
|
files: &[],
|
|
warm_up: Duration::from_secs(180),
|
|
definition: (
|
|
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
|
|
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
|
|
),
|
|
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
|
|
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
|
|
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
|
|
quick_fix: QuickFix {
|
|
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
|
|
typed: typed(
|
|
"src/main.rs",
|
|
"let mut inventory = Inventory::new();",
|
|
" let _blank = parser::ParseError { line: 1 };",
|
|
),
|
|
diagnostic: "missing structure fields",
|
|
title: "Fill struct fields",
|
|
result: "ParseError { line: 1, message",
|
|
},
|
|
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
|
|
format: (
|
|
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
|
|
" let _spaced = (1, 2);",
|
|
),
|
|
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)]
|
|
#[ignore = "language smoke: needs rust-analyzer"]
|
|
async fn rust() {
|
|
smoke("rust", RUST).await;
|
|
}
|
|
|
|
const GO: Checks = Checks {
|
|
fixture: "go-app",
|
|
files: &[],
|
|
warm_up: Duration::from_secs(180),
|
|
definition: (
|
|
at("cmd/server/main.go", "s := store.New()", "New"),
|
|
at("internal/store/store.go", "func New() *Store", "New"),
|
|
),
|
|
references: (
|
|
at("internal/store/store.go", "func New() *Store", "New"),
|
|
&["cmd/server/main.go", "internal/store/store.go"],
|
|
),
|
|
hover: (
|
|
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
|
|
"func (s *store.Store) Search(q string) []store.Note",
|
|
),
|
|
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
|
|
quick_fix: QuickFix {
|
|
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
|
|
diagnostic: "undefined: fmt",
|
|
title: "\"fmt\"",
|
|
result: "\t\"fmt\"",
|
|
},
|
|
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
|
|
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
|
|
indent: Indent::Tabs,
|
|
rename: (
|
|
at("internal/store/store.go", "func New() *Store", "New"),
|
|
"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)]
|
|
#[ignore = "language smoke: needs gopls"]
|
|
async fn go() {
|
|
smoke("go", GO).await;
|
|
}
|
|
|
|
const JAVA: Checks = Checks {
|
|
fixture: "java-app",
|
|
files: &[],
|
|
warm_up: Duration::from_secs(300),
|
|
definition: (
|
|
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
|
|
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
|
),
|
|
references: (
|
|
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
|
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
|
|
),
|
|
hover: (
|
|
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
|
|
"long com.example.orders.OrderBook.revenueCents()",
|
|
),
|
|
completion: (
|
|
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
|
|
"largest",
|
|
),
|
|
quick_fix: QuickFix {
|
|
typed: typed(
|
|
"src/main/java/com/example/orders/App.java",
|
|
"long revenue = book.revenueCents();",
|
|
" List<Order> all = book.byCustomer(\"alice\");",
|
|
),
|
|
diagnostic: "List cannot be resolved to a type",
|
|
title: "Import 'List' (java.util)",
|
|
result: "import java.util.List;",
|
|
},
|
|
intention: (
|
|
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
|
|
"Extract to local variable",
|
|
),
|
|
format: (
|
|
typed(
|
|
"src/main/java/com/example/orders/App.java",
|
|
"long revenue = book.revenueCents();",
|
|
" long doubled=revenue*2;",
|
|
),
|
|
" long doubled = revenue * 2;",
|
|
),
|
|
indent: Indent::Spaces(4),
|
|
rename: (
|
|
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
|
"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)]
|
|
#[ignore = "language smoke: needs jdtls and JDK 21"]
|
|
async fn java() {
|
|
smoke("java", JAVA).await;
|
|
}
|
|
|
|
const KOTLIN: Checks = Checks {
|
|
fixture: "kotlin-gradle",
|
|
files: &[],
|
|
warm_up: Duration::from_secs(420),
|
|
definition: (
|
|
at("src/main/kotlin/com/example/library/App.kt", "val library = Library()", "Library"),
|
|
at("src/main/kotlin/com/example/library/Library.kt", "class Library(", "Library"),
|
|
),
|
|
references: (
|
|
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
|
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
|
),
|
|
hover: (
|
|
at("src/main/kotlin/com/example/library/App.kt", "library.search(\"java\")", "search"),
|
|
"fun search(query: String): List<Book>",
|
|
),
|
|
completion: (
|
|
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library."),
|
|
"lend",
|
|
),
|
|
quick_fix: QuickFix {
|
|
typed: typed(
|
|
"src/main/kotlin/com/example/library/App.kt",
|
|
"val library = Library()",
|
|
" val stamp = LocalDateTime.now()",
|
|
),
|
|
diagnostic: "Unresolved reference",
|
|
title: "Import",
|
|
result: "import java.time.LocalDateTime",
|
|
},
|
|
intention: (
|
|
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
|
|
"block body",
|
|
),
|
|
format: (
|
|
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
|
|
" val pair = listOf(1, 2)",
|
|
),
|
|
indent: Indent::Spaces(4),
|
|
rename: (
|
|
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
|
"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)]
|
|
#[ignore = "language smoke: needs kotlin-lsp, a JDK and Gradle"]
|
|
async fn kotlin() {
|
|
smoke("kotlin", KOTLIN).await;
|
|
}
|