diff --git a/.github/workflows/language-smoke.yml b/.github/workflows/language-smoke.yml new file mode 100644 index 00000000..0441fecf --- /dev/null +++ b/.github/workflows/language-smoke.yml @@ -0,0 +1,97 @@ +name: Language smoke +# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics +# with a quick fix, an intention, formatting, rename) per language, against the language +# server the app starts by default: crates/ide-host/tests/language_smoke.rs. +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - "crates/**" + - "languages/**" + - "fixtures/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/language-smoke.yml" + workflow_dispatch: + +concurrency: + group: language-smoke-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + # Pinned so a server release can't fail an unrelated PR; bump them deliberately. + VTSLS: 0.3.0 + TYPESCRIPT: 6.0.3 + BASEDPYRIGHT: 1.40.2 + RUFF: 0.17.3 + GOPLS: v0.24.0 + JDTLS: 1.63.0-202610191322 + KOTLIN_LSP: 263.7084.0 + GRADLE: 8.14.3 + +jobs: + smoke: + name: Language smoke (${{ matrix.language }}) + strategy: + fail-fast: false + matrix: + language: [typescript, python, rust, go, java, kotlin] + runs-on: ubuntu-22.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: { components: "rust-analyzer, rustfmt" } + # One build cache for every language; main saves it. + - uses: Swatinem/rust-cache@v2 + with: + shared-key: language-smoke + save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }} + + - if: matrix.language == 'typescript' + uses: actions/setup-node@v4 + with: { node-version: 22 } + - if: matrix.language == 'typescript' + run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT" + + - if: matrix.language == 'python' + uses: actions/setup-python@v5 + with: { python-version: "3.12" } + - if: matrix.language == 'python' + run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF" + + - if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: { go-version: "1.27", cache: false } + - if: matrix.language == 'go' + run: go install "golang.org/x/tools/gopls@$GOPLS" + + - if: matrix.language == 'java' || matrix.language == 'kotlin' + uses: actions/setup-java@v4 + with: { distribution: temurin, java-version: "21" } + - if: matrix.language == 'java' + run: | + mkdir -p "$RUNNER_TEMP/jdtls" + curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \ + | tar xz -C "$RUNNER_TEMP/jdtls" + echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH" + + # The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH. + - if: matrix.language == 'kotlin' + uses: gradle/actions/setup-gradle@v4 + with: { gradle-version: "${{ env.GRADLE }}" } + - if: matrix.language == 'kotlin' + run: | + mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin" + curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \ + | tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1 + ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - run: cargo test --locked -p ide-host --test language_smoke --no-run + - run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }} diff --git a/AGENTS.md b/AGENTS.md index 425fbc6e..689bbba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them): this worktree's running MCP bridges and actual ports. - `pnpm build` — typecheck + bundle frontend. - `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`. +- `cargo test -p ide-host --test language_smoke -- --ignored --exact ` — code intelligence + on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions). - `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`, `pnpm gen:bindings`. - `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion diff --git a/crates/ide-host/tests/language_smoke.rs b/crates/ide-host/tests/language_smoke.rs new file mode 100644 index 00000000..da76a6e6 --- /dev/null +++ b/crates/ide-host/tests/language_smoke.rs @@ -0,0 +1,714 @@ +//! 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 +//! 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 `. + +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]), +} + +#[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>>; + +struct Smoke { + language: &'static str, + host: Arc, + 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 { + 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 = 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>>( + &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>(&self, paths: impl IntoIterator) -> BTreeSet { + 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 = 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::>())), + } + }) + .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::>())); + } + Err(seen.into_iter().take(4).collect::>()) + }) + .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::>())), + } + }) + .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 = 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 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.rename(c.rename.0, c.rename.1, c.rename.2).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"], + ), +}; + +#[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"], + ), +}; + +#[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"]), +}; + +#[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"], + ), +}; + +#[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 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"], + ), +}; + +#[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", + ), + 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"], + ), +}; + +#[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; +} diff --git a/fixtures/README.md b/fixtures/README.md index c6c97e17..1966e582 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -12,11 +12,16 @@ something to show. | `python-app/` | Python 3.10+, pytest | `python -m tasks.cli` (from `src/`) | `pytest` | `OPENIDEA_FAIL=1 pytest` | unused imports in `models.py` | | `go-app/` | Go 1.22+ | `go run ./cmd/server` | `go test ./...` | `OPENIDEA_FAIL=1 go test ./...` | unused `fmt.Sprintf` result in `internal/store/store.go` | | `kotlin-gradle/` | JDK 21, Gradle 8 (no wrapper) | `gradle run` | `gradle test` | `gradle test -PopenideaFail` | unused variable in `App.kt` | +| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` | | `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` | The fixtures are standalone: `rust-app` declares its own `[workspace]` and `fixtures/eslint.config.js` keeps them out of the IDE's own lint run. +The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion, +navigation, diagnostics, quick fixes, formatting and rename on these projects with real +language servers: when you change a fixture, run it for that language. + ## Setup Work on a **copy** so git and edits never touch this repository: diff --git a/fixtures/java-app/.gitignore b/fixtures/java-app/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/fixtures/java-app/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/fixtures/java-app/pom.xml b/fixtures/java-app/pom.xml new file mode 100644 index 00000000..916e3138 --- /dev/null +++ b/fixtures/java-app/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + com.example + openidea-fixture-orders + 0.1.0 + OpenIDEA QA fixture: a tiny order book + + + 21 + UTF-8 + + false + + + + + org.junit.jupiter + junit-jupiter + 5.11.3 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + ${openideaFail} + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + com.example.orders.App + + + + + diff --git a/fixtures/java-app/src/main/java/com/example/orders/App.java b/fixtures/java-app/src/main/java/com/example/orders/App.java new file mode 100644 index 00000000..80bd50d7 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/orders/App.java @@ -0,0 +1,16 @@ +package com.example.orders; + +public class App { + public static void main(String[] args) { + OrderBook book = new OrderBook(); + book.place("alice", "Mechanical keyboard", 1, 8999); + book.place("bob", "Wireless mouse", 2, 2999); + book.place("alice", "USB-C hub", 1, 3999); + + int unused = 42; // intentional warning: unused variable + // Set a breakpoint here (QA: debugger) and inspect `book`. + long revenue = book.revenueCents(); + System.out.printf("%d orders, revenue %.2f%n", book.size(), revenue / 100.0); + book.largest().ifPresent(o -> System.out.println("Largest: " + o.product())); + } +} diff --git a/fixtures/java-app/src/main/java/com/example/orders/Order.java b/fixtures/java-app/src/main/java/com/example/orders/Order.java new file mode 100644 index 00000000..a082b0c4 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/orders/Order.java @@ -0,0 +1,14 @@ +package com.example.orders; + +/** A customer order: a product, how many and the unit price in cents. */ +public record Order(String customer, String product, int quantity, long unitPriceCents) { + + public Order { + if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive"); + } + + /** The order's total in cents. */ + public long totalCents() { + return unitPriceCents * quantity; + } +} diff --git a/fixtures/java-app/src/main/java/com/example/orders/OrderBook.java b/fixtures/java-app/src/main/java/com/example/orders/OrderBook.java new file mode 100644 index 00000000..07926151 --- /dev/null +++ b/fixtures/java-app/src/main/java/com/example/orders/OrderBook.java @@ -0,0 +1,35 @@ +package com.example.orders; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** Keeps the orders placed so far, in the order they were placed. */ +public class OrderBook { + private final List orders = new ArrayList<>(); + + /** Places an order and returns it. */ + public Order place(String customer, String product, int quantity, long unitPriceCents) { + Order order = new Order(customer, product, quantity, unitPriceCents); + orders.add(order); + return order; + } + + public List byCustomer(String customer) { + return orders.stream().filter(o -> o.customer().equals(customer)).toList(); + } + + public Optional largest() { + return orders.stream().max(Comparator.comparingLong(Order::totalCents)); + } + + // TODO: discounts for repeat customers + public long revenueCents() { + return orders.stream().mapToLong(Order::totalCents).sum(); + } + + public int size() { + return orders.size(); + } +} diff --git a/fixtures/java-app/src/test/java/com/example/orders/OrderBookTest.java b/fixtures/java-app/src/test/java/com/example/orders/OrderBookTest.java new file mode 100644 index 00000000..42b30c2d --- /dev/null +++ b/fixtures/java-app/src/test/java/com/example/orders/OrderBookTest.java @@ -0,0 +1,29 @@ +package com.example.orders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +class OrderBookTest { + @Test + void sumsRevenueOverAllOrders() { + OrderBook book = new OrderBook(); + book.place("alice", "Keyboard", 2, 1000); + book.place("bob", "Mouse", 1, 500); + assertEquals(2500, book.revenueCents()); + assertEquals(1, book.byCustomer("bob").size()); + } + + @Test + void rejectsEmptyOrders() { + assertThrows(IllegalArgumentException.class, () -> new Order("alice", "Keyboard", 0, 1000)); + } + + @Test + @EnabledIfSystemProperty(named = "openidea.fail", matches = "true") + void intentionallyFails() { + assertEquals(1, new OrderBook().size(), "QA: an intentionally failing test"); + } +}