diff --git a/.github/workflows/cnb-mirror.yml b/.github/workflows/cnb-mirror.yml new file mode 100644 index 000000000..2021f4f8d --- /dev/null +++ b/.github/workflows/cnb-mirror.yml @@ -0,0 +1,52 @@ +name: CNB mirror + +# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source +# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror +# an existing tag again. Skipped without the CNB_TOKEN secret. +on: + workflow_call: + inputs: + tag: { type: string, required: true } + workflow_dispatch: + inputs: + tag: + description: "Published release to mirror (e.g. v0.1.38)" + required: true + +permissions: + contents: read + +concurrency: + group: cnb-mirror-${{ inputs.tag }} + +jobs: + mirror: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + TAG: ${{ inputs.tag }} + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Check token + id: token + run: | + if [ -z "$CNB_TOKEN" ]; then + echo "::notice::CNB_TOKEN unset: not mirroring $TAG" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v5 + if: steps.token.outputs.skip != 'true' + with: { sparse-checkout: scripts } + - uses: actions/setup-node@v5 + if: steps.token.outputs.skip != 'true' + with: { node-version: 22 } + - name: Download release + if: steps.token.outputs.skip != 'true' + run: | + gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets + gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json + du -sh assets + - name: Upload to CNB + if: steps.token.outputs.skip != 'true' + run: node scripts/cnb-mirror.ts "$TAG" assets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed6644138..3b44d7d99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,6 +166,28 @@ jobs: xcrun stapler staple "$dmg" gh release upload "$TAG" "$dmg" --clobber done + # Portable Windows builds (#1857): on Windows Tauri embeds the + # frontend in the exe and WebView2 is a system component, so zipping + # the raw exe gives an unpack-and-run alternative to the installers. + - name: Portable zip + if: runner.os == 'Windows' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $target = "${{ matrix.target }}" + $arch = if ($target -like "x86_64*") { "x64" } else { "arm64" } + $version = $env:TAG -replace '^v', '' + $asset = "OpenIDEA_${version}_windows_${arch}_portable.zip" + $dir = "target/$target/release" + $files = @("$dir/openidea.exe") + if (Test-Path "$dir/WebView2Loader.dll") { + $files += "$dir/WebView2Loader.dll" + } + Compress-Archive -Path $files -DestinationPath $asset -Force + $hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower() + "$hash $asset" | Out-File -Encoding ascii "$asset.sha256" + gh release upload $env:TAG $asset "$asset.sha256" --clobber # The remote development server, per host platform it can run on. The desktop # app downloads the one for the host (ide-tools `server`) and installs it over SSH. @@ -355,3 +377,10 @@ jobs: await github.rest.repos.updateRelease({ ...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false, }); + + # The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is. + cnb-mirror: + needs: publish + uses: ./.github/workflows/cnb-mirror.yml + with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" } + secrets: inherit diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0a0d7102b..10806cab4 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", "src/editor/core/cm/jsp/parser*.ts", + "src/editor/core/cm/astro/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/.oxlintrc.json b/.oxlintrc.json index 51778e9c5..6acbb45db 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ "crates", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/astro/parser*.ts", "fixtures", "plugins", "test-results", diff --git a/AGENTS.md b/AGENTS.md index 50ad7debe..202c8be36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them): `pnpm gen:bindings`. - `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion benches vs budgets). Budgets: `perf/budgets.json`. +- `pnpm release:prepare ` — the release commit's changes: app version everywhere, + `Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version. ## Before opening a PR diff --git a/Cargo.lock b/Cargo.lock index 5a3e48cb0..d04863312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1552,6 +1552,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + [[package]] name = "darling" version = "0.23.0" @@ -1572,6 +1582,20 @@ dependencies = [ "darling_macro 0.24.1", ] +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -1581,7 +1605,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.119", ] @@ -1594,10 +1618,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 3.0.9", ] +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -3001,7 +3036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -4071,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dunce", "encoding_rs", @@ -4094,7 +4129,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ast-grep-core", "ide-core", @@ -4108,7 +4143,7 @@ dependencies = [ [[package]] name = "ide-android" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "serde", @@ -4119,8 +4154,9 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.37" +version = "0.1.38" dependencies = [ + "blake3", "dirs 6.0.0", "dunce", "ide-core", @@ -4134,7 +4170,7 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.37" +version = "0.1.38" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4143,7 +4179,7 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dunce", "encoding_rs", @@ -4160,7 +4196,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "ide-run", @@ -4174,13 +4210,15 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.37" +version = "0.1.38" dependencies = [ + "futures", "ide-core", "ide-jsonrpc", "ide-language", "memchr", "parking_lot", + "portable-pty", "serde", "serde_json", "specta", @@ -4192,7 +4230,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.37" +version = "0.1.38" dependencies = [ "bb8", "dirs 6.0.0", @@ -4200,6 +4238,7 @@ dependencies = [ "ide-core", "ide-http", "ide-ssh", + "oracle", "parking_lot", "reqwest", "rustls", @@ -4218,7 +4257,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "serde", @@ -4231,7 +4270,7 @@ dependencies = [ [[package]] name = "ide-extension" -version = "0.1.37" +version = "0.1.38" dependencies = [ "base64 0.22.1", "blake3", @@ -4251,7 +4290,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.37" +version = "0.1.38" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4263,7 +4302,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "ide-vcs", @@ -4276,7 +4315,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.37" +version = "0.1.38" dependencies = [ "base64 0.22.1", "criterion", @@ -4337,7 +4376,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "parking_lot", @@ -4355,7 +4394,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.37" +version = "0.1.38" dependencies = [ "blake3", "criterion", @@ -4377,7 +4416,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.37" +version = "0.1.38" dependencies = [ "encoding_rs", "futures", @@ -4394,7 +4433,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.37" +version = "0.1.38" dependencies = [ "blake3", "criterion", @@ -4450,7 +4489,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dunce", "futures", @@ -4476,7 +4515,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.37" +version = "0.1.38" dependencies = [ "futures", "ide-core", @@ -4499,7 +4538,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.37" +version = "0.1.38" dependencies = [ "blake3", "ide-core", @@ -4520,7 +4559,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "ide-run", @@ -4533,7 +4572,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.37" +version = "0.1.38" dependencies = [ "globset", "ide-core", @@ -4542,6 +4581,7 @@ dependencies = [ "ignore", "parking_lot", "serde", + "serde_json", "specta", "tempfile", "tokio", @@ -4550,7 +4590,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dunce", "encoding_rs", @@ -4573,7 +4613,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dunce", "ide-core", @@ -4592,7 +4632,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.37" +version = "0.1.38" dependencies = [ "criterion", "globset", @@ -4612,7 +4652,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "parking_lot", @@ -4625,7 +4665,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.37" +version = "0.1.38" dependencies = [ "dirs 6.0.0", "flate2", @@ -4647,7 +4687,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "ide-language", @@ -4662,7 +4702,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "libc", @@ -4675,7 +4715,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.37" +version = "0.1.38" dependencies = [ "criterion", "ide-core", @@ -4688,7 +4728,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.37" +version = "0.1.38" dependencies = [ "flate2", "hex", @@ -4709,7 +4749,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.37" +version = "0.1.38" dependencies = [ "blake3", "dunce", @@ -4730,7 +4770,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.37" +version = "0.1.38" dependencies = [ "chardetng", "dunce", @@ -4752,7 +4792,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.37" +version = "0.1.38" dependencies = [ "ide-core", "open", @@ -4768,7 +4808,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.37" +version = "0.1.38" dependencies = [ "blake3", "dunce", @@ -5973,7 +6013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", - "block2 0.6.2", "dispatch2", "objc2 0.6.5", ] @@ -6284,6 +6323,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "odpic-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6309,7 +6357,7 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.37" +version = "0.1.38" dependencies = [ "base64 0.22.1", "blake3", @@ -6355,7 +6403,6 @@ dependencies = [ "ide-workspace", "objc2 0.6.5", "objc2-app-kit 0.3.2", - "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", @@ -6395,6 +6442,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0" +dependencies = [ + "cc", + "odpic-sys", + "once_cell", + "oracle_procmacro", + "paste", + "rustversion", +] + +[[package]] +name = "oracle_procmacro" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -8377,7 +8450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a" dependencies = [ "foldhash", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -8695,6 +8768,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -8731,6 +8810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -8953,7 +9033,7 @@ dependencies = [ "serde", "serde_json", "static_vcruntime", - "strsim", + "strsim 0.11.1", "tauri-utils", "tauri-winres", "walkdir", @@ -9979,9 +10059,9 @@ dependencies = [ [[package]] name = "tree-sitter-haskell" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4" +checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index 507145e37..73910c928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"] exclude = ["sdk", "plugins"] [workspace.package] -version = "0.1.37" +version = "0.1.38" edition = "2024" rust-version = "1.96" license = "Apache-2.0" @@ -103,6 +103,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" rustls-platform-verifier = "0.7" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] } tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] } +oracle = { version = "0.6", default-features = false } bb8 = "0.9" uuid = { version = "1", features = ["v4"] } # OS credential stores diff --git a/README.md b/README.md index 50ea82fd9..843b88ce3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window its search-everything habits — to a native app with a Rust core. Editor, code insight, debugger, Git, terminal, database and HTTP clients, -for 60+ languages. Builds for macOS, Windows and Linux. +for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd +rather skip the installer, each release also ships a portable zip — unpack it +and run `openidea.exe`. ## Benchmark @@ -64,8 +66,18 @@ the [official plugins](plugins/official). The plugin API is still a preview. WeChat group QR code +### Contributors + + + OpenIDEA contributors + + ## License [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). +Database tool window notes, including Oracle Instant Client setup, are in +[`docs/databases.md`](docs/databases.md). +Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md). + Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 2ebfd19ae..5f6c145c2 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -2,8 +2,9 @@ ## Project Lombok -`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox -classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059) +`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox +classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), +the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch) and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089). Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component notices are retained in [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar. @@ -33,7 +34,8 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor - [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` → `fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` → - `fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` → + `fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`, + Apache-2.0); `makefile` (`expui/makefile` → `fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below). The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 7116047f1..a31d09a7d 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -54,6 +54,9 @@ const SESSION_STATE: [&str; 5] = pub(crate) const RETRY_UPDATE: &str = "openidea_retry"; /// A native agent's own update: the usage limits of its account (see [`AgentLimit`]). pub(crate) const LIMITS_UPDATE: &str = "openidea_limits"; +/// A native agent's own update: the background tasks its session runs +/// (see [`TaskState::background_tasks`]). +pub(crate) const BACKGROUND_TASKS_UPDATE: &str = "openidea_background_tasks"; /// Referenced files up to this size are embedded in the prompt (when the agent takes it). const MAX_EMBED: u64 = 256 * 1024; /// How long a probe waits for the agent to answer `initialize`. @@ -125,6 +128,11 @@ impl Shared { fn known_config(&self, provider: &str) -> Vec { let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default(); + // A thinking level's values depend on the model (low/medium/high on one, none or a + // different set on another), but what ran last is remembered wholesale — after a + // model switch the New Task page would offer the previous model's levels. Until the + // session reports the new model's own, don't show the option at all. + config.retain(|o| o.category.as_deref() != Some("thought_level")); config::add_client_options(provider, &mut config); if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone())) && let Some(o) = config.iter_mut().find(|c| c.id == o) @@ -204,6 +212,10 @@ struct TaskState { pending_rollback: u32, /// A turn is running or about to (prompts arriving now are queued). busy: bool, + /// Background tasks the agent reported as running (`run_in_background` commands and + /// the like), as it sent them: unlike a turn they outlive it, so an idle release + /// waits for the last one. A (re)started process runs nothing yet. + background_tasks: Vec, } /// A running agent and its session. @@ -239,6 +251,9 @@ struct Task { turn_started: AtomicBool, /// Turns sent so far: an idle release scheduled before the latest one is void. turns: AtomicU64, + /// Bumped when the agent's last background task finishes: an idle release scheduled + /// before that is void, the usual delay starts over from the report. + idle_epoch: AtomicU64, /// Settings already sent this session (`(option, value)`), so a refusal is not retried. config_sent: Mutex>, dirty: Mutex>, @@ -297,6 +312,7 @@ impl AgentManager { f.task.status = AgentStatus::Ready; } f.task.retry = None; + f.task.background.clear(); let task = Task::restore(f, shared.clone()); task.state.lock().conversation.settle(now_ms()); tasks.insert(task.id.clone(), task); @@ -465,6 +481,7 @@ impl AgentManager { capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(), resets_ms: None, retry: None, + background: Box::default(), }; let file = TaskFile { task: task.clone(), @@ -659,6 +676,7 @@ impl AgentManager { queue: Vec::new(), resets_ms: None, retry: None, + background: Box::default(), ..s.task.clone() }; TaskFile { @@ -797,6 +815,21 @@ impl AgentManager { Ok(()) } + /// Stops background task `background` of task `id`. + pub async fn stop_background(&self, id: &str, background: &str) -> IdeResult<()> { + let task = self.task(id)?; + let live = task.live.lock().clone().ok_or_else(|| IdeError::invalid("the agent is not running"))?; + if !live.capabilities.background_tasks { + return Err(IdeError::invalid("this agent can't stop a background task")); + } + let _: Value = live + .peer + .request("_openidea/stop_task", json!({ "sessionId": live.session, "taskId": background }), None) + .await + .map_err(|error| IdeError::internal(task.describe(error)))?; + Ok(()) + } + /// Answers permission request `permission` with option `option` (`None` cancels it). pub fn respond(&self, id: &str, permission: u32, option: Option) -> IdeResult<()> { let task = self.task(id)?; @@ -909,6 +942,7 @@ impl Task { before: Arc::new(file.before), pending_rollback: file.pending_rollback, busy: false, + background_tasks: Vec::new(), }), live: Mutex::new(None), connecting: tokio::sync::Mutex::new(()), @@ -919,6 +953,7 @@ impl Task { cancel_pending: AtomicBool::new(false), turn_started: AtomicBool::new(false), turns: AtomicU64::new(0), + idle_epoch: AtomicU64::new(0), config_sent: Mutex::new(HashSet::new()), dirty: Mutex::new(BTreeSet::new()), flush_scheduled: AtomicBool::new(false), @@ -955,6 +990,39 @@ impl Task { self.emit_task(); } + /// Keeps the agent's background tasks that outlive its turn. Returns whether the last + /// one just finished, so the idle release starts over from this report. + fn set_background_tasks(&self, tasks: Vec) -> bool { + let (emptied, shown) = { + let mut state = self.state.lock(); + if state.background_tasks == tasks { + return false; + } + let emptied = !state.background_tasks.is_empty() && tasks.is_empty(); + let background = shown_background(&tasks, &state.task.background); + let shown = background != *state.task.background; + *state.task.background = background; + state.background_tasks = tasks; + if emptied { + // Void a release scheduled while the tasks still ran: it would stop the + // agent sooner than the usual delay after this one. + self.idle_epoch.fetch_add(1, Ordering::AcqRel); + } + (emptied, shown) + }; + if shown { + self.emit_task(); + } + emptied + } + + fn clear_shown_background(&self) { + let cleared = !std::mem::take(&mut self.state.lock().task.background).is_empty(); + if cleared { + self.emit_task(); + } + } + fn set_status(&self, status: AgentStatus, error: Option) { { let mut s = self.state.lock(); @@ -1073,19 +1141,32 @@ impl Task { /// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so /// finished tasks don't keep an agent (and what it started) running. Only an agent that /// reloads its session: the next message starts it again with the conversation intact. + /// A turn's background tasks outlive it: the release waits for the last one, and the + /// delay runs again once the agent reports it done. fn release_when_idle(self: &Arc) { let turn = self.turns.load(Ordering::Acquire); + let epoch = self.idle_epoch.load(Ordering::Acquire); let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed)); let task = Arc::downgrade(self); self.shared.runtime.spawn(async move { tokio::time::sleep(delay).await; let Some(task) = task.upgrade() else { return }; if task.turns.load(Ordering::Acquire) != turn + || task.idle_epoch.load(Ordering::Acquire) != epoch || task.state.lock().busy || !task.permissions.lock().is_empty() { return; } + // Stopping now would orphan what the agent still runs in the background: its + // result would never reach the conversation. Wait for the last task; when it + // is done, its report voids this schedule and starts the delay over. + let waiting = !task.state.lock().background_tasks.is_empty(); + if waiting && task.live.lock().as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { + tracing::debug!("agent task {}: idle, waiting for its background tasks", task.id); + task.release_when_idle(); + return; + } let live = { let mut live = task.live.lock(); if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None } @@ -1093,6 +1174,7 @@ impl Task { if let Some(live) = live { tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id); live.peer.kill(); + task.clear_shown_background(); } }); } @@ -1111,6 +1193,7 @@ impl Task { if !gone && let Some(live) = self.live.lock().take() { live.peer.kill(); } + self.clear_shown_background(); match failure.limit { Some(resets) => { self.state.lock().task.resets_ms = resets; @@ -1351,6 +1434,9 @@ impl Task { s.task.config = options; s.task.capabilities = capabilities; s.session_id = Some(session_id.clone()); + // The (re)started process runs nothing yet: whatever it reports next is all of it. + s.background_tasks = Vec::new(); + s.task.background.clear(); } self.persist(); self.emit_task(); @@ -1443,6 +1529,16 @@ impl Task { } return; } + if kind == BACKGROUND_TASKS_UPDATE { + if !self.replaying.load(Ordering::Acquire) { + let tasks = update.get("tasks").and_then(Value::as_array).cloned().unwrap_or_default(); + // The last one finished while the agent is up: the usual delay starts over. + if self.set_background_tasks(tasks) { + self.release_when_idle(); + } + } + return; + } if kind == "session_info_update" { // The agent's own title for the session replaces the first line of the prompt. let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()); @@ -1551,17 +1647,38 @@ impl Task { .collect() }) .unwrap_or_default(); + let questions: Vec = params + .pointer("/toolCall/_meta/questions") + .and_then(|questions| serde_json::from_value(questions.clone()).ok()) + .unwrap_or_default(); + let asked: Vec = questions.iter().map(|question| question.question.clone()).collect(); let (tx, rx) = std::sync::mpsc::channel(); - let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None }); + let entry = + self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None }); self.permissions.lock().insert(id, tx); self.set_status(AgentStatus::NeedsInput, None); self.approve_pending(); let answer = rx.recv().ok().flatten(); - let name = answer - .as_ref() - .and_then(|a| options.iter().find(|o| &o.id == a)) - .map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone()); + let answers = answer + .as_deref() + .filter(|_| !asked.is_empty()) + .and_then(|reply| serde_json::from_str::>(reply).ok()); + let name = match answers { + Some(answers) => asked + .iter() + .filter_map(|question| answers.get(question)) + .map(|items| match items { + Value::Array(items) => items.iter().filter_map(Value::as_str).collect::>().join(", "), + item => item.as_str().unwrap_or_default().to_owned(), + }) + .collect::>() + .join("; "), + None => answer + .as_ref() + .and_then(|reply| options.iter().find(|option| &option.id == reply)) + .map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()), + }; { let mut s = self.state.lock(); if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) { @@ -1617,6 +1734,7 @@ impl Task { self.cancel_permissions(); let live = self.live.lock().take()?; live.peer.kill(); + self.clear_shown_background(); Some(live.peer) } } @@ -1730,6 +1848,19 @@ fn client_info() -> Value { } /// What the agent said it can do, and whether it takes embedded files. +fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec { + let now = now_ms(); + tasks + .iter() + .filter_map(|task| { + let id = task.get("task_id")?.as_str()?.to_owned(); + let started_ms = known.iter().find(|seen| seen.id == id).map_or(now, |seen| seen.started_ms); + let text = |key: &str| task.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + Some(AgentBackgroundTask { id, kind: text("task_type"), description: text("description"), started_ms }) + }) + .collect() +} + fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) { let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true); let capabilities = AgentCapabilities { @@ -1737,6 +1868,7 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) { fork: init.session("fork"), rollback: meta("/openidea/rollback"), steer: meta("/openidea/steer"), + background_tasks: meta("/openidea/backgroundTasks"), }; (capabilities, init.prompt("embeddedContext")) } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 41cf2875c..50edd1392 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -34,6 +34,8 @@ pub struct AgentCapabilities { pub rollback: bool, /// A message can join the running turn instead of waiting for it. pub steer: bool, + /// It reports its background tasks and stops them one by one. + pub background_tasks: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -89,6 +91,22 @@ pub struct AgentTask { /// by its next output or any status change. #[serde(default)] pub retry: Option, + /// What the agent runs in the background (shells, subagents, monitors), oldest first. + // Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows. + #[serde(default)] + pub background: Box>, +} + +/// A task the agent runs in the background while the conversation goes on. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentBackgroundTask { + pub id: String, + /// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…). + pub kind: String, + pub description: String, + /// When the client first saw it (ms since the epoch). + pub started_ms: f64, } /// An API request the agent is about to retry. @@ -260,6 +278,28 @@ pub struct AgentPermissionOption { pub kind: String, } +/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQuestion { + #[serde(default)] + pub header: String, + pub question: String, + #[serde(default)] + pub multi_select: bool, + pub options: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQuestionOption { + pub label: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub preview: Option, +} + /// One item of a task's conversation. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] @@ -332,6 +372,10 @@ pub enum AgentEntry { id: u32, title: String, options: Vec, + /// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object + /// of question to answer instead of an option. + #[serde(default)] + questions: Vec, /// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". chosen: Option, }, diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index 65152647e..b8a23c5e7 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -312,7 +312,7 @@ impl Shim { "loadSession": true, "promptCapabilities": { "image": true, "embeddedContext": true }, "sessionCapabilities": { "fork": {} }, - "_meta": { "openidea": { "rollback": true, "steer": true } }, + "_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } }, }, "agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() }, "authMethods": [], @@ -396,6 +396,13 @@ impl Shim { session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?; Ok(json!({})) } + "_openidea/stop_task" => { + let session = self.session(¶ms)?; + let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?; + let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?; + rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?; + Ok(json!({})) + } "_openidea/rollback" => { let session = self.session(¶ms)?; let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -611,7 +618,8 @@ impl Session { session.on_exit(&reader_proc); } }); - let init = proc.control(json!({ "subtype": "initialize" })).await?; + // Stop ends the turn only: the client stops background tasks one by one. + let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?; let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default(); let chosen = { let mut st = self.st.lock(); @@ -1109,38 +1117,46 @@ fn permission_result( } } -/// `AskUserQuestion`: each question as a permission request whose options are its -/// answers; `None` when one is not answered. +/// `AskUserQuestion`: one permission request carrying all its questions, answered with a +/// JSON object of question to the chosen labels and typed text; `None` when skipped. async fn ask_questions(input: &Value, call: &Value, ask: F) -> Option where F: Fn(Value, Value) -> Fut, Fut: std::future::Future>, { - let mut answers = serde_json::Map::new(); - for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() { - let question = q.get("question").and_then(Value::as_str).unwrap_or_default(); - let labels: Vec<&str> = q - .get("options") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|o| o.get("label")?.as_str()) - .collect(); - let mut options: Vec = labels - .iter() - .enumerate() - .map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" })) - .collect(); - options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" })); - let mut call = call.clone(); - call["title"] = json!(question); - let chosen = ask(call, Value::Array(options)).await?; - let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?; - answers.insert(question.to_owned(), json!(labels.get(i)?)); - } + let questions = input.get("questions")?; + let mut call = call.clone(); + call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question")); + call["_meta"] = json!({ "questions": questions }); + let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]); + let chosen = ask(call, options).await?; + let chosen: serde_json::Map = serde_json::from_str(&chosen).ok()?; + let answers = questions + .as_array()? + .iter() + .filter_map(|entry| { + let question = entry.get("question")?.as_str()?; + let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect(); + let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true); + let answer = if multi { join_answers(&items) } else { items.first()?.to_string() }; + Some((question.to_owned(), json!(answer))) + }) + .collect(); Some(Value::Object(answers)) } +/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an +/// item that holds the separator or a quote JSON-quoted so it reads back as one item. +fn join_answers(items: &[&str]) -> String { + items + .iter() + .map( + |item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() }, + ) + .collect::>() + .join(", ") +} + /// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of /// its messages, or names none (the CLI's own error before the turn began). fn belongs(turn: &Turn, result: &Value) -> bool { diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 49c9f4676..23c3d0eb2 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -976,6 +976,19 @@ fn api_retries_are_announced() { ); } +#[test] +fn background_tasks_are_announced_wholesale() { + let mut t = Translator::new("s", PathBuf::from("/w"), true); + let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]); + let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks})); + assert_eq!(out.len(), 1); + assert_eq!(out[0].session, "s"); + assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks})); + // The list is replaced with every report: an empty one says every task is done. + let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []})); + assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []})); +} + #[test] fn streamed_text_is_not_sent_twice() { let mut t = Translator::new("s", PathBuf::from("/w"), true); @@ -1093,3 +1106,67 @@ fn tool_calls_plans_and_subagents() { assert_eq!(finished[1].update["content"][0]["text"], "It is x"); assert_eq!(finished[1].update["senderSessionId"], "ag"); } + +#[tokio::test] +async fn questions_go_out_as_one_request() { + let questions = json!([ + {"header": "Files", "question": "Where should they live?", "multiSelect": false, + "options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]}, + {"header": "Checks", "question": "Which checks?", "multiSelect": true, + "options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]}, + ]); + let input = json!({ "questions": questions }); + let asked = std::sync::Mutex::new(Vec::new()); + let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string(); + let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"}); + let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| { + asked.lock().unwrap().push((call, options)); + let reply = reply.clone(); + async move { Some(reply) } + }) + .await; + assert_eq!(result, Some(answers)); + let asked = asked.into_inner().unwrap(); + assert_eq!(asked.len(), 1); + let (call, options) = &asked[0]; + assert_eq!(call["title"], "Where should they live?"); + assert_eq!(call["_meta"]["questions"], questions); + assert_eq!(options[0]["optionId"], "reject"); +} + +#[tokio::test] +async fn answers_keep_claude_codes_format_with_other_text() { + let input = json!({"questions": [ + {"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]}, + {"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]}, + {"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]}, + ]}); + let reply = json!({ + "Which cache?": ["Memcached, for now"], + "Which checks?": ["Lint", "Tests"], + "Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"], + }) + .to_string(); + let result = ask_questions(&input, &json!({}), |_, _| { + let reply = reply.clone(); + async move { Some(reply) } + }) + .await; + assert_eq!( + result, + Some(json!({ + "Which cache?": "Memcached, for now", + "Which checks?": "Lint, Tests", + "Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"", + })) + ); +} + +#[tokio::test] +async fn skipped_questions_have_no_answers() { + let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]}); + let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await; + assert_eq!(skipped, None); + let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await; + assert_eq!(cancelled, None); +} diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index 7d9a91f3e..044355844 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -5,7 +5,7 @@ //! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`. use super::tools; -use crate::{LIMITS_UPDATE, RETRY_UPDATE}; +use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE}; use serde_json::{Value, json}; use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; @@ -458,6 +458,15 @@ impl Translator { let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty()); self.finish_child(&tool_use, stop, last.or(summary)) } + // A turn's background tasks (`run_in_background`) outlive it: the task keeps + // the list and stays up while it isn't empty (see `Task::release_when_idle`). + Some("background_tasks_changed") => { + let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() }; + vec![Update { + session: self.root.clone(), + update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }), + }] + } _ => Vec::new(), } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index e1da00d19..ca9f69566 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -11,6 +11,8 @@ struct FakeAgent { calls: Mutex>, /// Without `loadSession` (a session lives only as long as its process). no_load: bool, + /// Config options `session/new` reports after the model one (per-model efforts, say). + extra_options: Mutex>, } impl FakeAgent { @@ -92,13 +94,22 @@ impl Handler for FakeAgent { self.calls.lock().push("subagents".into()); } Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load, - "sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}})) + "sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}})) } - "session/new" => Ok(json!({"sessionId": "s1", + "session/new" => { + let mut options = vec![json!({"id": "model", "category": "model", "type": "select", + "currentValue": "big", + "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})]; + options.extend(self.extra_options.lock().iter().cloned()); + Ok(json!({"sessionId": "s1", "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]}, - "configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big", - "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})), + "configOptions": options})) + } "session/fork" => Ok(json!({"sessionId": "s2"})), + "_openidea/stop_task" => { + self.calls.lock().push(format!("{method} {}", params["taskId"])); + Ok(json!({})) + } "_openidea/rollback" | "_openidea/steer" => { let detail = params.get("turns").cloned().unwrap_or_else(|| params["prompt"][0]["text"].clone()); self.calls.lock().push(format!("{method} {detail}")); @@ -118,6 +129,16 @@ impl Handler for FakeAgent { self.say(&session, "second turn"); return Ok(json!({"stopReason": "end_turn"})); } + if text == "background" { + // A command that outlives the turn, as the agent reports it. + self.update( + &session, + json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, + "tasks": [{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]}), + ); + self.say(&session, "running it in the background"); + return Ok(json!({"stopReason": "end_turn"})); + } if text == "delegate" { self.delegate(&session); return Ok(json!({"stopReason": "end_turn"})); @@ -135,6 +156,24 @@ impl Handler for FakeAgent { self.say(&session, "ok"); return Ok(json!({"stopReason": "end_turn"})); } + if text == "ask" { + let peer = self.latest(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, + "toolCall": {"toolCallId": "q1", "title": "AskUserQuestion", "_meta": {"questions": [ + {"question": "Which layout?", "options": [{"label": "List"}, {"label": "Grid"}]}, + {"question": "Which checks?", "multiSelect": true, + "options": [{"label": "Disk"}, {"label": "GPU temps"}]}]}}, + "options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}), + None, + )) + .unwrap(); + let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + self.say(&session, &format!("answered {chosen}")); + return Ok(json!({"stopReason": "end_turn"})); + } if text == "wait" { // Works until cancelled. while !self.calls.lock().iter().any(|c| c == "session/cancel") { @@ -588,6 +627,44 @@ fn prompts_become_acp_content_blocks() { assert!(clean(text(" ")).is_none()); } +#[tokio::test(flavor = "multi_thread")] +async fn answered_questions_close_the_question_with_every_picked_label() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("ask"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let question = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string(); + mgr.respond(&task.id, question, Some(reply)).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id, None).unwrap(); + assert!( + entries + .iter() + .any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "List; Disk, GPU temps")), + "{entries:?}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { let tmp = tempfile::tempdir().unwrap(); @@ -788,6 +865,35 @@ async fn usage_limits_and_late_settings() { assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high")); } +#[tokio::test(flavor = "multi_thread")] +async fn remembered_settings_drop_per_model_choices_for_the_next_task() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + // The effort list depends on the model: what the last session ran means nothing once + // the user picks another model, so the New Task page shows the option without a choice. + *agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level", + "type": "select", "currentValue": "high", + "options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})]; + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let provider = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap(); + assert!(provider.config.iter().all(|o| o.id != "effort"), "a per-model option isn't offered stale"); + let model = provider.config.iter().find(|o| o.id == "model").unwrap(); + assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives"); +} + #[test] fn retry_updates_become_a_countdown() { let update = @@ -1110,6 +1216,114 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() { mgr.shutdown(); } +/// A turn's background tasks outlive it: an idle agent stops only once its last one is +/// done, and the usual delay runs from that report. +#[tokio::test(flavor = "multi_thread")] +async fn an_idle_agent_waits_for_its_background_tasks_before_it_stops() { + let live = + |mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed()); + let new = || NewAgentTask { + project_root: None, + prompt: text("background"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }; + + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + mgr.set_idle_release(Duration::from_millis(200)); + let task = mgr.create(new()).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // The task runs long past the idle delay: the agent stays up for it. + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(live(&mgr, &task.id)); + // It is done: the delay starts over from this report, then the agent stops. + agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []})); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(live(&mgr, &task.id), "the delay runs from when the last task is done"); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(!live(&mgr, &task.id)); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error"); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_task_shows_what_runs_in_the_background() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("background"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let shown = || mgr.task(&task.id).unwrap().state.lock().task.background.clone(); + let first = shown(); + assert_eq!(first.len(), 1); + assert_eq!( + (first[0].id.as_str(), first[0].kind.as_str(), first[0].description.as_str()), + ("bsh1", "local_bash", "sleep 900") + ); + + agent.update( + "s1", + json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": [ + {"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}, + {"task_id": "mon1", "task_type": "monitor", "description": "Watch the deploy"}, + ]}), + ); + tokio::time::timeout(Duration::from_secs(5), async { + while shown().len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(shown()[0].started_ms, first[0].started_ms, "a task keeps when it was first seen"); + + agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []})); + tokio::time::timeout(Duration::from_secs(5), async { + while !shown().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_background_task_is_stopped_on_its_own() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + mgr.stop_background(&task.id, "b1").await.unwrap(); + assert!(agent.calls.lock().contains(&"_openidea/stop_task \"b1\"".to_owned())); + mgr.shutdown(); +} + /// Deleting a task whose agent still runs in the worktree removes both and leaves no /// process behind, whether it is waiting for permission or idle without session/load. #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs index e2efaea0f..3617046ba 100644 --- a/crates/ide-agent/src/timeline.rs +++ b/crates/ide-agent/src/timeline.rs @@ -434,7 +434,13 @@ mod tests { t.push(AgentEntry::Agent { text: "between".into() }); assert_eq!(t.apply(&plan("completed")).changed, vec![0]); t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"})); - t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None }); + t.push(AgentEntry::Permission { + id: 1, + title: "Run?".into(), + options: vec![], + questions: vec![], + chosen: None, + }); assert_eq!(t.end_turn(None), vec![2, 3]); let again = Timeline::from_entries(t.entries.clone()); assert_eq!(again.plan, Some(0)); diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index bda630cf4..0de84af23 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true [dependencies] +blake3.workspace = true dunce.workspace = true ide-core.workspace = true zip.workspace = true diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs index 8274003d6..7ed4b9237 100644 --- a/crates/ide-build/src/compile/fingerprint.rs +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -2,18 +2,49 @@ use super::*; use std::fs; use std::hash::{Hash, Hasher}; +use std::io::Read; use std::time::SystemTime; +/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only. +type Stamp = (u64, SystemTime, Option); + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LaunchFingerprint { version: u32, parameters: String, - files: BTreeMap>, + /// Per input: a [`Stamp`] for regular files, `None` for + /// directories and inputs that cannot be resolved. The content hash catches external + /// edits that preserve both length and mtime (timestamp-preserving tools, coarse + /// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside + /// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp. + files: BTreeMap>, +} + +/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An +/// unreadable file records a sentinel so one permission problem does not drop the whole +/// fingerprint (that would just force a normal rebuild). +fn content_hash(path: &Path) -> IdeResult { + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()), + Err(e) => return Err(e.into()), + }; + let mut hasher = blake3::Hasher::new(); + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(hasher.finalize().to_hex().to_string()) } fn scan( + root: &Path, path: &Path, - files: &mut BTreeMap>, + files: &mut BTreeMap>, ancestors: &mut HashSet, cancel: &CancellationToken, ) -> IdeResult<()> { @@ -36,11 +67,12 @@ fn scan( // Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions. files.insert(path.to_path_buf(), None); for entry in fs::read_dir(path)? { - scan(&entry?.path(), files, ancestors, cancel)?; + scan(root, &entry?.path(), files, ancestors, cancel)?; } ancestors.remove(&canonical); } else if meta.is_file() { - files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?))); + let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None }; + files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash))); } else { return Err(IdeError::internal("unsupported launch input file")); } @@ -145,11 +177,11 @@ pub fn launch_fingerprint( .map_err(|e| IdeError::internal(e.to_string()))?; let mut files = BTreeMap::new(); for path in inputs { - scan(&path, &mut files, &mut HashSet::new(), cancel)?; + scan(root, &path, &mut files, &mut HashSet::new(), cancel)?; } // Store project inputs relative to the project, including each file's relative path. let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect(); - Ok(Some(LaunchFingerprint { version: 1, parameters, files })) + Ok(Some(LaunchFingerprint { version: 2, parameters, files })) } #[cfg(test)] @@ -231,6 +263,25 @@ mod tests { } } + /// An external tool that keeps the byte length and the mtime (timestamp-preserving + /// writes, coarse-granularity filesystems) still changes the content: the fingerprint + /// must differ via the content hash, not via `(length, mtime)`. + #[test] + fn external_edit_preserving_length_and_mtime_still_invalidates() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let source = root.join("lib/src/main/kotlin/Channel.kt"); + let before = fingerprint(root, &model); + assert!(before.is_some()); + let mtime = fs::metadata(&source).unwrap().modified().unwrap(); + // Same 5-byte length, different content. + write(root, "lib/src/main/kotlin/Channel.kt", "valve"); + fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap(); + let after = fingerprint(root, &model); + assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint"); + } + #[test] fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 91bbdbf35..ce41a1089 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -234,7 +234,12 @@ pub fn plan( Ok(BuildPlan { system, title, steps }) } -fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult> { +pub(super) fn maven_steps( + root: &Path, + model: &BuildModel, + kind: BuildKind, + module: Option<&Path>, +) -> IdeResult> { let fallback; let m = match &model.maven { Some(m) => m, @@ -383,18 +388,7 @@ fn go_steps(dir: &Path, kind: BuildKind) -> Vec { /// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it. fn package_manager(dir: &Path) -> &'static str { - for d in dir.ancestors() { - if d.join("pnpm-lock.yaml").is_file() { - return "pnpm"; - } else if d.join("yarn.lock").is_file() { - return "yarn"; - } else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() { - return "bun"; - } else if d.join("package-lock.json").is_file() { - return "npm"; - } - } - "npm" + ide_core::project_roots::package_manager(dir) } fn has_build_script(dir: &Path) -> bool { @@ -882,7 +876,8 @@ mod tests { model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into()); let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap(); assert_eq!(all.steps[0].cwd, external); - assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy()); + // Linked roots are saved with forward slashes, so on Windows the program mixes separators. + assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper)); let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap(); assert_eq!(module.steps[0].cwd, external); assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk"); diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index b4bc88396..f8a593ec7 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -218,21 +218,14 @@ fn normalize(p: &Path) -> PathBuf { out } -/// The directory of the Gradle build under `root`: the root itself, or a direct -/// subfolder, holding a settings or build script. +/// The first automatically discovered build, for callers operating on one build. pub fn build_dir(root: &Path) -> Option { - let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file()); - if has(root) { - return Some(root.to_path_buf()); - } - let mut dirs: Vec = std::fs::read_dir(root) - .ok()? - .flatten() - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.')) - .map(|e| e.path()) - .collect(); - dirs.sort(); - dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file())) + build_dirs(root).into_iter().next() +} + +fn build_dirs(root: &Path) -> Vec { + let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect(); + ide_core::project_roots::discover(root, &markers) } fn wrapper(dir: &Path) -> String { @@ -495,7 +488,7 @@ pub fn linked_project_key(root: &Path, dir: &str) -> String { /// Linked build identities without reading their scripts (also used by index hot paths). pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec { match &settings.linked_projects { - None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(), + None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(), Some(dirs) => { let mut out = Vec::new(); for dir in dirs { @@ -686,6 +679,55 @@ pub(crate) fn has_war_plugin(script: &str) -> bool { #[cfg(test)] mod tests { + #[test] + fn default_linkage_discovers_all_shallow_builds() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for file in [ + "Cargo.toml", + "package.json", + "fixtures/kotlin-gradle/settings.gradle.kts", + "fixtures/kotlin-gradle/build.gradle.kts", + "fixtures/kotlin-gradle/tools/settings.gradle", + "services/backend/api/settings.gradle", + "standalone/build.gradle", + "ignored/src/settings.gradle", + "ignored/out/build.gradle", + "node_modules/example/settings.gradle", + "target/example/build.gradle", + "build/example/settings.gradle", + "dist/example/build.gradle", + "vendor/example/settings.gradle", + ".hidden/settings.gradle", + "too/deep/to/find/settings.gradle", + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + let models = super::load_linked(root, &Default::default()); + assert_eq!( + models.iter().map(|m| m.dir.as_str()).collect::>(), + ["fixtures/kotlin-gradle", "ignored/src", "services/backend/api", "standalone"] + ); + std::fs::write(root.join("build.gradle.kts"), "").unwrap(); + let models = super::load_linked(root, &Default::default()); + assert_eq!(models.len(), 1); + assert!(models[0].dir.is_empty()); + } + + #[cfg(unix)] + #[test] + fn default_linkage_does_not_follow_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("real")).unwrap(); + std::fs::write(root.join("real/build.gradle"), "").unwrap(); + std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap(); + let models = super::load_linked(root, &Default::default()); + assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::>(), ["real"]); + } + #[test] fn a_path_from_the_ui_finds_the_saved_link() { let temp = tempfile::tempdir().unwrap(); @@ -718,7 +760,10 @@ mod tests { std::fs::create_dir(root.join(dir)).unwrap(); std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap(); } - assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a"); + assert_eq!( + super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::>(), + ["a", "b"] + ); let mut settings = ide_core::settings::GradleSettings { linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]), ..Default::default() diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs index e5ea5f3a4..d5c56b0c6 100644 --- a/crates/ide-build/src/kotlin_bridge.rs +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -53,8 +53,12 @@ pub struct Outcome { pub success: bool, } -/// Inspect sources, excluding generated/build/VCS trees and never following symlinks. -pub fn has_kotlin(root: &Path) -> bool { +/// Only linked Maven modules contribute sources to the bridge. +pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool { + model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir))) +} + +fn has_kotlin_sources(root: &Path) -> bool { let Ok(entries) = std::fs::read_dir(root) else { return false }; entries.flatten().any(|entry| { let Ok(kind) = entry.file_type() else { return false }; @@ -66,11 +70,25 @@ pub fn has_kotlin(root: &Path) -> bool { entry.file_name().to_str(), Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea") ) - && has_kotlin(&entry.path()) + && !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] + .iter() + .any(|marker| entry.path().join(marker).is_file()) + && has_kotlin_sources(&entry.path()) } }) } +fn plan(root: &Path, model: &BuildModel) -> IdeResult { + if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) { + return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); + } + Ok(compile::BuildPlan { + system: compile::BuildSystem::Maven, + title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(), + steps: compile::maven_steps(root, model, BuildKind::Build, None)?, + }) +} + /// Run the project's configured wrapper/settings/JDK and lifecycle, with an output /// redirector loaded before Maven calculates its lifecycle execution plans. pub fn build( @@ -81,10 +99,7 @@ pub fn build( cancel: &CancellationToken, ) -> IdeResult { std::fs::create_dir_all(generation)?; - let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?; - if plan.system != compile::BuildSystem::Maven { - return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); - } + let mut plan = plan(root, model)?; let mut manifests = Vec::new(); for (index, step) in plan.steps.iter_mut().enumerate() { step.args.push("--fail-at-end".into()); @@ -447,14 +462,106 @@ mod tests { assert_eq!(error.message, "Kotlin bridge requires a Maven build root"); } + #[test] + fn kotlin_sources_belong_to_linked_maven_modules() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + for file in [ + "java/pom.xml", + "gradle/build.gradle.kts", + "gradle/src/main/kotlin/App.kt", + "java/unlinked/pom.xml", + "java/unlinked/src/main/kotlin/App.kt", + "java/gradle/settings.gradle.kts", + "java/gradle/src/main/kotlin/App.kt", + "java/target/generated/Old.kt", + "java/node_modules/dependency/App.kt", + "java/linked/pom.xml", + "java/linked/src/test/kotlin/Test.kt", + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + let mut model = BuildModel { + maven: Some(crate::MavenModel { + projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }], + ..Default::default() + }), + ..Default::default() + }; + assert!(!has_kotlin(root, &model)); + model.maven.as_mut().unwrap().projects.push(crate::MavenProject { + dir: "java/linked".into(), + aggregated: true, + ..Default::default() + }); + assert!(has_kotlin(root, &model)); + model.maven.as_mut().unwrap().projects.clear(); + assert!(!has_kotlin(root, &model)); + assert!(!has_kotlin(root, &BuildModel::default())); + } + + #[test] + fn maven_bridge_ignores_the_workspace_build_system() { + for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join(marker), "").unwrap(); + let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() }; + for name in ["java", "kotlin"] { + let module = root.join(name); + std::fs::create_dir_all(&module).unwrap(); + model.maven.as_mut().unwrap().projects.push(crate::MavenProject { + dir: name.into(), + command: format!("{name}-mvnw"), + options: vec!["-o".into(), "-Peditor".into()], + env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]), + ..Default::default() + }); + } + std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap(); + assert!(has_kotlin(root, &model), "{marker}"); + let plan = plan(root, &model).unwrap(); + assert_eq!(plan.system, compile::BuildSystem::Maven); + assert_eq!(plan.steps.len(), 2); + for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) { + assert_eq!(step.cwd, root.join(name)); + assert_eq!(step.program, format!("{name}-mvnw")); + assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]); + assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk")); + } + } + } + + #[test] + fn external_linked_maven_sources_are_included() { + let workspace = tempfile::tempdir().unwrap(); + let external = tempfile::tempdir().unwrap(); + std::fs::write(external.path().join("App.kt"), "class App").unwrap(); + let model = BuildModel { + maven: Some(crate::MavenModel { + projects: vec![crate::MavenProject { + dir: external.path().to_string_lossy().into_owned(), + command: "mvn".into(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + assert!(has_kotlin(workspace.path(), &model)); + assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path()); + } + #[test] fn does_not_find_deleted_sources_in_build_outputs() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("target/generated")).unwrap(); std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap(); - assert!(!has_kotlin(dir.path())); + assert!(!has_kotlin_sources(dir.path())); std::fs::write(dir.path().join("New.kt"), "").unwrap(); - assert!(has_kotlin(dir.path())); + assert!(has_kotlin_sources(dir.path())); } #[test] diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 5fd70ba67..c6ccbff6f 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -8,6 +8,7 @@ //! later replaces plugins and dependencies with the effective build. use crate::xml::{self, Element}; +use ide_core::{Pos, Range}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -467,7 +468,7 @@ struct Resolver { } /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. -pub(crate) fn default_local_repo() -> Option { +pub fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) } @@ -1013,10 +1014,7 @@ pub(crate) fn plugin( // ---------------------------------------------------------------- loading -/// Directories never searched for POMs. -const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"]; const MAX_PROJECTS: usize = 1000; -const POM_SEARCH_DEPTH: usize = 3; fn rel_path(root: &Path, p: &Path) -> String { p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/") @@ -1027,44 +1025,7 @@ fn rel_path(root: &Path, p: &Path) -> String { /// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml` /// with its modules beside it, `src/app/pom.xml` and so on). fn top_level_poms(root: &Path) -> Vec { - let top = root.join("pom.xml"); - if top.is_file() { - return vec![top]; - } - let mut out = Vec::new(); - let src_pom = root.join("src").join("pom.xml"); - if src_pom.is_file() { - out.push(src_pom); - } - let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; - while let Some((dir, depth)) = queue.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { continue }; - let mut dirs: Vec = Vec::new(); - for e in entries.flatten() { - let name = e.file_name().to_string_lossy().into_owned(); - if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str()) - { - dirs.push(e.path()); - } - } - dirs.sort(); - for d in dirs.into_iter().rev() { - let pom = d.join("pom.xml"); - if pom.is_file() { - out.push(pom); - continue; - } - let src_pom = d.join("src").join("pom.xml"); - if src_pom.is_file() { - out.push(src_pom); - } - if depth + 1 < POM_SEARCH_DEPTH { - queue.push((d, depth + 1)); - } - } - } - out.sort(); - out + ide_core::project_roots::discover(root, &["pom.xml"]).into_iter().map(|dir| dir.join("pom.xml")).collect() } /// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing). @@ -1532,10 +1493,296 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec) out } +// ---------------------------------------------------------------- goto declaration + +/// What a go-to-declaration in a POM found, and where. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PomGoto { + /// Absolute path of the file with the declaration: the POM itself, or a parent + /// POM of the local repository when the definition is inherited from one. + pub path: PathBuf, + pub range: Range, + /// What was found. + pub kind: PomGotoKind, + /// The declaration as named for the user (`example.version`, `com.example:example-lib`). + pub label: String, +} + +/// The kind of declaration a POM go-to-declaration found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PomGotoKind { + /// A `` entry of the POM or one of its parents. + Property, + /// A `` entry providing the dependency's version. + ManagedDependency, + /// A `` entry providing the plugin's version. + ManagedPlugin, +} + +/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`. +fn offset_at(text: &str, pos: Pos) -> usize { + let (mut line_seen, mut at) = (0usize, 0usize); + if pos.line > 0 { + for (i, b) in text.bytes().enumerate() { + if b == b'\n' { + line_seen += 1; + if line_seen == pos.line as usize { + at = i + 1; + break; + } + } + } + if line_seen < pos.line as usize { + return text.len(); + } + } + let line_end = text[at..].find('\n').map_or(text.len(), |i| at + i); + let (mut col, mut byte) = (0u32, at); + for c in text[at..line_end].chars() { + let width = c.len_utf16() as u32; + if col + width > pos.col { + break; + } + col += width; + byte += c.len_utf8(); + } + byte +} + +/// 0-based line / UTF-16 column of byte offset `at`. +fn pos_at(text: &str, at: usize) -> Pos { + let before = &text[..at]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32) +} + +/// The `${property}` reference covering byte offset `at`: its name and the span of +/// the whole reference. `None` when `at` is outside one (a closing `${…}` before it +/// ends the search: references do not nest). +fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> { + // `at` may sit on the `$` itself. + let open = text[..at].rfind("${").or_else(|| text[at..].starts_with("${").then_some(at))?; + let close = open + 2 + text[open + 2..].find('}')?; + (close >= at).then(|| (text[open + 2..close].trim(), open, close + 1)) +} + +/// The elements containing `at`, from the root down to the innermost one. +fn element_path(root: &Element, at: usize) -> Vec<&Element> { + let mut path = vec![root]; + loop { + let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else { + return path; + }; + path.push(next); + } +} + +/// The `` / `` of a dependency or plugin element, with the +/// plugin default (`org.apache.maven.plugins`) applied. +fn coordinates(e: &Element, props: &HashMap) -> Option<(String, String)> { + let group = if e.name == "plugin" && e.text_of("groupId").is_none() { + "org.apache.maven.plugins".to_owned() + } else { + interpolate(&e.text_of("groupId")?, props) + }; + Some((group, interpolate(&e.text_of("artifactId")?, props))) +} + +/// The go-to-declaration target at `pos` (0-based line / UTF-16 column) of the POM +/// `text` stored at `path` (the current buffer text when the file is open): a +/// `${property}` reference to the `` entry that defines it along the +/// parent chain, or a dependency / plugin to the `` / +/// `` entry that provides its version (a parent POM, followed into +/// the local repository when it is not in the project). `None` when there is no +/// resolvable declaration here. +pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option) -> Option { + let root = xml::parse(text).filter(|e| e.name == "project")?; + let at = offset_at(text, pos); + if let Some((name, _, _)) = property_ref_at(text, at) + && !name.is_empty() + { + return property_target(text, path, name, local_repo); + } + // A `` / `` in a declaration position: its version may come + // from a management entry of this POM or an ancestor's. + let elements = element_path(&root, at); + let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?; + if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") { + return None; // Management entries are declarations; there is nothing to go to. + } + let kind = match e.name.as_str() { + "dependency" => PomGotoKind::ManagedDependency, + _ => PomGotoKind::ManagedPlugin, + }; + managed_target(text, path, e, kind, local_repo) +} + +/// The parent POMs of the POM in `text` at `path`, child-first (its `` refs, +/// via `relativePath` in the project or the local repository), with their texts. +fn parent_chain(path: &Path, text: &str, local_repo: Option) -> Vec<(PathBuf, String)> { + let mut resolver = Resolver::new(local_repo, &[]); + let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut chain = vec![(path.clone(), text.to_owned())]; + let mut dir = path.parent().unwrap_or(Path::new("")).to_path_buf(); + let mut pom = parse_pom(text); + for _ in 0..MAX_PARENT_DEPTH { + let Some(parent) = pom.and_then(|p| p.parent) else { return chain }; + let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain }; + let pp = dunce::canonicalize(&pp).unwrap_or(pp); + let Ok(next) = std::fs::read_to_string(&pp) else { return chain }; + pom = resolver.pom(&pp); + dir = pp.parent().unwrap_or(Path::new("")).to_path_buf(); + chain.push((pp, next)); + } + chain +} + +/// The properties visible in the POM at `chain[i]`: inherited (its parents' and +/// ancestors' own entries) plus its own, and the `project.*` built-ins. +fn properties_at(resolver: &mut Resolver, chain: &[(PathBuf, String)], i: usize) -> HashMap { + let (p, text) = &chain[i]; + let Some(pom) = parse_pom(text) else { return HashMap::new() }; + let dir = p.parent().unwrap_or(Path::new("")).to_path_buf(); + let ctx = resolver.context(p, 0); + resolver.properties_for(&pom, &ctx, &dir) +} + +/// Where the property `name` is declared: the `project.*` built-ins go to their +/// coordinate elements, the rest to the first `` entry along the parent +/// chain declaring it (a child's definition wins over a parent's; an active +/// profile's counts as the POM's own). The winner's path, element range and name. +fn property_target(text: &str, path: &Path, name: &str, local_repo: Option) -> Option { + let name = name.strip_prefix("pom.").map(|n| format!("project.{n}")).unwrap_or_else(|| name.to_owned()); + let resolver = Resolver::new(local_repo.clone(), &[]); + let chain = parent_chain(path, text, local_repo); + // `project.parent.*` (and the legacy `parent.*` spellings) are declared by the + // clicked POM's own `` section. + let parent_key = match name.as_str() { + "project.parent.groupId" + | "project.parent.artifactId" + | "project.parent.version" + | "parent.groupId" + | "parent.version" => { + let key = name.rsplit('.').next().unwrap(); + Some(&name[name.len() - key.len()..]) + } + _ => None, + }; + if let Some(key) = parent_key { + if !matches!(key, "groupId" | "artifactId" | "version") { + return None; // No other `project.parent.*` property has a declaration. + } + let root = xml::parse(&chain[0].1).filter(|e| e.name == "project")?; + let e = root.child("parent")?.child(key)?; + return Some(PomGoto { + path: chain[0].0.clone(), + range: range_of(&chain[0].1, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + // Built-in coordinates: the POM's own element, else the first ancestor declaring one. + if let Some(key) = name.strip_prefix("project.") { + if !matches!(key, "groupId" | "artifactId" | "version" | "packaging" | "name") { + return None; // `project.basedir`, `project.build.directory`… have no element. + } + for (p, text) in &chain { + let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue }; + let Some(e) = root.child(key).filter(|e| !e.text.trim().is_empty()) else { continue }; + return Some(PomGoto { + path: p.clone(), + range: range_of(text, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + return None; + } + // A property of ``: the first POM of the chain (child-first) that + // declares it, at project level or in an active profile. + for (p, text) in &chain { + let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue }; + let active: Vec = parse_pom(text) + .map(|pom| resolver.active(&pom.profiles).map(|pr| pr.id.clone()).collect()) + .unwrap_or_default(); + // Project level first, then the first active profile declaring the property. + let e = root.child("properties").and_then(|props| props.child(&name)).or_else(|| { + root.list("profiles", "profile") + .into_iter() + .find(|profile| { + active.contains(&profile.child("id").map(|id| id.text.trim().to_owned()).unwrap_or_default()) + && profile.child("properties").and_then(|props| props.child(&name)).is_some() + })? + .child("properties")? + .child(&name) + }); + if let Some(e) = e { + return Some(PomGoto { + path: p.clone(), + range: range_of(text, e), + kind: PomGotoKind::Property, + label: name.clone(), + }); + } + } + None +} + +/// The `` / `` entry providing the version +/// of the dependency / plugin element `e`: this POM's own management first, then the +/// parent chain, child-first (the nearest declaration wins). Coordinates are matched +/// after interpolation with each POM's effective properties. +fn managed_target( + text: &str, + path: &Path, + e: &Element, + kind: PomGotoKind, + local_repo: Option, +) -> Option { + let mut resolver = Resolver::new(local_repo.clone(), &[]); + let chain = parent_chain(path, text, local_repo); + let key = coordinates(e, &properties_at(&mut resolver, &chain, 0))?; + let (section, item) = match kind { + PomGotoKind::ManagedDependency => ("dependencyManagement/dependencies", "dependency"), + _ => ("build/pluginManagement/plugins", "plugin"), + }; + for (i, (p, text)) in chain.iter().enumerate() { + let root = xml::parse(text).filter(|e| e.name == "project")?; + let props = properties_at(&mut resolver, &chain, i); + let Some(hit) = root.list(section, item).into_iter().find(|m| coordinates(m, &props).as_ref() == Some(&key)) + else { + continue; + }; + let target = hit.child("artifactId").unwrap_or(hit); + return Some(PomGoto { + path: p.clone(), + range: range_of(text, target), + kind, + label: format!("{}:{}", key.0, key.1), + }); + } + None +} + +/// The range of element `e` in `text`. +fn range_of(text: &str, e: &Element) -> Range { + Range::new(pos_at(text, e.start), pos_at(text, e.end)) +} + #[cfg(test)] mod tests { use super::*; + /// `parent_chain` returns paths normalized by `dunce::canonicalize`, so the + /// expected values compared against them must go through the same call: the + /// temp dirs sit behind symlinks on macOS (`/var` → `/private/var`) and under + /// an 8.3 short name on Windows, and raw paths would not match on those runners. + fn canon(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + } + /// A plugin jar in `repo` whose descriptor declares `goals`. fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) { let dir = repo.join(group.replace('.', "/")).join(artifact).join(version); @@ -2215,6 +2462,325 @@ mod tests { assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}"); } } + + // ---------------------------------------------------- goto declaration + + /// The position of the first character of `needle` (a click on it). + fn at(text: &str, needle: &str) -> Pos { + pos_at(text, text.find(needle).unwrap()) + } + + /// A parent POM and a child of it in `tmp`: the parent at `tmp/pom.xml`, the + /// child at `tmp/app/pom.xml` (found through the default `../pom.xml`). + fn write_parent_child(tmp: &Path, parent: &str, child: &str) { + std::fs::create_dir_all(tmp.join("app")).unwrap(); + std::fs::write(tmp.join("pom.xml"), parent).unwrap(); + std::fs::write(tmp.join("app/pom.xml"), child).unwrap(); + } + + const CHILD_HEADER: &str = r#" + + com.example + parent + 1.0 + + app +"#; + + const PARENT_HEADER: &str = r#" + com.example + parent + 1.0 +"#; + + #[test] + fn a_property_reference_finds_its_definition_in_the_same_pom() { + let text = format!( + r#"{CHILD_HEADER} + 1.2.3 + + + + com.example + lib + ${{example.version}} + + +"# + ); + let path = Path::new("/tmp/pom.xml"); + let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.label, "example.version"); + assert_eq!(goto.path, path); + let from = text.find("").unwrap(); + let to = text.find("").unwrap() + "".len(); + assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to))); + } + + #[test] + fn an_inherited_property_reference_finds_its_definition_in_the_parent_pom() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{example.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + let from = parent.find("").unwrap(); + assert_eq!(goto.range.start, pos_at(&parent, from)); + } + + #[test] + fn a_child_property_wins_over_the_parent_s_definition() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.0.0 + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + 2.0.0 + + + + com.example + lib + ${{example.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.path, canon(&path), "the child's own definition"); + let from = text.find("").unwrap(); + let to = text.find("").unwrap() + "".len(); + assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to))); + assert!(text[from..to].contains("2.0.0")); + } + + #[test] + fn a_managed_dependency_reference_finds_the_managing_declaration_in_the_parent() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + + + + + com.example + example-lib + ${{example.version}} + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + example-lib + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "example-lib"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::ManagedDependency); + assert_eq!(goto.label, "com.example:example-lib"); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + let from = parent.find("example-lib").unwrap(); + let to = from + "example-lib".len(); + assert_eq!(goto.range, Range::new(pos_at(&parent, from), pos_at(&parent, to))); + } + + #[test] + fn a_managed_plugin_reference_finds_the_managing_declaration_in_the_parent() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + + maven-compiler-plugin + + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "maven-compiler-plugin"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::ManagedPlugin); + assert_eq!(goto.label, "org.apache.maven.plugins:maven-compiler-plugin"); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml"))); + } + + #[test] + fn an_inherited_property_is_found_in_a_parent_pom_of_the_local_repository() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repository"); + let parent_dir = repo.join("com/example/parent/1.0"); + std::fs::create_dir_all(&parent_dir).unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + 1.2.3 + +"# + ); + std::fs::write(parent_dir.join("parent-1.0.pom"), &parent).unwrap(); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{example.version}} + + +"# + ); + let path = tmp.path().join("app/pom.xml"); + std::fs::create_dir_all(tmp.path().join("app")).unwrap(); + std::fs::write(&path, &child).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), Some(repo)).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, canon(&parent_dir.join("parent-1.0.pom"))); + assert_eq!(goto.range.start, pos_at(&parent, parent.find("").unwrap())); + } + + #[test] + fn project_version_resolves_to_the_first_pom_declaring_its_own_version() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!(r#"{PARENT_HEADER}pom"#); + let child = format!( + r#"{CHILD_HEADER} + + com.example + lib + ${{project.version}} + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + let goto = goto_declaration(&text, &path, at(&text, "${project.version}"), None).unwrap(); + assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")), "the child has no own "); + assert_eq!(goto.range.start, pos_at(&parent, parent.find("").unwrap())); + } + + #[test] + fn clicks_without_a_resolvable_declaration_find_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let parent = format!( + r#"{PARENT_HEADER} + + + com.example + managed-lib + 1.0 + + + +"# + ); + let child = format!( + r#"{CHILD_HEADER} + + com.example + direct-lib + ${{missing.key}} + + + com.example + other-lib + 2.0 + + +"# + ); + write_parent_child(tmp.path(), &parent, &child); + let path = tmp.path().join("app/pom.xml"); + let text = std::fs::read_to_string(&path).unwrap(); + // The property is declared nowhere along the chain. + assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None); + // The dependency names its own version: it is the declaration. + assert_eq!(goto_declaration(&text, &path, at(&text, "other-lib"), None), None); + // A management entry is a declaration itself, not a reference. + let parent_path = tmp.path().join("pom.xml"); + let parent_text = std::fs::read_to_string(&parent_path).unwrap(); + assert_eq!( + goto_declaration(&parent_text, &parent_path, at(&parent_text, "managed-lib"), None), + None + ); + // Offsets beyond the document, and non-POM documents, find nothing. + assert_eq!(goto_declaration(&text, &path, Pos::new(999, 0), None), None); + assert_eq!(goto_declaration("", &path, Pos::new(0, 3), None), None); + } + + #[test] + fn a_property_reference_after_non_ascii_text_is_still_found() { + let text = format!( + r#"{CHILD_HEADER} + + 1.2.3 + + + + com.example + lib + + ${{example.version}} + + +"# + ); + let path = Path::new("/tmp/pom.xml"); + let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap(); + assert_eq!(goto.kind, PomGotoKind::Property); + assert_eq!(goto.path, path); + } } #[cfg(test)] diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index b390b2cca..0a262c624 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -95,6 +95,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec { "-Dverbose".into(), ] } + +/// The settings' Maven options for the sync runs: the thread count (`-T`, also +/// `--threads`) is left out, because the sync pins its own `-T1` — its console +/// output is parsed as one stream, which a parallel build interleaves. +fn sync_maven_options(options: &[String]) -> Vec { + let mut out = Vec::with_capacity(options.len()); + let mut args = options.iter(); + while let Some(arg) = args.next() { + if arg == "-T" || arg == "--threads" { + args.next(); // the count itself + } else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") { + // `--threads=4` / `-T4`: the count is part of the argument. + } else { + out.push(arg.clone()); + } + } + out +} const GRADLE_CACHE: &str = "gradle.json"; const MAVEN_PROFILES: &str = "maven-profiles.json"; @@ -324,7 +342,7 @@ pub fn sync_maven( // `dependency:tree` output of concurrent modules, which the sync parses // as one stream. mvn is serial anyway, so this only pins the daemon. let mut args: Vec = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()]; - args.extend(options.maven_options.iter().cloned()); + args.extend(sync_maven_options(&options.maven_options)); if options.offline { args.push("-o".into()); } @@ -831,6 +849,19 @@ mod tests { assert_eq!(error_summary("one\nlast line\n\n"), "last line"); } + #[test] + fn maven_sync_leaves_out_the_settings_thread_count() { + // The sync pins its own `-T1`; the user's `-T` (Settings › Maven) would win + // over it on the mvnd command line and interleave the parsed output. + let options = ["-s".to_owned(), "settings.xml".to_owned(), "-T".to_owned(), "4".to_owned()]; + assert_eq!(sync_maven_options(&options), ["-s", "settings.xml"]); + let options = ["--threads".to_owned(), "2C".to_owned(), "-Dmaven.repo.local=/m2".to_owned()]; + assert_eq!(sync_maven_options(&options), ["-Dmaven.repo.local=/m2"]); + assert_eq!(sync_maven_options(&["-T4".to_owned()]), Vec::::new()); + assert_eq!(sync_maven_options(&["--threads=4".to_owned()]), Vec::::new()); + assert!(sync_maven_options(&[]).is_empty()); + } + #[cfg(unix)] #[test] fn maven_sync_runs_the_daemon_command_serially() { diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 8c6671297..1bf899a17 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -1,8 +1,8 @@ //! The file list on the OS clipboard: what Explorer copies when the user //! copies files, so the project view can paste them, and what the project view //! writes so Explorer can paste the files it copied (IDEA does both). The -//! written list comes with the paths as text, so a paste into an editor -//! inserts them instead of doing nothing. +//! written list comes with the file names as text, so a paste into an editor +//! inserts them instead of doing nothing (#1878). //! //! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL //! terminated list of absolute paths, wide when `fWide` is set. The data is set @@ -44,6 +44,21 @@ pub fn set_files(paths: &[PathBuf]) -> bool { imp::set_files(paths) } +/// The text flavor written beside the file list: the files' names, one per +/// line. An editor paste inserts names, not the absolute paths the file list +/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no +/// file name (a root like `C:\`) keeps its whole path. +#[cfg(any(windows, target_os = "macos", test))] +fn names_text(paths: &[PathBuf], separator: &str) -> String { + paths + .iter() + .map(|p| { + p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned()) + }) + .collect::>() + .join(separator) +} + /// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code /// page survive. Empty paths are skipped. pub fn encode(paths: &[PathBuf]) -> Vec { @@ -108,7 +123,7 @@ pub fn decode(bytes: &[u8]) -> Vec { #[cfg(windows)] mod imp { - use super::{HEADER, decode, encode}; + use super::{HEADER, decode, encode, names_text}; use std::ffi::c_void; use std::path::PathBuf; use std::ptr; @@ -220,9 +235,9 @@ mod imp { if bytes.len() <= HEADER { return false; } - // The paths as text, newline-separated, so pasting into an editor - // inserts them (IDEA writes the list and the text together). - let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\r\n"); + // The file names as text: the file list keeps the absolute paths for + // Explorer, while an editor paste inserts the names (#1878). + let text = names_text(paths, "\r\n"); // SAFETY: the blocks are allocated, filled, handed to the clipboard and // freed only on the paths where the clipboard did not take ownership. unsafe { @@ -242,7 +257,7 @@ mod imp { return false; } // Replacing the list also drops the text the user copied before; - // the paths' own text is set back with it. + // the file names are set back with it. if EmptyClipboard() == 0 { CloseClipboard(); GlobalFree(memory); @@ -278,6 +293,8 @@ mod imp { use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL}; use std::path::PathBuf; + use super::names_text; + /// `NSURL` as the one class to read, limited to file URLs: a web link the /// user copied is not a file to paste. fn read_file_urls( @@ -338,13 +355,11 @@ mod imp { urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect(); let board = NSPasteboard::generalPasteboard(); // Replacing the list also drops the text the user copied before; - // the paths' own text is set back with it, so an editor paste - // inserts them (IDEA writes the list and the text together). + // the file names are set back as text, so an editor paste inserts + // names, not paths (#1878). board.clearContents(); let written = board.writeObjects(&NSArray::from_slice(&writers)); - let text = NSString::from_str( - &paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\n"), - ); + let text = NSString::from_str(&names_text(paths, "\n")); board.setString_forType(&text, unsafe { NSPasteboardTypeString }); written }) @@ -415,6 +430,15 @@ mod tests { assert_eq!(decode(&far), Vec::::new()); } + #[test] + fn the_text_flavor_is_the_file_names() { + assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub"); + // A path with no file name (the filesystem root) keeps its whole path. + let root = PathBuf::from("/"); + assert!(root.file_name().is_none()); + assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy()); + } + #[test] fn an_ansi_list_is_still_read_before_the_terminator() { let mut bytes = vec![0u8; HEADER]; diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 4806bf2cd..c5b32ef98 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -252,6 +252,8 @@ pub struct SmartKeysSettings { /// Reformat on paste: `none` | `indentBlock` | `indentEachLine`. pub reformat_on_paste: String, pub smart_indent: bool, + /// Enter › Insert documentation comment stub: Enter after `/**` closes the comment. + pub insert_doc_stub: bool, /// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. pub backspace_unindent: String, } @@ -267,6 +269,7 @@ impl Default for SmartKeysSettings { tab_out_of_pair: true, reformat_on_paste: "indentEachLine".into(), smart_indent: true, + insert_doc_stub: true, backspace_unindent: "autoindent".into(), } } diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 6cef110cb..298547f89 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -229,6 +229,9 @@ pub enum DebugEvent { thread_id: i64, started: bool, }, + /// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it + /// in the debug console reaches its input. + ProgramInput, BreakpointsChanged { path: crate::IpcPath, }, diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 7bbb35dad..aa7db963a 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod jsonc; pub mod output_encoding; pub mod process; pub mod project_dir; +pub mod project_roots; pub mod proxy_settings; pub mod remote_settings; pub mod settings; diff --git a/crates/ide-core/src/project_roots.rs b/crates/ide-core/src/project_roots.rs new file mode 100644 index 000000000..458c9e96d --- /dev/null +++ b/crates/ide-core/src/project_roots.rs @@ -0,0 +1,197 @@ +//! Shared filesystem rules for discovering builds and resolving their owners. + +use std::path::{Component, Path, PathBuf}; + +pub const SEARCH_DEPTH: usize = 3; + +pub fn children(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut dirs: Vec<_> = entries + .flatten() + .filter(|entry| { + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + return false; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + !name.starts_with('.') + && !matches!( + name.as_ref(), + "node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv" + ) + }) + .map(|entry| entry.path()) + .collect(); + dirs.sort(); + dirs +} + +pub fn discover(root: &Path, markers: &[&str]) -> Vec { + discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file())) +} + +pub fn discover_matching( + root: &Path, + max_depth: usize, + stop_at_match: bool, + marked: impl Fn(&Path) -> bool, +) -> Vec { + let mut found = Vec::new(); + let mut pending = vec![(root.to_path_buf(), 0)]; + while let Some((dir, depth)) = pending.pop() { + if marked(&dir) { + found.push(dir.clone()); + if stop_at_match { + continue; + } + } + // A source folder may itself be a build root (e.g. src/pom.xml), but + // source trees without their own build marker are never searched. + let source = dir.join("src"); + if source.is_dir() && !source.is_symlink() && marked(&source) { + found.push(source); + } + if depth < max_depth { + pending.extend( + children(&dir) + .into_iter() + .rev() + .filter(|child| !child.ends_with("src")) + .map(|child| (child, depth + 1)), + ); + } + } + found.sort(); + found.dedup(); + found +} + +/// The closest package manager declaration or lockfile, shared by build and run. +pub fn package_manager(root: &Path) -> &'static str { + for dir in root.ancestors() { + let package = std::fs::read(dir.join("package.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) { + match pm.split('@').next().unwrap_or_default() { + "pnpm" => return "pnpm", + "yarn" => return "yarn", + "bun" => return "bun", + "npm" => return "npm", + _ => {} + } + } + for (marker, pm) in [ + ("pnpm-lock.yaml", "pnpm"), + ("yarn.lock", "yarn"), + ("bun.lock", "bun"), + ("bun.lockb", "bun"), + ("package-lock.json", "npm"), + ] { + if dir.join(marker).is_file() { + return pm; + } + } + } + "npm" +} + +pub fn resolve(root: &Path, path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in root.join(path).components() { + match component { + Component::CurDir => {} + Component::ParentDir if out.file_name().is_some() => { + out.pop(); + } + component => out.push(component), + } + } + out +} + +pub fn relative(root: &Path, path: &Path) -> PathBuf { + let path = resolve(root, path); + path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_manager_declarations_are_shared_by_build_and_run() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap(); + let child = temp.path().join("packages/web"); + std::fs::create_dir_all(&child).unwrap(); + assert_eq!(package_manager(&child), "pnpm"); + std::fs::write(child.join("package.json"), r#"{"packageManager":"yarn@4.0"}"#).unwrap(); + assert_eq!(package_manager(&child), "yarn"); + } + + #[test] + fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let markers = [ + "package.json", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle.kts", + "pyproject.toml", + "composer.json", + "Gemfile", + "CMakeLists.txt", + "project.godot", + "deno.json", + "Makefile", + "justfile", + ]; + for marker in markers { + for dir in [ + "apps/one", + "services/backend/two", + "src", + "a/b/c/src", + "too/deep/to/find", + "node_modules/ignored", + "target/ignored", + "build/ignored", + "vendor/ignored", + ".hidden", + "src/ignored", + ] { + let path = root.join(dir).join(marker); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + assert_eq!( + discover(root, &[marker]), + ["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir)) + ); + } + std::fs::write(root.join("Cargo.toml"), "").unwrap(); + assert_eq!(discover(root, &["Cargo.toml"]), [root]); + } + + #[cfg(unix)] + #[test] + fn discovery_never_follows_directory_symlinks() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("real")).unwrap(); + std::fs::write(temp.path().join("real/go.mod"), "").unwrap(); + std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap(); + assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]); + } + + #[test] + fn paths_keep_component_boundaries_and_external_roots() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("workspace"); + assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api")); + assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other")); + assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web")); + } +} diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 8e41bc14d..62442a36f 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -1,5 +1,5 @@ -//! Settings of remote work: SSH configurations, SFTP deployment and SSH run -//! targets. The fields hang off `Settings` in `settings.rs`. +//! Settings of remote work: SSH configurations, SFTP deployment and run +//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`. use serde::{Deserialize, Serialize}; use specta::Type; @@ -140,22 +140,55 @@ pub struct DeploymentMapping { pub web: String, } -/// Build, Execution, Deployment › Run Targets: run configurations on another machine. +/// Where a run target runs configurations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RunTargetKind { + /// Another machine over SSH, the project synchronised to its folder. + #[default] + Ssh, + /// A new container of an image (`docker run --rm`), the project bind-mounted. + Docker, + /// A new container of a Compose service (`docker compose run --rm`). + Compose, +} + +/// Build, Execution, Deployment › Run Targets: run configurations on another machine +/// or in a container. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct RunTarget { + pub kind: RunTargetKind, pub id: String, pub name: String, - /// [`SshConfiguration::id`]. + /// [`SshConfiguration::id`] (SSH). pub ssh_config: String, - /// Remote folder the project is synchronised to and run from. + /// Remote folder the project is synchronised to and run from (SSH). pub project_root: String, - /// Upload changed project files before each run. + /// Upload changed project files before each run (SSH). pub sync_before_run: bool, /// Interpreters on the target; empty = the one on the remote `PATH`. pub python: String, pub node: String, pub java: String, + /// Id of a Docker connection of the Services tool window (Docker, Compose); + /// empty = the CLI's own daemon. + pub docker_connection: String, + /// Image to run (Docker). + pub image: String, + /// Compose files, relative to the project; empty = the project's default one (Compose). + pub compose_files: Vec, + /// Service to run (Compose). + pub service: String, + /// Where the project is in the container; empty = `/workspaces/` (Docker), + /// the service's bind mount of the project (Compose). + pub container_root: String, + /// Published ports, as `docker run --publish` takes them (`8000:8000`). + pub ports: Vec, + /// More options for `docker run` / `docker compose run`, shell-like quoted. + pub run_options: String, + /// Run as the image's user instead of the project folder's owner (Linux). + pub image_user: bool, } #[cfg(test)] @@ -176,4 +209,24 @@ mod deployment_tests { ); } } + + #[test] + fn run_target_kind_defaults_to_ssh_and_round_trips() { + let legacy: RunTarget = + serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap(); + assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh")); + let docker = RunTarget { + kind: RunTargetKind::Compose, + docker_connection: "local".into(), + compose_files: vec!["compose.dev.yaml".into()], + service: "web".into(), + ports: vec!["8000:8000".into()], + image_user: true, + ..legacy + }; + let json = serde_json::to_value(&docker).unwrap(); + assert_eq!(json["kind"], "compose"); + assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"])); + assert_eq!(serde_json::from_value::(json).unwrap(), docker); + } } diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 6f65eff4c..101178039 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -268,6 +268,8 @@ pub struct GeneralSettings { pub power_save_mode: bool, /// File › Rename Project… (project layer): the name shown for the project; empty = its folder name. pub project_name: String, + /// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. + pub update_source: String, } impl Default for GeneralSettings { @@ -282,6 +284,7 @@ impl Default for GeneralSettings { date_format: DateFormatSettings::default(), power_save_mode: false, project_name: String::new(), + update_source: "auto".into(), } } } @@ -364,6 +367,8 @@ pub struct AppearanceSettings { pub red_green_vision: bool, pub different_tool_window_background: bool, pub compact_mode: bool, + /// IDEA "Use smaller indents in trees": tool window trees indent less per level. + pub small_tree_indents: bool, pub full_path_in_title: bool, pub project_colors_in_toolbar: bool, pub dnd_with_alt_only: bool, @@ -434,6 +439,7 @@ impl Default for AppearanceSettings { red_green_vision: false, different_tool_window_background: false, compact_mode: false, + small_tree_indents: false, full_path_in_title: false, project_colors_in_toolbar: true, dnd_with_alt_only: false, @@ -601,7 +607,7 @@ impl Default for FileSettings { } } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct LanguagesSettings { pub python: PythonSettings, @@ -612,11 +618,34 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: ExternalFormatterSettings, pub oxfmt: ExternalFormatterSettings, + pub ruff: ExternalFormatterSettings, pub java: JavaSettings, pub ruby: RubySettings, pub markdown: MarkdownSettings, } +impl Default for LanguagesSettings { + fn default() -> Self { + Self { + python: PythonSettings::default(), + node: NodeSettings::default(), + php: PhpSettings::default(), + rust: RustSettings::default(), + go: GoSettings::default(), + typescript: TypeScriptSettings::default(), + prettier: ExternalFormatterSettings::default(), + oxfmt: ExternalFormatterSettings::default(), + ruff: ExternalFormatterSettings { + languages: vec!["python".into()], + ..ExternalFormatterSettings::default() + }, + java: JavaSettings::default(), + ruby: RubySettings::default(), + markdown: MarkdownSettings::default(), + } + } +} + /// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] @@ -630,6 +659,7 @@ impl LanguagesSettings { match tool { ExternalFormatter::Prettier => &self.prettier, ExternalFormatter::Oxfmt => &self.oxfmt, + ExternalFormatter::Ruff => &self.ruff, } } } @@ -640,6 +670,7 @@ impl LanguagesSettings { pub enum ExternalFormatter { Prettier, Oxfmt, + Ruff, } impl ExternalFormatter { @@ -648,16 +679,19 @@ impl ExternalFormatter { match self { Self::Prettier => "prettier", Self::Oxfmt => "oxfmt", + Self::Ruff => "ruff", } } } -/// An external formatter (Prettier, oxfmt) and the languages it formats. +/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct ExternalFormatterSettings { - /// Empty path: project node_modules, managed installation, then PATH. + /// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. pub path: String, + /// Ruff only: directory containing its configuration, or a configuration file. + pub configuration_directory: String, pub languages: Vec, /// Prefer this formatter even when a language server offers formatting. pub prefer: bool, @@ -668,6 +702,7 @@ impl Default for ExternalFormatterSettings { fn default() -> Self { Self { path: String::new(), + configuration_directory: String::new(), languages: [ "markdown", "yaml", @@ -722,6 +757,10 @@ pub struct PhpSettings { /// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of /// `composer.json` (`wordpress`). pub stubs: Vec, + /// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`, + /// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute, + /// `~/`, or relative to the project. + pub phpstan_config: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -880,6 +919,24 @@ pub struct MavenSettings { /// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else /// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on. pub mvnd_home: String, + /// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core + /// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module). + pub threads: String, +} + +impl MavenSettings { + /// The `-T` value when it is one Maven accepts: a thread count (`4`) or a + /// core multiplier with an optional decimal fraction (`1C`, `2.5C`). + /// Anything else is dropped: a bad `-T` would make every Maven run fail. + pub fn thread_count(&self) -> Option<&str> { + let value = self.threads.trim(); + let number = value.strip_suffix('C').unwrap_or(value); + let mut decimal = number.split('.'); + let well_formed = number.parse::().is_ok_and(|n| n > 0.0) + && decimal.clone().count() <= 2 + && decimal.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())); + well_formed.then_some(value) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -1122,6 +1179,41 @@ mod maven_linkage_tests { assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome")); assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); } + + #[test] + fn thread_count_takes_counts_and_core_multipliers_only() { + let threads = |t: &str| { + let m = MavenSettings { threads: t.into(), ..Default::default() }; + m.thread_count().map(str::to_owned) + }; + // Empty = Maven's default (one thread per module). + assert_eq!(threads("").as_deref(), None); + assert_eq!(threads(" ").as_deref(), None); + // A thread count, and a core multiplier whose fraction may be decimal. + assert_eq!(threads("4").as_deref(), Some("4")); + assert_eq!(threads(" 2 ").as_deref(), Some("2")); + assert_eq!(threads("1C").as_deref(), Some("1C")); + assert_eq!(threads("2.0C").as_deref(), Some("2.0C")); + assert_eq!(threads("0.5C").as_deref(), Some("0.5C")); + // Everything else a `-T` would make every Maven run reject is dropped. + assert_eq!(threads("abc").as_deref(), None); + assert_eq!(threads("4x").as_deref(), None); + assert_eq!(threads("0").as_deref(), None); + assert_eq!(threads("-4").as_deref(), None); + assert_eq!(threads("1e3").as_deref(), None); + assert_eq!(threads("2..5C").as_deref(), None); + } + + #[test] + fn thread_count_round_trips_and_defaults_to_empty() { + // Old settings files predate the thread count; `serde(default)` keeps them loadable. + let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap(); + assert!(old.threads.is_empty() && old.thread_count().is_none()); + let settings = MavenSettings { threads: "2C".into(), ..old }; + let encoded = serde_json::to_string(&settings).unwrap(); + assert!(encoded.contains("\"threads\":\"2C\"")); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); + } } #[cfg(test)] diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs index 266f5bae0..e088df724 100644 --- a/crates/ide-core/src/tool_path.rs +++ b/crates/ide-core/src/tool_path.rs @@ -30,6 +30,21 @@ pub fn search_path() -> OsString { std::env::join_paths(dirs).unwrap_or_default() } +/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which +/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one. +pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')"; + +/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist. +pub fn gem_bin_dirs(output: &str) -> Vec { + let mut dirs: Vec = Vec::new(); + for dir in output.lines().map(str::trim).map(PathBuf::from) { + if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + /// Resolves a command on the shared path, or an explicit path relative to `root`. pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option { let path = Path::new(command); @@ -128,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec { } /// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9. -fn version_key(name: &str) -> Vec { +pub fn version_key(name: &str) -> Vec { name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect() } diff --git a/crates/ide-coverage/src/import.rs b/crates/ide-coverage/src/import.rs index 669f1b8cd..e35c24c1c 100644 --- a/crates/ide-coverage/src/import.rs +++ b/crates/ide-coverage/src/import.rs @@ -1,8 +1,8 @@ //! "Import a report collected in CI from disk": the format is sniffed from the -//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another +//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another //! machine are mapped into the project by their longest existing suffix. -use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov}; +use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov}; use ide_core::{IdeError, IdeResult}; use std::path::{Component, Path, PathBuf}; @@ -11,6 +11,7 @@ pub(crate) enum Format { Lcov, GoProfile, Jacoco, + SimpleCov, } pub(crate) fn detect(text: &str) -> Option { @@ -18,6 +19,9 @@ pub(crate) fn detect(text: &str) -> Option { if head.starts_with('<') { return head.contains(" IdeResult { let text = std::fs::read_to_string(path)?; let mut suite = match detect(&text) { Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"), + Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"), Some(Format::GoProfile) => { let (module, module_dir) = go_module_for(root, &text); goprofile::parse(&text, &module, &module_dir).finish("go test -cover") @@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult { } None => { return Err(IdeError::invalid(format!( - "{} is not an lcov tracefile, Go cover profile or JaCoCo XML report", + "{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset", path.display() ))); } @@ -125,9 +130,29 @@ mod tests { Some(Format::Jacoco) ); assert_eq!(detect(""), None); + assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov)); + assert_eq!(detect("{\"name\": \"package.json\"}"), None); assert_eq!(detect("hello"), None); } + #[test] + fn imports_ci_simplecov_with_foreign_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + std::fs::create_dir_all(root.join("lib")).unwrap(); + std::fs::write(root.join("lib/a.rb"), "").unwrap(); + let report = tmp.path().join(".resultset.json"); + std::fs::write( + &report, + r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#, + ) + .unwrap(); + let suite = import(&report, &root).unwrap(); + assert_eq!(suite.runner, "SimpleCov"); + assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb")); + assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1)); + } + #[test] fn imports_ci_lcov_with_foreign_paths() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-coverage/src/lib.rs b/crates/ide-coverage/src/lib.rs index 757812036..707f936da 100644 --- a/crates/ide-coverage/src/lib.rs +++ b/crates/ide-coverage/src/lib.rs @@ -10,6 +10,7 @@ //! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov | //! | Node.js scripts | `c8` | lcov | //! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov | +//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset | //! | Go (`go test`) | `-coverprofile` | Go cover profile | //! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML | //! @@ -20,6 +21,7 @@ mod import; mod jacoco; mod lcov; mod plan; +mod simplecov; pub use import::import; pub use plan::prepare; @@ -83,6 +85,8 @@ pub enum CoverageReportSpec { Lcov { runner: String, path: IpcPath, base: IpcPath }, /// `go test -coverprofile`; import paths under `module` map to `module_dir`. GoProfile { path: IpcPath, module: String, module_dir: IpcPath }, + /// SimpleCov's `.resultset.json`; relative paths resolve against `base`. + SimpleCov { path: IpcPath, base: IpcPath }, /// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. Jacoco { dir: IpcPath }, /// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o `). @@ -185,6 +189,10 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult { let text = read_report(path)?; Ok(lcov::parse(&text, base).finish(runner)) } + CoverageReportSpec::SimpleCov { path, base } => { + let text = read_report(path)?; + Ok(simplecov::parse(&text, base)?.finish("SimpleCov")) + } CoverageReportSpec::GoProfile { path, module, module_dir } => { let text = read_report(path)?; Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover")) diff --git a/crates/ide-coverage/src/plan.rs b/crates/ide-coverage/src/plan.rs index 2368aaaa4..5355ffd51 100644 --- a/crates/ide-coverage/src/plan.rs +++ b/crates/ide-coverage/src/plan.rs @@ -67,6 +67,7 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared "gradle" | "gradlew" => gradle(config, &dir, out), "npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out), "node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out), + "ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out), "deno" => unsupported("Coverage of Deno tasks is not supported yet."), "java" => unsupported( "Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.", @@ -234,6 +235,65 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar ready("c8", config, path(&c8), args, report) } +/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself. +const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage. +begin + require "simplecov" +rescue LoadError + # `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on + # the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov. + return +end +SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR")) +SimpleCov.start do + add_filter %r{^/(test|spec|features)/} + enable_coverage :branch +end +"#; + +/// Files where a project conventionally starts SimpleCov. +const SIMPLECOV_STARTERS: &[&str] = + &[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"]; + +fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file()); + let mentions = |file: &str| { + [dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov"))) + }; + if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") { + return missing( + "simplecov", + "Ruby coverage needs the simplecov gem in the project's Gemfile.", + Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)), + ); + } + let starts = |d: &Path| { + SIMPLECOV_STARTERS.iter().any(|f| { + std::fs::read_to_string(d.join(f)) + .is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov")) + }) + }; + let mut config = config.clone(); + let resultset = if [dir, root].iter().any(|d| starts(d)) { + // The project's own configuration decides where the report goes (default `coverage/`). + // Drop the previous run's data: SimpleCov merges into it. + let results = dir.join("coverage"); + let _ = std::fs::remove_file(results.join(".resultset.json")); + results.join(".resultset.json") + } else { + let preload = out.join("openidea_simplecov.rb"); + if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) { + return unsupported(format!("Cannot write the SimpleCov preload: {e}")); + } + config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out)); + // `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them). + ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true); + out.join(".resultset.json") + }; + let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() }; + ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report) +} + fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared { if py.target.is_empty() { return unsupported("Coverage needs a Python script or module to run."); @@ -398,6 +458,74 @@ mod tests { )); } + /// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov + /// on the load path it must step aside instead of failing the launcher. + #[test] + fn simplecov_preload_tolerates_a_ruby_without_the_gem() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out dir"); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap(); + let status = std::process::Command::new("ruby") + .args(["-e", "exit 0"]) + .env("RUBYLIB", &out) + .env("RUBYOPT", "-ropenidea_simplecov") + .env("OPENIDEA_COVERAGE_DIR", &out) + .env("GEM_HOME", dir.path().join("empty gems")) + .env("GEM_PATH", dir.path().join("empty gems")) + .status(); + // No Ruby on this machine: nothing to check. + let Ok(status) = status else { return }; + assert!(status.success()); + } + + #[test] + fn ruby_runs_get_simplecov() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // The IDE's data directory has spaces on macOS (`Application Support`). + let out = root.join("out dir"); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap(); + let config = cfg("rspec", "bundle", &["exec", "rspec"]); + // Bundler project without the gem: install hint. + match prepare(root, &config, &out) { + CoveragePrepared::MissingTool { tool, install: Some(i), .. } => { + assert_eq!(tool, "simplecov"); + assert_eq!(i.args, ["add", "simplecov", "--group", "test"]); + } + other => panic!("{other:?}"), + } + // Gem present, never started: preloaded through RUBYOPT, report in `out`. + std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap(); + let p = prepare(root, &config, &out); + let (l, report) = launch(&p); + assert_eq!( + (l.command.as_str(), l.args.as_slice()), + ("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice()) + ); + assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov")); + assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out)); + assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy()); + assert!(out.join("openidea_simplecov.rb").is_file()); + assert!( + matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out)) + ); + // Started by the project: untouched environment, stale data dropped, report in `coverage/`. + std::fs::create_dir_all(root.join("test")).unwrap(); + std::fs::create_dir_all(root.join("coverage")).unwrap(); + std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap(); + std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap(); + let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out); + let (l, report) = launch(&p); + assert!(!l.env.contains_key("RUBYOPT")); + assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]); + assert!(!root.join("coverage/.resultset.json").exists()); + assert!( + matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json")) + ); + } + #[test] fn unknown_commands_are_unsupported() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-coverage/src/simplecov.rs b/crates/ide-coverage/src/simplecov.rs new file mode 100644 index 000000000..f93c41759 --- /dev/null +++ b/crates/ide-coverage/src/simplecov.rs @@ -0,0 +1,74 @@ +//! SimpleCov's `coverage/.resultset.json`: +//! +//! ```json +//! { "RSpec": { "timestamp": 1, "coverage": { +//! "/abs/lib/a.rb": { "lines": [null, 1, 0], +//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } }, +//! "/abs/lib/legacy.rb": [null, 1] } } } +//! ``` +//! +//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based. +//! A branch belongs to the line its condition starts on (third number of its key). + +use crate::Accumulator; +use ide_core::{IdeError, IdeResult}; +use serde_json::Value; +use std::path::Path; + +/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key. +fn start_line(key: &str) -> Option { + key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok() +} + +pub(crate) fn parse(text: &str, base: &Path) -> IdeResult { + let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?; + let mut acc = Accumulator::default(); + // One entry per test command ("RSpec", "Minitest", …); their coverage is merged. + for suite in json.as_object().into_iter().flat_map(|o| o.values()) { + let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue }; + for (name, data) in files { + let p = Path::new(name); + let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) }; + acc.file(&path); + let lines = data.get("lines").or(Some(data)).and_then(Value::as_array); + for (i, hits) in lines.into_iter().flatten().enumerate() { + if let Some(hits) = hits.as_u64() { + acc.line(&path, i as u32, hits); + } + } + for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() { + let Some(line) = start_line(cond) else { continue }; + for (branch, hits) in branches.as_object().into_iter().flatten() { + acc.has_branches = true; + acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0)); + } + } + } + } + Ok(acc) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_lines_branches_and_merges_suites() { + let text = r#"{ + "RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0], + "branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}}, + "Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}} + }"#; + let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov"); + assert_eq!(suite.files.len(), 2); + let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap(); + assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::>(), [(1, 3), (2, 3)]); + assert_eq!((a.branches_total, a.branches_covered), (2, 1)); + assert_eq!(a.lines[0].branches_total, 2); + let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap(); + assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb")); + assert_eq!(old.lines[0].hits, 1); + assert!(suite.has_branches); + assert!(parse("nope", Path::new("/p")).is_err()); + } +} diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml index a902900b7..757915cca 100644 --- a/crates/ide-dap/Cargo.toml +++ b/crates/ide-dap/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true [dependencies] +futures.workspace = true tempfile.workspace = true ide-core.workspace = true ide-language.workspace = true @@ -16,6 +17,7 @@ specta.workspace = true tokio = { workspace = true, features = ["net"] } parking_lot.workspace = true memchr = "2" +portable-pty.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 25c44cdd6..458a38eef 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option { "codelldb", &["--port", PORT_PLACEHOLDER], "tcp", - "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH", + "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers", ), "kotlin-debug-adapter" => cfg( "kotlin-debug-adapter", &[], "stdio", - "Install the Kotlin Debug Adapter from Settings > Language Servers", + "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers", ), // Windows installs `python.exe`; `python3.exe` there is at most a Store alias. "debugpy" => cfg( @@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option { // Without a host it listens on `localhost`, which may resolve to ::1 only. &[PORT_PLACEHOLDER, "127.0.0.1"], "tcp", - "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`", + "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers", ), "delve" => cfg( "dlv", @@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option { "netcoredbg", &["--interpreter=vscode"], "stdio", - "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)", + "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers", ), "lldb-dap" => { cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)") @@ -196,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec { dirs } +/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers +/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is +/// found like Run finds it, even when the login shell's `PATH` didn't reach the app. +fn adapter_path(env: &[(String, String)]) -> Option { + let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" }); + (!own).then(ide_core::tool_path::search_path) +} + /// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots. fn search_dirs() -> Vec { let mut dirs: Vec = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default(); - dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::>()).unwrap_or_default()); + dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path())); if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"] { @@ -464,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher { None => args, }; let mut cmd = Command::new(&binary); + if let Some(path) = adapter_path(&spec.env) { + cmd.env("PATH", path); + } ide_core::process::prepare(cmd.as_std_mut()); cmd.args(&args) .envs(spec.env.iter().map(|(k, v)| (k, v))) @@ -592,6 +603,21 @@ async fn connect_with_retries( mod tests { use super::*; + #[test] + fn adapters_run_on_the_tool_search_path() { + let search = ide_core::tool_path::search_path(); + assert_eq!(adapter_path(&[]), Some(search.clone())); + assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search)); + // A launch that sets its own (a selected Go SDK's bin first) keeps it. + assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None); + // The search path reaches the usual install spots even when this process's `PATH` doesn't. + #[cfg(unix)] + { + let dirs: Vec = std::env::split_paths(&ide_core::tool_path::search_path()).collect(); + assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}"); + } + } + #[tokio::test] async fn program_output_is_forwarded_without_the_adapter_banner() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); @@ -668,7 +694,7 @@ mod tests { assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none()); let err = missing(&spec); assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); - assert!(err.message.contains("netcoredbg/releases")); + assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers")); } #[tokio::test] diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 5d3012377..524c10bea 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -19,6 +19,7 @@ mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; use connection::{Connection, DapError}; +use futures::future::join_all; use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink}; use ide_language::LanguageRegistry; use parking_lot::Mutex; @@ -510,6 +511,18 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities { } } +/// States of a file's line breakpoints that no session has been sent. +fn unsent(breakpoints: &[Breakpoint]) -> Vec { + breakpoints + .iter() + .map(|b| BreakpointState { + line: b.line, + verified: false, + message: (!b.enabled).then(|| "Disabled".to_owned()), + }) + .collect() +} + /// States for breakpoints no session has verified yet. fn unverified(enabled: impl Iterator) -> Vec { enabled @@ -517,6 +530,34 @@ fn unverified(enabled: impl Iterator) -> Vec { .collect() } +/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows +/// `__debug_bin.exe` plus `__debug_bin.exe` in DAP mode) and removes it on a +/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep +/// the leftovers once the session is down. Runs after `session_closed`, so Delve +/// has already exited or been killed and holds no file locks; a leftover of a +/// still-building run is removed on that run's own shutdown. +fn remove_delve_binaries(cwd: &Path) { + let Ok(entries) = std::fs::read_dir(cwd) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + // Windows writes `__debug_bin.exe` (and `.exe` in DAP mode); other + // platforms keep the bare name. Suffixes like `.go` are user files. + let suffix = name.strip_prefix("__debug_bin"); + let leftover = suffix.is_some_and(|s| { + s.is_empty() + || s == ".exe" + || (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit())) + }); + if leftover + && entry.file_type().is_ok_and(|t| t.is_file()) + && let Err(e) = std::fs::remove_file(entry.path()) + { + tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display()); + } + } +} + pub(crate) fn variable_from(v: &Value) -> Variable { let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); Variable { @@ -877,12 +918,20 @@ impl DebugManager { let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await; } - let files: Vec<(PathBuf, Vec)> = - self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect(); + // A fresh adapter has no breakpoints, so a file with none enabled needs no request; the + // others go out together, as VS Code sends them, instead of one round trip per file. + let (files, idle): (Vec<_>, Vec<_>) = self + .breakpoints + .lock() + .iter() + .map(|(p, b)| (p.clone(), b.clone())) + .partition(|(_, b)| b.iter().any(|b| b.enabled)); + let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await; // Breakpoint states follow the owner's first session. let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id); - for (path, bps) in files { - match s.send_breakpoints(&path, &bps, None).await { + let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps)))); + for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) { + match result { Ok(states) => { if first { self.states.lock().insert(path.clone(), states); @@ -960,6 +1009,10 @@ impl DebugManager { } s.conn.close(); self.session_closed(s); + if s.adapter == "delve" && s.spec.server_host.is_none() { + let cwd = s.spec.cwd.clone(); + tokio::task::spawn_blocking(move || remove_delve_binaries(&cwd)); + } } /// Breakpoints are kept per file even without a session and sent on start. @@ -990,16 +1043,7 @@ impl DebugManager { Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0), } } - let states = result.unwrap_or_else(|| { - breakpoints - .iter() - .map(|b| BreakpointState { - line: b.line, - verified: false, - message: (!b.enabled).then(|| "Disabled".to_owned()), - }) - .collect() - }); + let states = result.unwrap_or_else(|| unsent(&breakpoints)); self.states.lock().insert(path, states.clone()); Ok(states) } @@ -1266,6 +1310,16 @@ impl DebugManager { Ok(()) } + /// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]). + pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> { + let session = self.session(s)?; + let mut program = session.program.lock(); + let program = program + .as_mut() + .ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?; + program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}"))) + } + /// Flutter's DAP adapter: injects the changed sources into the running app. /// Not part of the DAP standard; adapters that do not know the request reject it. pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> { @@ -1317,13 +1371,26 @@ impl DebugManager { /// Sends empty breakpoint lists (line, function, data) to `session` until its next /// stop, when the stored ones are restored. `except` is left to the caller. async fn suspend_breakpoints(&self, session: &Arc, except: Option<&Path>) -> IdeResult<()> { - let files: Vec = self.breakpoints.lock().keys().cloned().collect(); - for path in files { - if Some(path.as_path()) == except { - continue; + // While paused the adapter holds the stored lists: only files with something enabled change. + let files: Vec = self + .breakpoints + .lock() + .iter() + .filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled)) + .map(|(path, _)| path.clone()) + .collect(); + let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await; + let mut failed = None; + for (path, result) in files.into_iter().zip(sent) { + match result { + Ok(_) => { + session.pending_restore.lock().files.insert(path); + } + Err(e) => failed = failed.or(Some(e)), } - session.send_breakpoints(&path, &[], None).await?; - session.pending_restore.lock().files.insert(path); + } + if let Some(e) = failed { + return Err(e.into()); } if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") { session.send_function_breakpoints(&[]).await?; @@ -1339,9 +1406,11 @@ impl DebugManager { /// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced. pub(crate) async fn restore_breakpoints(&self, session: &Arc) { let pending = std::mem::take(&mut *session.pending_restore.lock()); - for path in pending.files { - let bps = self.stored_breakpoints(&path); - if let Err(e) = session.send_breakpoints(&path, &bps, None).await { + let files: Vec<(PathBuf, Vec)> = + pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect(); + let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await; + for ((path, _), result) in files.iter().zip(sent) { + if let Err(e) = result { tracing::debug!("restoring breakpoints of {} failed: {e}", path.display()); } } diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index f7bc69c3b..e7356fbbb 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -43,6 +43,8 @@ pub(crate) struct Session { pub adapter_process: Mutex>, /// Processes started for `runInTerminal`. pub debuggees: Mutex>, + /// The terminal a `runInTerminal` program runs in: typed input goes to it. + pub program: Mutex>, /// Set once `configurationDone` was sent; breakpoint updates go out live after that. pub configured: AtomicBool, pub initialized: watch::Sender, @@ -90,6 +92,7 @@ impl Session { caps: Mutex::new(Value::Null), adapter_process: Mutex::new(child), debuggees: Mutex::new(Vec::new()), + program: Mutex::new(None), configured: AtomicBool::new(false), initialized: watch::channel(false).0, terminated: AtomicBool::new(false), @@ -266,6 +269,9 @@ impl Session { for mut c in std::mem::take(&mut *self.debuggees.lock()) { let _ = c.start_kill(); } + if let Some(mut program) = self.program.lock().take() { + let _ = program.child.kill(); + } } } @@ -451,8 +457,25 @@ fn on_reverse_request(mgr: &Arc, s: &Arc, seq: i64, comma } } -/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console. +/// A `runInTerminal` program and the pseudo-terminal it runs in. +pub(crate) struct ProgramTerminal { + writer: Box, + child: Box, + _master: Box, +} + +impl ProgramTerminal { + pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> { + self.writer.write_all(text.as_bytes())?; + self.writer.flush() + } +} + +/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB +/// starts a launcher that gives the program its terminal, and the program reads input only from +/// a terminal), forwards its output to the console, and keeps the terminal for typed input. pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result { + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; let argv: Vec = args .get("args") .and_then(Value::as_array) @@ -461,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result cmd.env(k, v), + None => cmd.env_remove(k), + } + } + for (k, v) in ide_core::process::shell_env() { + cmd.env(k, v); + } + cmd.env("TERM", "xterm-256color"); + match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) { + Some(cwd) => cmd.cwd(cwd), + None => cmd.cwd(&s.spec.cwd), } if let Some(env) = args.get("env").and_then(Value::as_object) { for (k, v) in env { @@ -480,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result) { + let session = s.id; + let sink = s.owner.sink.clone(); + let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || { + let mut buf = vec![0u8; 8192]; + let mut pending: Vec = Vec::new(); + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => pending.extend_from_slice(&buf[..n]), + } + // Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk. + let mut valid = match std::str::from_utf8(&pending) { + Ok(_) => pending.len(), + Err(e) if e.error_len().is_none() => e.valid_up_to(), + Err(_) => pending.len(), + }; + if valid > 0 && pending[valid - 1] == b'\r' { + valid -= 1; + } + let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n"); + pending.drain(..valid); + if !output.is_empty() { + sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } }); + } + } + if !pending.is_empty() { + let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n"); + sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } }); + } + }); +} + fn forward_output(s: &Session, mut stream: R, category: &'static str) { let session = s.id; let sink = s.owner.sink.clone(); diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index e1efd7101..2f728a9f3 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -17,6 +17,8 @@ struct Script { start_debugging: bool, ignore_configuration_done: bool, source_path: Option, + /// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one). + hold_set_breakpoints: usize, } struct FakeLauncher { @@ -49,6 +51,7 @@ async fn fake_adapter(stream let mut r = BufReader::new(r); let mut seq = 1000; let mut pending_launch: Option = None; + let mut held: Vec = Vec::new(); macro_rules! send { ($v:expr) => {{ let mut v: Value = $v; @@ -101,14 +104,20 @@ async fn fake_adapter(stream send!(json!({"type": "event", "event": "initialized"})); } "setBreakpoints" => { - let bps: Vec = msg["arguments"]["breakpoints"] - .as_array() - .unwrap() - .iter() - .enumerate() - .map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]})) - .collect(); - ok!(msg, json!({"breakpoints": bps})); + held.push(msg); + if held.len() < script.hold_set_breakpoints { + continue; + } + for msg in std::mem::take(&mut held) { + let bps: Vec = msg["arguments"]["breakpoints"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]})) + .collect(); + ok!(msg, json!({"breakpoints": bps})); + } } "setExceptionBreakpoints" => ok!(msg, json!({})), "setFunctionBreakpoints" | "setDataBreakpoints" => { @@ -468,6 +477,40 @@ async fn run_in_terminal_spawns_debuggee() { assert!(response["body"]["processId"].as_u64().unwrap() > 0); } +#[cfg(unix)] +#[tokio::test] +async fn run_in_terminal_program_reads_typed_input() { + let h = harness(Script { + // CodeLLDB's launcher asks to clear the terminal; the console keeps its output. + run_in_terminal: Some(vec![ + "sh".into(), + "-c".into(), + "[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(), + "--clear-screen".into(), + ]), + ..Script::default() + }); + let session = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await; + h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await; + h.manager.send_input(session, "Bob\n").unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await; + let output: String = h + .events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => { + Some(output.clone()) + } + _ => None, + }) + .collect(); + assert!(!output.contains('\r'), "{output:?}"); + h.manager.stop(session).await.unwrap(); + assert!(h.manager.send_input(session, "x\n").is_err()); +} + #[test] fn launch_arguments_merge_and_defaults() { let mut c = config(); @@ -887,6 +930,7 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() { let b = PathBuf::from("/p/b.py"); h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap(); h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap(); + h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap(); h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap(); let id = h.start(config()).await.unwrap(); h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; @@ -915,6 +959,24 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() { h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap(); assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}])); assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([])); + assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore"); +} + +#[tokio::test] +async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() { + let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() }); + h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap(); + h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap(); + let off = PathBuf::from("/p/off.py"); + h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap(); + // Waiting for each answer before the next request would stall until the request timeout. + tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap(); + let paths: Vec = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect(); + assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]); + assert_eq!( + h.manager.breakpoint_states(&off), + [BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }] + ); } #[tokio::test] @@ -1285,3 +1347,26 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() { assert!(!is_flutter_project(&root, None)); std::fs::remove_dir_all(&root).unwrap(); } + +#[test] +fn delve_leftovers_are_removed_without_touching_other_files() { + let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] { + std::fs::write(dir.join(name), b"bin").unwrap(); + } + // Lookalikes that are not Delve's own binaries stay: sources, a future + // in-progress build, and a directory that happens to share the name. + for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] { + std::fs::write(dir.join(name), b"keep").unwrap(); + } + std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap(); + remove_delve_binaries(&dir); + for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] { + assert!(!dir.join(name).exists(), "{name} should be gone"); + } + for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] { + assert!(dir.join(name).exists(), "{name} should stay"); + } + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/ide-dap/tests/php_debug.rs b/crates/ide-dap/tests/php_debug.rs new file mode 100644 index 000000000..8493760b6 --- /dev/null +++ b/crates/ide-dap/tests/php_debug.rs @@ -0,0 +1,140 @@ +//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug) +//! configuration does: a launch without a program makes it listen, and every PHP +//! run that Xdebug connects stops at the breakpoint set in the local copy of the +//! file, through `pathMappings`, until the session is stopped. A second listener on +//! the same port fails to start and names the port. +//! +//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with +//! `PHP_DEBUG_ADAPTER= cargo test -p ide-dap --test php_debug -- --ignored --nocapture` +//! (`PHP_BIN` picks the PHP executable, default `php`). + +use ide_core::event::DebugEvent; +use ide_core::ipc_path::to_ipc; +use ide_core::{IdeEvent, SharedSink}; +use ide_dap::*; +use ide_language::LanguageRegistry; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SCRIPT: &str = ">, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent { + let start = Instant::now(); + loop { + if let Some(e) = events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()), + _ => None, + }) { + return e; + } + assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock()); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig { + // As remote.ts builds it for Remote root = `served`, Local root = `local`. + let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]); + LaunchConfig { + name: "PHP Remote".into(), + adapter: "php-debug".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: Some(local.to_path_buf().into()), + env: BTreeMap::new(), + extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()), + } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn listens_for_xdebug_until_stopped_with_path_mappings() { + let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else { + eprintln!("PHP_DEBUG_ADAPTER not set; skipping"); + return; + }; + let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into()); + let dir = tempfile::tempdir().unwrap(); + // The project here, and the copy PHP runs from (a container's /var/www/html). + let (local, served) = (dir.path().join("local"), dir.path().join("served")); + for d in [&local, &served] { + std::fs::create_dir_all(d).unwrap(); + std::fs::write(d.join("index.php"), SCRIPT).unwrap(); + } + // Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows). + let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap())); + let (local, served) = (resolved(&local), resolved(&served)); + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + + let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap())); + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let local_script = local.join("index.php"); + manager + .set_breakpoints( + local_script.clone(), + vec![Breakpoint { + line: BREAK_LINE, + condition: None, + hit_condition: None, + log_message: None, + enabled: true, + }], + ) + .await + .unwrap(); + let id = manager + .start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| { + Some(adapter.clone()) + }) + .await + .unwrap_or_else(|e| panic!("start: {e:?}")); + + // Requests in a row each stop; the session keeps listening between them. + for request in 1..=2 { + events.lock().clear(); + let run = std::process::Command::new(&php) + .args([ + "-dxdebug.mode=debug", + "-dxdebug.start_with_request=yes", + "-dxdebug.client_host=127.0.0.1", + &format!("-dxdebug.client_port={port}"), + "index.php", + ]) + .current_dir(&served) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await; + let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() }; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].line, BREAK_LINE, "request {request}"); + let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok()); + assert_eq!(top, local_script.canonicalize().ok(), "request {request}"); + manager.resume(id, thread_id).await.unwrap(); + wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await; + let out = run.wait_with_output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}"); + assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}"); + } + + // Another listener on the port (another IDE): the start fails and says why. + let err = manager + .start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone())) + .await + .expect_err("a second listener on the same port"); + assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}"); + assert_eq!(manager.sessions(None).len(), 1); + + manager.stop(id).await.unwrap(); + assert!(manager.sessions(None).is_empty()); +} diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index e72eccf23..709cacc15 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -17,6 +17,7 @@ parking_lot.workspace = true futures.workspace = true sqlx.workspace = true tiberius.workspace = true +oracle.workspace = true bb8.workspace = true tokio-util = { workspace = true, features = ["compat"] } dirs.workspace = true diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 020dc0c6c..af0825e06 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -107,6 +107,7 @@ pub enum Pool { Mysql(MySqlPool), SqlServer(Bb8Pool), Clickhouse(crate::clickhouse::ClickhousePool), + Oracle(crate::oracle::OraclePool), } /// Runs `$body` with `$p` bound to the concrete pool of each driver. @@ -116,7 +117,7 @@ macro_rules! with_pool { Pool::Sqlite($p) => $body, Pool::Postgres($p) => $body, Pool::Mysql($p) => $body, - Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(), + Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(), } }; } @@ -262,7 +263,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O } } -fn hex(bytes: &[u8]) -> String { +pub(crate) fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 + bytes.len() * 2); s.push_str("0x"); for b in bytes { @@ -386,7 +387,7 @@ macro_rules! fetch { } /// Columns, rows, rows affected, more rows beyond the limit, time of the first answer. -type Fetched = (Vec, Vec>>, Option, bool, Option); +pub(crate) type Fetched = (Vec, Vec>>, Option, bool, Option); async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> { // Transaction control must be a batch: sp_executesql rejects a changed transaction count. @@ -503,6 +504,18 @@ impl Setup { } } DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()), + DbDriver::Oracle => { + let schema = o.default_schema.trim(); + if !schema.is_empty() { + if !is_identifier(schema) { + return Err(IdeError::invalid(format!("invalid schema name: {schema}"))); + } + s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}")); + } + if !o.time_zone.trim().is_empty() { + s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim()))); + } + } _ => {} } for p in &source.properties { @@ -527,6 +540,9 @@ impl Setup { (DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()), (DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()), (DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))), + (DbDriver::Oracle, _) => { + s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value))) + } _ => s.params.push((name, value.to_owned())), } } @@ -549,6 +565,15 @@ impl Pool { Pool::Mysql(_) => DbDriver::Mysql, Pool::SqlServer(_) => DbDriver::SqlServer, Pool::Clickhouse(_) => DbDriver::Clickhouse, + Pool::Oracle(_) => DbDriver::Oracle, + } + } + + /// The keep-alive ping (Oracle needs a FROM clause). + pub fn ping_sql(&self) -> &'static str { + match self { + Pool::Oracle(_) => "SELECT 1 FROM DUAL", + _ => "SELECT 1", } } @@ -559,6 +584,9 @@ impl Pool { if source.driver == DbDriver::Clickhouse { return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse); } + if source.driver == DbDriver::Oracle { + return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle); + } let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30)); let init = Arc::new(setup.statements); // Runs the session statements (startup script…) on every new connection. @@ -721,12 +749,13 @@ impl Pool { Ok(Pool::SqlServer(pool)) } DbDriver::Clickhouse => unreachable!(), + DbDriver::Oracle => unreachable!(), } } pub async fn close(&self) { match self { - Pool::SqlServer(_) => {} + Pool::SqlServer(_) | Pool::Oracle(_) => {} Pool::Clickhouse(p) => p.close(), _ => with_pool!(self, p => p.close().await), } @@ -735,6 +764,7 @@ impl Pool { pub fn is_closed(&self) -> bool { match self { Pool::SqlServer(_) => false, + Pool::Oracle(p) => p.is_closed(), Pool::Clickhouse(p) => p.is_closed(), _ => with_pool!(self, p => p.is_closed()), } @@ -745,6 +775,11 @@ impl Pool { if let Pool::Clickhouse(p) = self { return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows); } + if let Pool::Oracle(p) = self { + let mut conn = p.get().await?; + let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?; + return Ok(fetched.1); + } let fetched: IdeResult = match self { Pool::SqlServer(p) => { let mut conn = p.get_owned().await.map_err(mssql_pool_err)?; @@ -811,6 +846,15 @@ impl Pool { } result } + Pool::Oracle(p) => { + let mut conn = p.get().await?; + let result = crate::oracle::transaction(&mut conn, statements).await; + if result.is_err() { + // An open transaction must never return to the pool with the connection. + conn.discard_inner(); + } + result + } _ => with_pool!(self, p => { let mut tx = p.begin().await.map_err(db_err)?; let mut total = 0; @@ -838,6 +882,7 @@ pub enum Held { Mysql(PoolConnection), SqlServer(Box>), Clickhouse(crate::clickhouse::ClickhousePool), + Oracle(crate::oracle::OracleConnection), } impl Held { @@ -853,6 +898,7 @@ impl Held { } Held::Clickhouse(p.clone()) } + Pool::Oracle(p) => Held::Oracle(p.get().await?), }) } @@ -861,13 +907,20 @@ impl Held { match self { Held::SqlServer(c) => mssql_command(c, sql).await, Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()), + Held::Oracle(c) => { + let sql = sql.trim(); + if sql.is_empty() { + return Ok(()); // Oracle transactions open implicitly. + } + crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop) + } other => { let sql = sqlx::AssertSqlSafe(sql.to_owned()); match other { Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), - Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(), + Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(), } .map_err(db_err) } @@ -889,6 +942,7 @@ impl Held { c.0.take(); } Held::Clickhouse(_) => {} + Held::Oracle(mut c) => c.discard_inner(), } } @@ -959,6 +1013,7 @@ impl Held { () = cancel.cancelled() => return Err(IdeError::cancelled()), } } + Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?, Held::Clickhouse(_) => unreachable!(), }; let (columns, rows, affected, has_more, first) = fetched; @@ -980,6 +1035,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str { match driver { DbDriver::Mysql => "START TRANSACTION", DbDriver::SqlServer => "BEGIN TRAN", + DbDriver::Oracle => "", // opens implicitly with autocommit off DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN", } } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index b549395d2..dabe98f99 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -5,6 +5,7 @@ mod clickhouse; pub mod conn; pub mod history; +mod oracle; pub mod plan; pub mod secrets; pub mod source; @@ -303,7 +304,7 @@ fn keep_alive(pool: &Arc, every: Duration) { loop { tick.tick().await; let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break }; - if let Err(e) = p.rows("SELECT 1", 1).await { + if let Err(e) = p.rows(p.ping_sql(), 1).await { tracing::debug!("keep-alive: {}", e.message); } } @@ -614,6 +615,7 @@ impl DbManager { DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()", DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())", DbDriver::SqlServer => "SELECT @@VERSION", + DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1", }; let result = pool.rows(version, 1).await; pool.close().await; @@ -905,6 +907,33 @@ impl DbManager { .next(); ddl.map(|d| format!("{d};\n")).ok_or_else(missing) } + DbDriver::Oracle => { + let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name)); + let kind = pool + .rows( + &format!( + "SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \ + AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1" + ), + 1, + ) + .await? + .into_iter() + .next() + .and_then(|r| r.into_iter().next().flatten()) + .ok_or_else(missing)?; + let ddl = cells( + pool.rows( + &format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")), + 1, + ) + .await?, + 0, + ) + .into_iter() + .next(); + ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing) + } DbDriver::Postgres => { let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next(); let (oid, kind) = match rel.as_deref() { @@ -1057,6 +1086,29 @@ impl DbManager { /// The execution plan of `statement` (explained, not run). pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> { let driver = self.pool(id).await?.driver(); + if driver == DbDriver::Oracle { + // EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately. + let plan_id = format!( + "OPENIDEA{}", + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()) + ); + self.run( + id, + &format!( + "EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}", + plan_id, + statement.trim().trim_end_matches(';') + ), + 0, + MAX_META_ROWS, + cancel, + ) + .await?; + let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?; + let nodes = plan::parse(driver, &result); + let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await; + return nodes; + } let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?; plan::parse(driver, &result) } diff --git a/crates/ide-db/src/oracle.rs b/crates/ide-db/src/oracle.rs new file mode 100644 index 000000000..5ece541a7 --- /dev/null +++ b/crates/ide-db/src/oracle.rs @@ -0,0 +1,639 @@ +//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C). +//! The Oracle client library (Instant Client) is loaded once per process from +//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking +//! pool, and cancelling a statement closes its connection. + +use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex}; +use crate::source::{DataSource, DbDriver, DbTxMode}; +use bb8::ManageConnection; +use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; +use oracle::sql_type::{Blob, Clob, OracleType}; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Characters of a LOB column shown in the grid; catalog text reads more. +const MAX_GRID_LOB: u64 = 4096; +const MAX_TEXT_LOB: u64 = 1 << 16; + +/// Everything a new connection needs, built once per pool. +#[derive(Debug)] +struct State { + user: String, + password: String, + /// EZConnect `//host:port/service`, a TNS descriptor or an alias. + connect: String, + init: Vec, + auto_commit: bool, + read_only: bool, + statement_cache: Option, + timeout: Duration, +} + +impl State { + fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult { + let user = source.user.trim(); + if user.is_empty() { + return Err(IdeError::invalid("Oracle data sources need a user name")); + } + Ok(Self { + user: user.to_owned(), + password: password.unwrap_or_default().to_owned(), + connect: connect_string(source), + init: setup.statements.clone(), + auto_commit: source.options.transaction != DbTxMode::Manual, + read_only: source.read_only, + statement_cache: setup.statement_cache.map(|n| n as u32), + timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)), + }) + } +} + +/// `//host:port/service`; the database field may also hold a TNS descriptor or alias. +fn connect_string(source: &DataSource) -> String { + let db = source.database.trim(); + if db.starts_with('(') || db.contains('=') { + return db.to_owned(); + } + let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }; + let port = source.port.unwrap_or(1521); + if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") } +} + +/// The expanded Instant Client directory, `None` when the search path is used. +fn client_dir(dir: &str) -> IdeResult> { + let dir = dir.trim(); + if dir.is_empty() { + return Ok(None); + } + let expanded: PathBuf = ide_ssh::expand(dir); + if !expanded.is_dir() { + return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display()))); + } + Ok(Some(expanded.to_string_lossy().into_owned())) +} + +/// Loads the Oracle client once per process; the first data source's directory wins. +/// Failures are not cached: a corrected directory works without restarting. +fn init_client(dir: &str) -> IdeResult<()> { + static LOADED: std::sync::Mutex> = std::sync::Mutex::new(None); + let dir = dir.trim(); + let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(used) = loaded.as_deref() { + if !dir.is_empty() && !used.is_empty() && used != dir { + tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}"); + } + return Ok(()); + } + let mut params = oracle::InitParams::new(); + if let Some(lib_dir) = client_dir(dir)? { + params + .oracle_client_lib_dir(lib_dir) + .map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?; + } + params.init().map_err(|e| { + let hint = if e.to_string().contains("DPI-1047") { + format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME") + } else { + e.to_string() + }; + IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}")) + })?; + *loaded = Some(dir.to_owned()); + Ok(()) +} + +/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error. +#[derive(Debug, Clone)] +pub struct OracleError(Arc); + +impl OracleError { + fn new(e: oracle::Error) -> Self { + Self(Arc::new(e)) + } + + fn message(msg: impl Into) -> Self { + Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into())) + } + + fn into_ide(self) -> IdeError { + oracle_err(clone_db(&self.0)) + } +} + +/// Database errors (the interesting ones) rebuild cheaply for cloning. +fn clone_db(e: &oracle::Error) -> oracle::Error { + match e.db_error() { + Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()), + None => message_error(&e.to_string()), + } +} + +fn message_error(msg: &str) -> oracle::Error { + oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned()) +} + +/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx. +pub(crate) fn oracle_err(e: oracle::Error) -> IdeError { + let Some(db) = e.db_error() else { + return IdeError::new(ErrorKind::InvalidInput, e.to_string()); + }; + let msg = db.message().to_owned(); + if msg.contains("DPI-1047") { + return IdeError::new( + ErrorKind::Io, + format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"), + ); + } + let code = db.code(); + let full = format!("[ORA-{code:05}] {msg}"); + match code { + 1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full), + 12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full), + _ => IdeError::new(ErrorKind::InvalidInput, full), + } +} + +#[derive(Debug)] +pub struct OraclePool { + state: Arc, + pool: Arc>, +} + +impl OraclePool { + pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult { + init_client(&source.options.instant_client)?; + let state = Arc::new(State::of(source, password, setup)?); + let pool = bb8::Pool::builder() + .max_size(4) + .connection_timeout(state.timeout) + .build(OracleConnectionManager(state.clone())) + .await + .map_err(|e| e.into_ide())?; + // bb8 connects lazily and reports failed attempts as a bare timeout, which + // would turn a wrong password into "timed out connecting to the database". + let opened = tokio::task::spawn_blocking({ + let state = state.clone(); + move || open(&state) + }) + .await + .map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))? + .map_err(oracle_err)?; + let _ = pool.add(OracleHandle { conn: Some(opened) }); + Ok(Self { state, pool: Arc::new(pool) }) + } + + /// A connection taken out of the pool for one statement or a held session. + pub async fn get(&self) -> IdeResult { + let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?; + Ok(OracleConnection { state: self.state.clone(), pooled }) + } + + pub fn is_closed(&self) -> bool { + let state = self.pool.state(); + state.connections == 0 && state.idle_connections == 0 + } +} + +fn pool_err(e: bb8::RunError) -> OracleError { + match e { + bb8::RunError::User(e) => e, + bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"), + } +} + +/// A pooled connection's payload; `conn` is `None` once the connection is +/// discarded (a cancelled statement leaves it in the blocking task). +pub struct OracleHandle { + conn: Option, +} + +pub struct OracleConnectionManager(Arc); + +impl ManageConnection for OracleConnectionManager { + type Connection = OracleHandle; + type Error = OracleError; + + async fn connect(&self) -> Result { + let state = self.0.clone(); + let opened = tokio::task::spawn_blocking(move || open(&state)) + .await + .map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?; + let conn = opened.map_err(OracleError::new)?; + Ok(OracleHandle { conn: Some(conn) }) + } + + async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { + let state = self.0.clone(); + let Some(raw) = conn.conn.take() else { + return Err(OracleError::message("Oracle connection is closed")); + }; + let ping = tokio::time::timeout( + state.timeout, + tokio::task::spawn_blocking(move || { + let result = (|| -> oracle::Result<()> { + // `execute` rejects selects, so read one row. + let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?; + let mut rows = stmt.query(&[])?; + if let Some(row) = rows.next() { + row?; + } + Ok(()) + })(); + (raw, result) + }), + ) + .await; + match ping { + Ok(Ok((raw, Ok(())))) => { + conn.conn = Some(raw); + Ok(()) + } + Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)), + Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))), + // Timed out (the task keeps the connection and closes it): discard this one. + Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")), + } + } + + fn has_broken(&self, conn: &mut Self::Connection) -> bool { + conn.conn.is_none() + } +} + +/// A connection borrowed from the pool. It returns to the pool when dropped, +/// unless its statement was cancelled (then it is discarded). +pub struct OracleConnection { + state: Arc, + pooled: bb8::PooledConnection<'static, OracleConnectionManager>, +} + +impl OracleConnection { + pub fn is_closed(&self) -> bool { + self.pooled.conn.is_none() + } + + /// Drops the connection instead of returning it; the server rolls an open + /// transaction back. + pub(crate) fn discard_inner(&mut self) { + self.pooled.conn.take(); + } +} + +fn open(state: &State) -> oracle::Result { + let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone()); + connector.driver_name("OpenIDEA"); + if let Some(n) = state.statement_cache { + connector.stmt_cache_size(n); + } + let mut conn = connector.connect()?; + conn.set_autocommit(state.auto_commit); + for sql in &state.init { + statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?; + } + Ok(conn) +} + +/// Runs `f` with the connection on the blocking pool. Cancelling drops the +/// connection instead of returning it (its statement keeps running server-side). +async fn with_conn( + c: &mut OracleConnection, + cancel: &CancellationToken, + f: impl FnOnce(&mut oracle::Connection) -> IdeResult + Send + 'static, +) -> IdeResult +where + T: Send + 'static, +{ + let Some(conn) = c.pooled.conn.take() else { + return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed")); + }; + let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult)>(1); + tokio::task::spawn_blocking(move || { + let mut slot = Some(conn); + let result = match slot.as_mut() { + Some(conn) => f(conn), + None => unreachable!("connection taken above"), + }; + // A closed receiver (cancelled) drops the connection, closing it. + let _ = tx.blocking_send((slot.take().expect("some"), result)); + }); + tokio::select! { + item = rx.recv() => match item { + Some((conn, result)) => { + c.pooled.conn = Some(conn); + result + } + None => Err(IdeError::internal("the Oracle connection task failed")), + }, + () = cancel.cancelled() => { + rx.close(); + Err(IdeError::cancelled()) + } + } +} + +/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`). +pub(crate) async fn run( + c: &mut OracleConnection, + sql: &str, + skip: usize, + max: usize, + mode: Cells, + cancel: &CancellationToken, +) -> IdeResult { + let state = c.state.clone(); + let sql = sql.to_owned(); + with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await +} + +/// The table editor's edits in one transaction. +pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult { + if c.state.read_only { + return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed")); + } + let script = statements.iter().map(|(sql, _)| sql.clone()).collect::>(); + let expect: Vec = statements.iter().map(|(_, e)| *e).collect(); + let apply = move |conn: &mut oracle::Connection| -> IdeResult { + conn.set_autocommit(false); + let mut total = 0; + for (sql, expect_one) in script.iter().zip(&expect) { + let mut stmt = conn.statement(sql).build().map_err(oracle_err)?; + stmt.execute(&[]).map_err(oracle_err)?; + let n = stmt.row_count().map_err(oracle_err)?; + if *expect_one && n != 1 { + conn.rollback().ok(); + return Err(IdeError::invalid(format!( + "{sql}: expected to change 1 row, changed {n}; nothing was saved" + ))); + } + total += n; + } + conn.commit().map_err(oracle_err)?; + Ok(total) + }; + let cancel = CancellationToken::new(); + with_conn(c, &cancel, apply).await +} + +/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the +/// first query's rows are the result, otherwise the total rows changed. +fn statements( + conn: &mut oracle::Connection, + state: &State, + sql: &str, + skip: usize, + max: usize, + mode: Cells, +) -> IdeResult { + conn.set_autocommit(state.auto_commit); + let mut query: Option = None; + let mut affected: u64 = 0; + let mut first: Option = None; + for s in crate::split::split(sql, DbDriver::Oracle) { + if state.read_only && is_write(&s.text) { + return Err(IdeError::new( + ErrorKind::PermissionDenied, + format!("read-only connection: {} statements are not allowed", first_word(&s.text)), + )); + } + let r = statement(conn, &s.text, skip, max, mode)?; + first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now)); + if r.0.is_empty() { + affected += r.2.unwrap_or(0); + } else if query.is_none() { + query = Some(r); + } + } + Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first))) +} + +/// Reads one statement; `skip` rows are fetched and dropped (result pagination). +fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult { + let started = Instant::now(); + // LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs). + let mut stmt = conn + .statement(sql) + .fetch_array_size((max as u32).clamp(1, 1000) + 1) + .lob_locator() + .build() + .map_err(oracle_err)?; + match stmt.statement_type() { + oracle::StatementType::Select => { + let rows = stmt.query(&[]).map_err(oracle_err)?; + let columns: Vec = rows + .column_info() + .iter() + .map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() }) + .collect(); + let mut out: Vec>> = Vec::new(); + let mut has_more = false; + let mut to_skip = skip; + for row in rows { + if to_skip > 0 { + to_skip -= 1; + continue; + } + if out.len() >= max { + has_more = true; + break; + } + let row = row.map_err(oracle_err)?; + out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect()); + } + Ok((columns, out, None, has_more, Some(started))) + } + _ => { + stmt.execute(&[]).map_err(oracle_err)?; + let affected = stmt.row_count().map_err(oracle_err)?; + Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started))) + } + } +} + +fn first_word(sql: &str) -> String { + sql.lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with("--")) + .and_then(|l| l.split_whitespace().next()) + .unwrap_or("") + .to_ascii_uppercase() +} + +/// Whether `sql` changes data or schema (read-only connections refuse it). +fn is_write(sql: &str) -> bool { + !matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET") +} + +fn cell(v: &oracle::SqlValue, mode: Cells) -> Option { + if v.is_null().unwrap_or(false) { + return None; + } + match v.oracle_type().ok() { + Some(OracleType::CLOB | OracleType::NCLOB) => { + let lob: Clob = v.get().ok()?; + let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB }; + let mut s = String::new(); + lob.take(cap).read_to_string(&mut s).ok()?; + Some(s) + } + Some(OracleType::BLOB) => { + let lob: Blob = v.get().ok()?; + let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB }; + let mut buf = Vec::new(); + lob.take(cap).read_to_end(&mut buf).ok()?; + Some(hex(&buf)) + } + _ => v.get::().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_strings() { + let s = |db: &str| DataSource { + driver: DbDriver::Oracle, + host: "db.local".into(), + port: Some(1521), + database: db.into(), + ..Default::default() + }; + assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1"); + assert_eq!(connect_string(&s("")), "//db.local:1521"); + assert_eq!( + connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }), + "//localhost:1521/ORCL" + ); + assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))"); + assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB"); + } + + #[test] + fn write_detection() { + assert!(!is_write("SELECT 1 FROM DUAL")); + assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q")); + assert!(!is_write("DESC users")); + assert!(is_write("INSERT INTO t VALUES (1)")); + assert!(is_write("begin\n null;\nend;")); + assert!(is_write("DROP TABLE t")); + } + + #[test] + fn missing_user_is_rejected_before_connecting() { + let setup = Setup::default(); + let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err(); + assert!(err.message.contains("user name")); + let state = State::of( + &DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() }, + Some("tiger"), + &setup, + ) + .unwrap(); + assert_eq!( + (state.user.as_str(), state.password.as_str(), state.connect.as_str()), + ("scott", "tiger", "//localhost:1521") + ); + assert!(state.auto_commit); + } + + #[test] + fn oracle_errors_map_to_kinds() { + let auth = oracle_err(message_error("x")).kind; // no database error: invalid input + assert_eq!(auth, ErrorKind::InvalidInput); + } + + #[tokio::test] + async fn a_discarded_connection_is_broken_but_a_live_one_is_not() { + // No server is reachable here, so the pooled connection is a husk: `has_broken` + // decides whether bb8 keeps it, which is what cancellation relies on. + let manager = OracleConnectionManager(Arc::new( + State::of( + &DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() }, + None, + &Setup::default(), + ) + .unwrap(), + )); + let mut broken = OracleHandle { conn: None }; + assert!(manager.has_broken(&mut broken)); + // A connection that cannot be opened reports the failure without panicking + // (a client-load error, or ORA-12541 when a client is available). + match manager.connect().await { + Ok(_) => unreachable!("no server is listening"), + Err(err) => assert!(!err.into_ide().message.is_empty()), + } + } + + #[test] + fn connect_reports_a_missing_client_directory() { + let err = client_dir("/nonexistent/instantclient").expect_err("no such directory"); + assert!(err.message.contains("Instant Client directory not found"), "{}", err.message); + assert!(client_dir("").expect("the search path").is_none()); + } + + /// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is + /// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the + /// Instant Client when it is not on the search path). + /// + /// Checking out a connection runs `is_valid`, so this fails if the ping is + /// not a query (bb8 silently retries a rejected check-out until it times out). + #[tokio::test] + async fn a_pooled_connection_runs_queries() { + let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return }; + let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service"); + let (user, password) = auth.split_once('/').expect("user/password"); + let (host, rest) = addr.split_once(':').expect("host:port/service"); + let (port, service) = rest.split_once('/').expect("port/service"); + let source = DataSource { + driver: DbDriver::Oracle, + host: host.into(), + port: Some(port.parse().expect("port")), + database: service.into(), + user: user.into(), + options: crate::source::DbOptions { + instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(), + ..Default::default() + }, + ..Default::default() + }; + // A missing or wrong password asks for one (the UI prompts on PermissionDenied). + for wrong in [None, Some("wrong")] { + let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") }; + assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message); + } + let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect"); + let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1")); + + // The result grid reads later pages by skipping rows client-side. + let page = pool + .execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new()) + .await + .expect("second page"); + let values: Vec = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect(); + assert_eq!(values, ["5", "6", "7"]); + assert!(page.has_more); + + // PL/SQL units reach the server with their closing `END;`. + pool.rows("begin null; end;", 0).await.expect("anonymous block"); + pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block"); + let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;"; + let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7")); + pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure"); + let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\ + create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/"; + pool.rows(package, 0).await.expect("package spec and body"); + let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8")); + pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up"); + + // LOB cells are read through locators. + let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs"); + assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())])); + } +} diff --git a/crates/ide-db/src/plan.rs b/crates/ide-db/src/plan.rs index 32b217b6e..db4f22deb 100644 --- a/crates/ide-db/src/plan.rs +++ b/crates/ide-db/src/plan.rs @@ -38,13 +38,28 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String { DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"), DbDriver::Mysql => format!("EXPLAIN {s}"), DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"), + DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"), } } +/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`). +pub fn oracle_plan_query(plan_id: &str) -> String { + format!( + "SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \ + FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id" + ) +} + +/// Removes a read plan (PLAN_TABLE keeps rows until the session ends). +pub fn oracle_plan_cleanup(plan_id: &str) -> String { + format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'") +} + /// Reads the result of [`explain_statement`]. pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult> { match driver { DbDriver::Sqlite => Ok(sqlite(&result.rows)), + DbDriver::Oracle => Ok(oracle(&result.rows)), DbDriver::Postgres | DbDriver::Clickhouse => { let text: String = result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::>().join("\n"); @@ -148,6 +163,40 @@ fn postgres(node: &Value, parent: Option, out: &mut Vec) { } } +/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`. +fn oracle(rows: &[Vec>]) -> Vec { + let mut ids: Vec = Vec::new(); + let mut out = Vec::new(); + for r in rows { + let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default(); + let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4)); + let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32); + let op = format!("{operation} {options}").trim().to_owned(); + let (operation, params) = match (operation.as_str(), options.as_str()) { + ("TABLE ACCESS", "FULL") => ("Full Scan".into(), object), + ("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object), + ("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object), + ("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object), + ("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object), + ("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object), + ("SORT", "ORDER BY") => ("Order By".into(), String::new()), + ("SORT", "GROUP BY") => ("Group By".into(), String::new()), + ("SORT", "UNIQUE") => ("Distinct".into(), String::new()), + ("HASH", "GROUP BY") => ("Group By".into(), String::new()), + _ => (op, object), + }; + let mut properties = Vec::new(); + for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] { + if let Some(v) = r.get(value).cloned().flatten() { + properties.push(prop(name, v)); + } + } + out.push(PlanNode { parent, operation, params, properties }); + ids.push(id); + } + out +} + /// MySQL's tabular `EXPLAIN`: one row per table access, flat. fn mysql(names: &[&str], rows: &[Vec>]) -> Vec { let col = |r: &[Option], n: &str| { @@ -202,6 +251,49 @@ mod tests { assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1"); assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1"); assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1"); + assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1"); + let q = oracle_plan_query("OI1"); + assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id")); + assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'")); + } + + #[test] + fn reads_oracle_plan_table_rows() { + let r = result( + &["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"], + vec![ + vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")], + vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")], + vec![ + Some("4"), + Some("2"), + Some("TABLE ACCESS"), + Some("FULL"), + Some("orders"), + Some("2"), + Some("41"), + Some("1230"), + ], + vec![ + Some("5"), + Some("2"), + Some("INDEX"), + Some("UNIQUE SCAN"), + Some("c_pk"), + Some("0"), + Some("1"), + Some("6"), + ], + ], + ); + let plan = parse(DbDriver::Oracle, &r).unwrap(); + let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect(); + assert_eq!( + ops, + [("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")] + ); + assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1))); + assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3")); } #[test] diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index 1acb4ea40..da8ddec2f 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -20,6 +20,8 @@ pub enum DbDriver { /// Microsoft SQL Server. SqlServer, Clickhouse, + /// Oracle Database (via Oracle Instant Client). + Oracle, } impl DbDriver { @@ -30,6 +32,7 @@ impl DbDriver { DbDriver::Mysql => Some(3306), DbDriver::SqlServer => Some(1433), DbDriver::Clickhouse => Some(8123), + DbDriver::Oracle => Some(1521), } } } @@ -85,6 +88,8 @@ pub struct DbOptions { pub auto_disconnect_secs: Option, /// PostgreSQL: show only the configured database, its schemas right under the data source. pub single_database: bool, + /// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path). + pub instant_client: String, } /// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`. @@ -261,6 +266,7 @@ pub fn scheme_driver(scheme: &str) -> Option { "sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer), "sqlite" | "sqlite3" => Some(DbDriver::Sqlite), "clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse), + "oracle" | "oracledb" => Some(DbDriver::Oracle), _ => None, } } @@ -273,6 +279,12 @@ pub fn parse_url(raw: &str) -> IdeResult { let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url); let (scheme, rest) = url.split_once(':').ok_or_else(bad)?; let driver = scheme_driver(scheme).ok_or_else(bad)?; + // JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix). + let rest = if driver == DbDriver::Oracle { + rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest) + } else { + rest + }; if driver == DbDriver::Sqlite { let file = rest.strip_prefix("//").unwrap_or(rest); let file = file.split('?').next().unwrap_or_default(); @@ -540,7 +552,11 @@ mod tests { assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres)); assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql)); assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db")); - assert!(parse_url("oracle://x").is_err()); + let u = parse_url("oracle://scott:tiger@db.local:1521/ORCLPDB1").unwrap(); + assert_eq!(u.driver, Some(DbDriver::Oracle)); + assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1")); + assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger"))); + assert_eq!(parse_url("oracledb://hr@db.local/hrdb").unwrap().driver, Some(DbDriver::Oracle)); assert!(parse_url("postgres://h:99999/db").is_err()); } diff --git a/crates/ide-db/src/split.rs b/crates/ide-db/src/split.rs index b2ee3a108..785aa62ed 100644 --- a/crates/ide-db/src/split.rs +++ b/crates/ide-db/src/split.rs @@ -9,7 +9,8 @@ use specta::Type; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct SqlStatement { - /// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). + /// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except + /// after an Oracle PL/SQL block, whose `END;` needs it). pub from: u32, pub to: u32, pub text: String, @@ -29,6 +30,8 @@ enum State { pub fn split(text: &str, driver: DbDriver) -> Vec { let mysql = driver == DbDriver::Mysql; let clickhouse = driver == DbDriver::Clickhouse; + // PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement. + let plsql = driver == DbDriver::Oracle; let chars: Vec<(usize, char)> = text.char_indices().collect(); // UTF-16 offset of each char index (plus the end). let mut u16_at = Vec::with_capacity(chars.len() + 1); @@ -46,6 +49,14 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { // Whether the current segment has anything but whitespace / comments. let mut has_code = false; let mut i = 0usize; + // PL/SQL block depth (BEGIN / CASE / LOOP open, END closes). + let mut depth = 0usize; + // Inside a top-level block's declaration section (`DECLARE … BEGIN`). + let mut declarations = false; + // The segment is a PL/SQL block: its closing `;` is part of the statement. + let mut block = false; + // `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it. + let mut package = false; let push = |out: &mut Vec, s: usize, e: usize, has_code: bool| { if !has_code { return; @@ -69,11 +80,24 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { let next = chars.get(i + 1).map(|c| c.1); match state { State::Code => match c { - ';' => { - push(&mut out, start, i, has_code); + _ if plsql && !has_code && c.is_alphabetic() => { + match plsql_unit(&chars, i) { + Unit::Sql => {} + Unit::Block => block = true, + Unit::Subprogram => (block, declarations) = (true, true), + Unit::Package => (block, package) = (true, true), + } + has_code = true; + // Re-read this character as code (it may start BEGIN / DECLARE). + continue; + } + ';' if depth == 0 && !declarations && !package => { + push(&mut out, start, if block { i + 1 } else { i }, has_code); start = i + 1; has_code = false; + block = false; } + ';' => has_code = true, '\'' => { state = State::Single; has_code = true; @@ -86,15 +110,28 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { state = State::Backtick; has_code = true; } + '/' if next == Some('*') => { + state = State::BlockComment; + i += 1; + } + '/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => { + // SQL*Plus block terminator: `/` alone on its line ends the statement. + push(&mut out, start, i, has_code); + while chars.get(i).is_some_and(|&(_, c)| c != '\n') { + i += 1; + } + start = i + 1; + has_code = false; + depth = 0; + declarations = false; + block = false; + package = false; + } '-' if next == Some('-') => { state = State::LineComment; i += 1; } '#' if mysql => state = State::LineComment, - '/' if next == Some('*') => { - state = State::BlockComment; - i += 1; - } '$' if driver == DbDriver::Postgres => { // `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes. let rest = &text[byte_at(i)..]; @@ -114,6 +151,34 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { continue; } } + _ if plsql && word_at(&chars, i, "BEGIN") => { + depth += 1; + declarations = false; + has_code = true; + } + _ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => { + depth += 1; + has_code = true; + } + // `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block + // end at BEGIN, so their `;` must not split it. + _ if plsql && word_at(&chars, i, "DECLARE") => { + if depth == 0 { + declarations = true; + } + has_code = true; + } + _ if plsql && word_at(&chars, i, "END") => { + // `END IF` closes an IF (which never opened a block); END LOOP / END CASE + // close the block their keyword opened. Other ENDs close a block. + let after = skip_ws(&chars, i + 3); + let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w)); + if closing != Some("IF") && depth > 0 { + depth -= 1; + } + i = closing.map_or(i + 3, |w| after + w.len()) - 1; + has_code = true; + } c if !c.is_whitespace() => has_code = true, _ => {} }, @@ -164,6 +229,73 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { out } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Unit { + Sql, + /// `DECLARE` / `BEGIN`: an anonymous block. + Block, + /// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN. + Subprogram, + /// `CREATE PACKAGE [BODY]` / `TYPE BODY`. + Package, +} + +/// What kind of PL/SQL unit, if any, a statement starting at `i` is. +fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit { + let mut words: Vec = Vec::new(); + while words.len() < 6 { + let word: String = chars[i.min(chars.len())..] + .iter() + .map(|&(_, c)| c) + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$') + .collect(); + if word.is_empty() { + break; + } + i = skip_ws(chars, i + word.chars().count()); + words.push(word.to_ascii_uppercase()); + } + let w = |n: usize| words.get(n).map_or("", String::as_str); + match w(0) { + "DECLARE" | "BEGIN" => return Unit::Block, + "CREATE" => {} + _ => return Unit::Sql, + } + let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 }; + if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") { + n += 1; + } + match (w(n), w(n + 1)) { + ("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram, + ("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package, + _ => Unit::Sql, + } +} + +/// Whether the characters at `i` are the keyword `word` (identifier bounds respected). +fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool { + let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c)); + before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$') + && after.is_some_and(|c| c.is_ascii_alphabetic()) + && chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars()) + && chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$') +} + +/// Whether only whitespace follows `i` until the end of its line. +fn ends_line(chars: &[(usize, char)], i: usize) -> bool { + chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace()) +} + +/// Whether only whitespace precedes `i` since the start of its line. +fn starts_line(chars: &[(usize, char)], i: usize) -> bool { + chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace()) +} + +/// The index of the first non-whitespace character at or after `i`. +fn skip_ws(chars: &[(usize, char)], i: usize) -> usize { + chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n) +} + /// The statement to run for a caret at `offset` (UTF-16): the one containing /// it, else the nearest one before it (caret after `;` or on a blank line /// below), else the next one. @@ -277,4 +409,62 @@ mod tests { assert_eq!(s.iter().map(|s| s.text.as_str()).collect::>(), vec!["select 1", "select 2"]); assert_eq!((s[1].from, s[1].to), (13, 21)); } + + #[test] + fn oracle_keeps_plsql_blocks_as_one_statement() { + let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend"; + assert_eq!( + texts(&format!("{block};\nselect 2;"), DbDriver::Oracle), + vec![format!("{block};"), "select 2".into()] + ); + let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend"; + assert_eq!( + texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle), + vec![format!("{nested};"), "select 3".into()] + ); + // Plain SQL with CASE is unaffected; END IF does not close a block twice. + assert_eq!( + texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle), + vec!["select case when a = 1 then 2 end from t", "select 2"] + ); + } + + #[test] + fn oracle_slash_terminator_and_other_drivers_unaffected() { + // `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing. + assert_eq!( + texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle), + vec!["begin\n null;\nend;", "select 2"] + ); + // A division is not a terminator. + assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]); + assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]); + } + + #[test] + fn oracle_create_units_keep_their_declarations_and_end() { + let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;"; + assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]); + let trigger = + "CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;"; + assert_eq!( + texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle), + vec![trigger, "select 2 from dual"] + ); + // Packages end at `/` (or the end of the script), like in SQL*Plus. + let spec = "create package k as\n procedure a;\n function f return number;\nend k;"; + let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;"; + assert_eq!( + texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle), + vec![spec, body, "select 3 from dual"] + ); + // Other CREATE statements and object types stay plain SQL. + assert_eq!( + texts( + "create view v as select 1 x from dual; create type t as object (a number); select 4", + DbDriver::Oracle + ), + vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"] + ); + } } diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs index bd281a859..9e2172610 100644 --- a/crates/ide-db/src/sql.rs +++ b/crates/ide-db/src/sql.rs @@ -46,6 +46,23 @@ pub fn databases(templates: bool) -> &'static str { /// Databases of managed services that their users cannot use (hidden unless connected to). pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"]; +/// Oracle system accounts hidden unless `show_system` (see the Schemas tab). +pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[ + "CTXSYS", + "DBSFWUSER", + "DVSYS", + "GSMADMIN_INTERNAL", + "LBACSYS", + "MDDATA", + "MDSYS", + "OLAPSYS", + "OUTLN", + "SYS", + "SYSTEM", + "XDB", + "XS$NULL", +]; + pub fn schemas(driver: DbDriver, system: bool) -> &'static str { match driver { DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq", @@ -59,6 +76,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { } DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", + DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username", + DbDriver::Oracle => { + "SELECT username FROM all_users WHERE username NOT IN \ + ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \ + 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username" + } DbDriver::Postgres => { "SELECT schema_name FROM information_schema.schemata \ WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ @@ -89,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String { AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name", quote_ident(driver, schema) ), + DbDriver::Oracle => format!( + "SELECT table_name, 'table' FROM all_tables WHERE owner = {} \ + UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1", + quote_literal(driver, schema), + quote_literal(driver, schema) + ), DbDriver::Postgres => format!( "SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ @@ -148,6 +177,17 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String { FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \ ORDER BY c.ordinal_position" ), + DbDriver::Oracle => format!( + "SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \ + CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \ + FROM all_tab_columns c \ + LEFT JOIN (SELECT kc.column_name FROM all_constraints con \ + JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \ + AND kc.constraint_name = con.constraint_name \ + WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \ + ON k.column_name = c.column_name \ + WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id" + ), } } @@ -185,6 +225,12 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String { WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \ ORDER BY i.name, ic.key_ordinal" ), + DbDriver::Oracle => format!( + "SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \ + FROM all_indexes i JOIN all_ind_columns ic \ + ON ic.index_owner = i.owner AND ic.index_name = i.index_name \ + WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position" + ), } } @@ -245,6 +291,21 @@ pub fn completion_columns(driver: DbDriver) -> &'static str { WHERE c.table_schema NOT IN ('sys', 'information_schema') \ ORDER BY c.table_schema, c.table_name, c.ordinal_position" } + DbDriver::Oracle => { + "SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \ + c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \ + CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \ + CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \ + FROM all_tab_columns c \ + LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \ + LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \ + JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \ + AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \ + ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \ + WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \ + 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \ + ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY" + } } } @@ -284,6 +345,19 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str { AND table_schema NOT IN ('sys', 'information_schema') \ ORDER BY table_schema, table_name, constraint_name, ordinal_position" } + DbDriver::Oracle => { + "SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \ + rcon.owner, rcon.table_name, rcol.column_name \ + FROM all_constraints con \ + JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \ + AND col.constraint_name = con.constraint_name \ + JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \ + JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \ + AND rcol.constraint_name = rcon.constraint_name \ + WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \ + 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \ + ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY" + } } } @@ -304,6 +378,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String DbDriver::SqlServer => format!( "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position" ), + DbDriver::Oracle => format!( + "SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position" + ), } } @@ -315,6 +392,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> { DbDriver::Postgres => Some("SELECT current_schema()"), DbDriver::Mysql => Some("SELECT DATABASE()"), DbDriver::SqlServer => Some("SELECT DB_NAME()"), + DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"), } } @@ -359,6 +437,8 @@ pub fn table_page( sql.push_str(" ORDER BY (SELECT 1)"); } sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1)); + } else if driver == DbDriver::Oracle { + sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1)); } else { sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1)); } @@ -421,6 +501,8 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE if values.is_empty() { return Ok(match driver { DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"), + // Oracle has no DEFAULT VALUES; without columns there is nothing to insert. + DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()), _ => format!("INSERT INTO {target} DEFAULT VALUES"), }); } @@ -509,6 +591,12 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String { "SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \ WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1" ), + DbDriver::Oracle => format!( + "SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \ + WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \ + lower(replace(triggering_event, ' OR ', ',')) \ + FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1" + ), } } @@ -551,17 +639,28 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option { "SELECT r.routine_name, lower(r.routine_type), '', r.data_type \ FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" )), + DbDriver::Oracle => Some(format!( + "SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \ + FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \ + AND object_name NOT LIKE 'BIN$%' ORDER BY 1" + )), } } -/// Rows: `name, data type` of a schema's sequences (PostgreSQL). +/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle). pub fn sequences(driver: DbDriver, schema: &str) -> Option { - (driver == DbDriver::Postgres).then(|| { - format!( + match driver { + DbDriver::Postgres => Some(format!( "SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1", quote_literal(driver, schema) - ) - }) + )), + // Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c. + DbDriver::Oracle => Some(format!( + "SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1", + quote_literal(driver, schema) + )), + _ => None, + } } /// The statement reading the definition of a routine (a row per overload), trigger @@ -606,6 +705,26 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam (DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2), (DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2), (DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2), + (DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => ( + format!( + "SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", + match kind { + K::Function => "FUNCTION", + K::Procedure => "PROCEDURE", + _ => "TRIGGER", + } + ), + 0, + ), + (DbDriver::Oracle, K::Sequence) => ( + format!( + "SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \ + E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \ + E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \ + FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}" + ), + 0, + ), _ => return None, }) } @@ -792,6 +911,64 @@ mod tests { assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns")); } + #[test] + fn oracle_quoting_pages_and_current_schema() { + assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\""); + assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'"); + assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\""); + assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL")); + assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), ""); + let sort = [SortKey { column: "id".into(), descending: true }]; + assert_eq!( + table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25), + "SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY" + ); + assert_eq!( + table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10), + "SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY" + ); + } + + #[test] + fn oracle_introspection_queries() { + assert_eq!(DbDriver::Oracle.default_port(), Some(1521)); + assert!(schemas(DbDriver::Oracle, true).contains("all_users")); + assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'")); + assert!(tables(DbDriver::Oracle, "hr").contains("all_tables")); + assert!(tables(DbDriver::Oracle, "hr").contains("all_views")); + let cols = columns(DbDriver::Oracle, "hr", "employees"); + assert!(cols.contains("all_tab_columns")); + assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')")); + assert!(cols.contains("constraint_type = 'P'")); + assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes")); + assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'")); + assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns")); + assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns")); + assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers")); + assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects")); + assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences")); + } + + #[test] + fn oracle_edits_and_object_ddl() { + let key = vec![cv("id", Some("1"))]; + assert_eq!( + edit_statement( + DbDriver::Oracle, + "hr", + "t", + &DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] } + ) + .unwrap(), + "UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'" + ); + assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err()); + use crate::DbObjectKind as K; + let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap(); + assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0); + assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences")); + } + #[test] fn sqlite_trigger_events() { let ev = sqlite_trigger_event; diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs index 65e23f819..8eb638e24 100644 --- a/crates/ide-docker/src/lib.rs +++ b/crates/ide-docker/src/lib.rs @@ -440,3 +440,89 @@ pub fn compose_op( }); run_streaming(ep, None, &args, cancel, on_output) } + +/// Whether `ep` is a daemon on this machine, where a bind mount of the project +/// is this machine's folder. The CLI's default daemon and a context are looked +/// up (`DOCKER_HOST`, else the context's endpoint). +pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { + let url = match ep { + DockerEndpoint::Socket { path } => path.trim().to_owned(), + DockerEndpoint::Tcp { host, .. } => { + let host = host.trim(); + if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") } + } + DockerEndpoint::Default => { + match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) { + Some(url) if !url.is_empty() => url, + _ => context_host(ep, None)?, + } + } + DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?, + }; + Ok(daemon_url_is_local(&url)) +} + +/// The daemon URL of context `name` (the current one with `None`). +fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult { + let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]; + args.extend(name); + Ok(run(ep, &args, QUICK)?.trim().to_owned()) +} + +/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are +/// this machine; `ssh://` and other hosts are not. +pub fn daemon_url_is_local(url: &str) -> bool { + let url = url.trim(); + let Some((scheme, rest)) = url.split_once("://") else { return true }; + match scheme { + "unix" | "npipe" => true, + "tcp" | "http" | "https" => { + let host = rest.split('/').next().unwrap_or_default(); + let host = match host.strip_prefix('[') { + Some(v6) => v6.split(']').next().unwrap_or_default(), + None => host.rsplit_once(':').map_or(host, |(h, _)| h), + }; + matches!(host, "localhost" | "127.0.0.1" | "::1") + } + _ => false, + } +} + +/// `docker compose config --format json` of the project at `dir` (`files` +/// relative to it; none = the project's default file). +pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult { + let mut args: Vec<&str> = vec!["compose"]; + for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) { + args.extend(["--file", f]); + } + args.extend(["config", "--format", "json"]); + let mut cmd = command(ep, &args)?; + cmd.current_dir(dir); + let out = process::run(cmd, QUICK, None)?; + if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) } +} + +#[cfg(test)] +mod local_tests { + use super::*; + + #[test] + fn local_daemon_urls() { + for url in [ + "unix:///var/run/docker.sock", + "/var/run/docker.sock", + "npipe:////./pipe/docker_engine", + "tcp://localhost:2375", + "tcp://127.0.0.1:2376", + "tcp://[::1]:2375", + "", + ] { + assert!(daemon_url_is_local(url), "{url}"); + } + for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] { + assert!(!daemon_url_is_local(url), "{url}"); + } + assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap()); + assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap()); + } +} diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index aaeeabf67..8f2e86955 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf}; pub const MANIFEST_FILE: &str = "plugin.toml"; /// The plugin API (engine) version this host implements. -pub const ENGINE_VERSION: &str = "1.1.0"; +pub const ENGINE_VERSION: &str = "1.3.0"; /// The 0.1 API, still accepted for contribution-only packages and 0.1 components. pub const LEGACY_API: &str = "0.1"; /// Marketplace categories, in the order they are shown. @@ -54,6 +54,10 @@ pub struct Manifest { pub change_notes: Option, /// Marketplace categories as declared (see [`Manifest::categories`]). pub declared_categories: Vec, + /// SVG shown in Settings › Plugins and the marketplace. + pub icon: Option, + /// Its variant for dark themes. + pub icon_dark: Option, /// `engines.openidea` (a semver requirement), or the legacy `openidea-api`. pub engine: String, /// Written in the 0.1 format. @@ -175,6 +179,10 @@ struct Raw { #[serde(default)] categories: Vec, #[serde(default)] + icon: Option, + #[serde(default)] + icon_dark: Option, + #[serde(default)] engines: Option, /// 0.1 format. #[serde(default)] @@ -253,6 +261,8 @@ impl Manifest { license: raw.license, change_notes: raw.change_notes, declared_categories: raw.categories, + icon: raw.icon, + icon_dark: raw.icon_dark, engine, legacy, wasm: raw.wasm, @@ -270,6 +280,16 @@ impl Manifest { Self::parse(&src).map_err(|e| format!("{}: {e}", path.display())) } + /// Refuses `what`, new in plugin API 1.`minor`, while `engines.openidea` + /// still admits an older 1.x: those IDEs reject the manifest at install. + pub fn needs_api(&self, what: &str, minor: u64) -> Result<(), String> { + let older = |m| engine_compatible_with(&self.engine, &semver::Version::new(1, m, u64::MAX)); + if self.legacy || (0..minor).any(older) { + return Err(format!("{what} is new in plugin API 1.{minor}: set engines.openidea to \"^1.{minor}\"")); + } + Ok(()) + } + fn validate(&self) -> Result<(), String> { validate_plugin_id(&self.id)?; if self.name.trim().is_empty() { @@ -291,6 +311,16 @@ impl Manifest { if let Some(wasm) = &self.wasm { check_relative(wasm, "wasm")?; } + if let Some(icon) = &self.icon { + self.needs_api("`icon`", 2)?; + check_relative(icon, "icon")?; + } + if let Some(dark) = &self.icon_dark { + if self.icon.is_none() { + return Err("`icon-dark` needs `icon`".into()); + } + check_relative(dark, "icon-dark")?; + } if self.wasm.is_none() && !self.activation.is_empty() { return Err("`activation` needs `wasm`: there is no code to activate".into()); } @@ -376,9 +406,11 @@ pub fn engine_compatible(engine: &str, legacy: bool) -> bool { if legacy { return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.')); } - let Ok(req) = semver::VersionReq::parse(engine) else { return false }; - let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version"); - req.matches(&host) + engine_compatible_with(engine, &semver::Version::parse(ENGINE_VERSION).expect("valid engine version")) +} + +fn engine_compatible_with(engine: &str, api: &semver::Version) -> bool { + semver::VersionReq::parse(engine).is_ok_and(|req| req.matches(api)) } /// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher @@ -549,6 +581,28 @@ mod tests { assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version")); } + const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n"; + + #[test] + fn parses_icons() { + let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap(); + assert_eq!(m.icon.as_deref(), Some(Path::new("icon.svg"))); + assert_eq!(m.icon_dark.as_deref(), Some(Path::new("icon_dark.svg"))); + assert_eq!(Manifest::parse(BASE).unwrap().icon, None); + } + + #[test] + fn rejects_invalid_icons() { + assert!(err(&format!("{BASE}icon-dark = \"d.svg\"\n")).contains("`icon-dark` needs `icon`")); + assert!(err(&format!("{BASE}icon = \"../x.svg\"\n")).contains("icon")); + assert!(err(&format!("{BASE}icon = \"/abs.svg\"\n")).contains("icon")); + assert!(err(&format!("{BASE}icon = \"i.svg\"\nicon-dark = \"../d.svg\"\n")).contains("icon-dark")); + for engine in ["^1.0", ">=1.1, <2"] { + let old = BASE.replace("^1.2", engine); + assert!(err(&format!("{old}icon = \"i.svg\"\n")).contains("new in plugin API 1.2"), "{engine}"); + } + } + #[test] fn ids() { assert!(validate_plugin_id("openidea.material-icons").is_ok()); diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index f1bc9c040..9f21ce632 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -314,6 +314,14 @@ pub struct Endorsement { pub signature: String, } +/// A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginIcon { + pub light: String, + pub dark: Option, +} + /// A published plugin version. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] @@ -340,6 +348,10 @@ pub struct IndexEntry { /// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. #[serde(default)] pub categories: Vec, + /// The manifest's icon, sanitised by whoever wrote the index and again by + /// the IDE before showing it. + #[serde(default)] + pub icon: Option, } impl IndexEntry { @@ -672,6 +684,7 @@ mod tests { capabilities: Vec::new(), engine: "^1.0".into(), categories: Vec::new(), + icon: None, } } diff --git a/crates/ide-extension/src/when.rs b/crates/ide-extension/src/when.rs index 98cd3c931..b6a75d638 100644 --- a/crates/ide-extension/src/when.rs +++ b/crates/ide-extension/src/when.rs @@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> { *p += 1; Ok(()) } - Some(Tok::Id(_)) => { + Some(Tok::Id(id)) => { + if let Some(path) = id.strip_prefix("workspaceContains:") + && (path.is_empty() + || path.starts_with('/') + || path.contains('\\') + || path.split('/').any(|s| s == "..")) + { + return Err(format!("workspaceContains: needs a relative path inside the project: {src}")); + } *p += 1; if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) { *p += 1; @@ -133,4 +141,23 @@ mod tests { assert!(validate_when(bad).is_err(), "{bad}"); } } + + #[test] + fn workspace_contains_paths_stay_in_the_project() { + for ok in [ + "workspaceContains:pom.xml", + "workspaceContains:.devcontainer/devcontainer.json", + "!workspaceContains:a/b && editorFocus", + ] { + assert!(validate_when(ok).is_ok(), "{ok}"); + } + for bad in [ + "workspaceContains:", + "workspaceContains:/etc/passwd", + "workspaceContains:../x", + "workspaceContains:a/../../x", + ] { + assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}"); + } + } } diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index ef9a7fa76..811075623 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -22,7 +22,7 @@ mod templates; use ide_extension::{Manifest, validate_plugin_id}; -use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href}; +use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href}; use ide_plugin::{Limits, PluginEngine}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -650,6 +650,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> { capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), engine: package::engine_requirement(&m.engine, built_for.as_ref()), categories: m.categories(), + icon: plugin_icon(&ide_extension::Package::new(m.clone(), scratch.clone()))?, }) })(); let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?; @@ -812,20 +813,24 @@ mod tests { /// A theme package `id` signed by `publisher` with `key`, packed into `out`. fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) { - signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out); + signed_theme_version(tmp, id, "0.1.0", "^1.2", publisher, key, out); } fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) { let src = tmp.join("src").join(format!("{id}-{version}")); let vars = templates::Vars { id, name: id, sdk: None }; + // A manifest icon needs API 1.2. + let icon = if engine == "^1.2" { "\nicon = \"icon.svg\"" } else { "" }; for (rel, content) in templates::render(templates::Template::Theme, &vars) { let path = src.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let content = content .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) - .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"")); + .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"{icon}")); std::fs::write(path, content).unwrap(); } + std::fs::write(src.join("icon.svg"), r#""#) + .unwrap(); std::fs::create_dir_all(out).unwrap(); let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"] .into_iter() @@ -921,6 +926,11 @@ mod tests { let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect(); assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]); assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme"); + assert!( + index.plugins[1].icon.as_ref().is_some_and(|i| i.light.contains(""#) + .unwrap(); + let out = t.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let (src, key) = (src.to_string_lossy().into_owned(), key.to_string_lossy().into_owned()); + let package = out.join("acme.iconic-0.1.0.oiplugin").to_string_lossy().into_owned(); + cmd_pack(&strings(&[&src, "--key", &key, "--publisher", "acme", "--out", &package])).unwrap(); + cmd_index(&strings(&[out.to_str().unwrap(), "--publisher", "acme", "--key", &key])).unwrap(); + let index = Index::parse(&std::fs::read_to_string(out.join("index.json")).unwrap()).unwrap(); + assert!(index.plugins[0].icon.as_ref().expect("icon").light.contains(" bool { + match language.formatter.as_ref().map(|f| f.tool.as_str()) { + Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt), + Some("ruff") => tool == ExternalFormatter::Ruff, + _ => false, + } +} + /// The external formatter of a file, for a language with an external formatter. -/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt -/// applies when it is enabled for the language and either an oxfmt -/// configuration is found above the file or oxfmt is the selected provider; -/// else Prettier, when it is enabled. Turning Prettier off for a language -/// leaves it to the language server, not to oxfmt. +/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is +/// the base formatter for Python; oxfmt can replace Prettier for its languages +/// when configured or selected. A tool that cannot format the language is +/// rejected even when explicitly requested. fn external_tool( forced: Option, language: Option<&LanguageConfig>, file: &Path, settings: &LanguagesSettings, ) -> IdeResult> { - use ExternalFormatter::{Oxfmt, Prettier}; - let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else { + use ExternalFormatter::{Oxfmt, Prettier, Ruff}; + let Some(language) = language.filter(|l| l.formatter.is_some()) else { return match forced { Some(tool) => Err(IdeError::invalid(format!( "{} cannot format {} files", @@ -34,10 +41,21 @@ fn external_tool( None => Ok(None), }; }; - if forced.is_some() { - return Ok(forced); + if let Some(tool) = forced { + return if supports_external_format(language, tool) { + Ok(Some(tool)) + } else { + Err(IdeError::invalid(format!( + "{} cannot format {} files", + formatter::display_name(tool.name()), + language.name + ))) + }; } let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str()); + if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") { + return Ok(enabled(Ruff).then_some(Ruff)); + } let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) { Some(Oxfmt) @@ -46,6 +64,61 @@ fn external_tool( }) } +fn codepoint_col(line: &str, utf16_col: u32) -> u32 { + let mut units = 0; + for (index, ch) in line.chars().enumerate() { + if units >= utf16_col { + return index as u32; + } + units += ch.len_utf16() as u32; + } + line.chars().count() as u32 +} + +fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange { + let position = |pos: Pos| { + let line = text.line(pos.line).unwrap_or_default(); + (pos.line + 1, codepoint_col(&line, pos.col) + 1) + }; + let (start_line, start_col) = position(range.start); + let (end_line, end_col) = position(range.end); + formatter::FormatRange { + start_offset: text.pos_to_offset(range.start), + end_offset: text.pos_to_offset(range.end), + start_line, + start_col, + end_line, + end_col, + } +} + +/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's +/// name (from stdin it doesn't fix anything), so the file on disk stays as it is. +fn php_cs_fixer_edits( + tools: &Tools, + php: Option<&Path>, + fixer: &formatter::PhpCsFixer, + path: &Path, + unsaved: &str, +) -> IdeResult> { + let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?; + let dir = tempfile::tempdir()?; + let copy = dir.path().join(name); + std::fs::write(©, unsaved)?; + formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, ©)?, &Default::default())?; + let formatted = std::fs::read_to_string(©)?; + let text = ide_text::Document::new(unsaved); + Ok(text + .diff(&formatted) + .changes + .into_iter() + .map(|c| TextEdit { + range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)), + new_text: c.insert, + }) + .collect()) +} + pub async fn format( host: &Host, window: &str, @@ -57,6 +130,34 @@ pub async fn format( let snap = project.documents.snapshot(doc)?; let settings = project.settings.effective(); let language = project.languages.get(&snap.language); + // A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files. + if options.tool.is_none() + && snap.path.extension().is_some_and(|e| e == "php") + && let Some(fixer) = formatter::php_cs_fixer(&snap.path) + { + if options.on_save && !settings.editor.format_on_save { + return Ok(Vec::new()); + } + host.require_trust(window)?; + if snap.read_only { + return Err(IdeError::invalid("The file is read-only")); + } + let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter); + let tools = Tools::new(&host.local_data_dir); + let edits = tokio::task::spawn_blocking(move || { + php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + if range.is_some() { + project.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Info, + title: "PHP-CS-Fixer".into(), + body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()), + }); + } + return Ok(edits); + } let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?; if let Some(tool) = selected && options.tool.is_none() @@ -85,13 +186,28 @@ pub async fn format( let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path); let tools = Tools::new(&host.local_data_dir); let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); + let project_root = project.root.clone(); + let configuration_directory = settings.languages.ruff.configuration_directory.clone(); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); - let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); + let range = range.map(|range| format_range(&text, range)); + let config_file = if tool == "ruff" { + ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)? + } else { + None + }; let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; command.current_dir(dir); - command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + command.args(formatter::arguments( + tool, + &config, + &snap.path, + options.tab_size, + options.insert_spaces, + range, + config_file.as_deref(), + )); let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) @@ -120,7 +236,7 @@ pub async fn format( #[cfg(test)] mod tests { use super::*; - use ExternalFormatter::{Oxfmt, Prettier}; + use ExternalFormatter::{Oxfmt, Prettier, Ruff}; use ide_core::LanguageId; use ide_language::LanguageRegistry; @@ -135,17 +251,36 @@ mod tests { } #[test] - fn default_settings_give_every_external_formatter_language_to_prettier() { + fn default_settings_use_ruff_for_python_and_prettier_for_the_web() { let settings = LanguagesSettings::default(); let file = Path::new("/project/file"); for lang in LanguageRegistry::load(None).unwrap().all() { - let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); + let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) { + Some("prettier") => Some(Prettier), + Some("ruff") => Some(Ruff), + _ => None, + }; let selected = external_tool(None, Some(&lang), file, &settings).unwrap(); - assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id); + assert_eq!(selected, configured, "{}", lang.id); } assert_eq!(external_tool(None, None, file, &settings).unwrap(), None); } + #[test] + fn ruff_is_the_python_external_formatter_and_is_opt_out() { + let file = Path::new("/project/src/example.py"); + let mut settings = LanguagesSettings::default(); + assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff)); + assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff)); + settings.ruff.languages.clear(); + assert_eq!(tool(None, "python", file, &settings).unwrap(), None); + assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff)); + let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err(); + assert_eq!(prettier.message, "Prettier cannot format Python files"); + let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err(); + assert_eq!(oxfmt.message, "oxfmt cannot format Python files"); + } + #[test] fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() { let dir = tempfile::tempdir().unwrap(); @@ -192,4 +327,74 @@ mod tests { let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err(); assert_eq!(rust.message, "oxfmt cannot format Rust files"); } + + #[test] + fn php_cs_fixer_needs_a_config_and_the_project_binary() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("src/Http")).unwrap(); + let file = root.join("src/Http/Kernel.php"); + assert_eq!(formatter::php_cs_fixer(&file), None); + std::fs::write(root.join(".php-cs-fixer.dist.php"), "`: rewrites the last argument. + std::fs::write( + &php, + "#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\ + [ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n", + ) + .unwrap(); + std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap(); + let file = root.join("src/Kernel.php"); + let tools = Tools::new(&root.join("tools")); + let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "&2\nexit 16\n").unwrap(); + let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, ">, lsp: Option>, debug: Option<(Arc, String)>, + retired: Option>, ) -> std::thread::JoinHandle<()> { for bridge in &bridges { bridge.stop(); @@ -262,6 +272,10 @@ impl Host { } let runtime = self.runtime.clone(); std::thread::spawn(move || { + // Dropping the project stops its file watcher and index worker, which first finish the + // batch they are applying (many file events while a build writes its output), so it + // isn't done on the closing caller's thread (the UI thread for a window close). + let retired = retired.map(|project| std::thread::spawn(move || drop(project))); runtime.block_on(async move { for bridge in bridges { bridge.shutdown().await; @@ -276,6 +290,9 @@ impl Host { debug.stop_key(&key).await; } }); + if let Some(retired) = retired { + let _ = retired.join(); + } }) } @@ -315,6 +332,7 @@ impl Host { projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), None, None, + None, )); let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT; while pending.iter().any(|handle| !handle.is_finished()) { @@ -331,6 +349,14 @@ impl Host { } } + /// Called on desktop exit: kills the runs and terminal shells still alive. Closing a + /// window stops what it started, but a quit from the Dock, the app menu or a logout + /// exits without closing the windows, and the process trees would outlive the app. + pub fn stop_processes_on_exit(&self) { + self.processes.kill_all(); + self.terminals.kill_all(); + } + /// Called before desktop exit, which may bypass document destructors. pub fn clear_workspace_edit_history_on_exit(&self) { for project in self.projects() { @@ -349,6 +375,9 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + if let Some(phpstan) = &project.phpstan { + phpstan.stop(); + } project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); @@ -415,3 +444,37 @@ impl Host { self.projects.write().insert(window.to_owned(), project); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn detached_close_releases_the_project_before_a_reopen() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = Arc::downgrade(&host.open_project("main", &root).unwrap()); + host.close_project_detached("main"); + assert!(host.project("main").is_err()); + // The project (its file watcher and index database) is dropped off the closing thread, but + // before opening a project waits out the close. + host.wait_detached_closes(); + assert!(project.upgrade().is_none(), "the closed project is dropped"); + host.open_project("main", &root).unwrap(); + host.close_project("main"); + } +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 3a30a7b73..d764923a2 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -238,6 +238,11 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option) host.project(window)?.agents.cancel(&task, subagent) } +/// Stops one of the task's background tasks. +pub async fn stop_background(host: &Host, window: &str, task: String, background: String) -> IdeResult<()> { + host.project(window)?.agents.stop_background(&task, &background).await +} + /// Answers a permission request; `option: None` cancels it. pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option) -> IdeResult<()> { host.require_trust(window)?; @@ -297,6 +302,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_rollback", |c| async move { rollback(&c.host, &c.window, c.arg("task")?, c.arg("turns")?).await }); d.add("agent_probe", |c| async move { probe(&c.host, &c.window, c.arg("provider")?, c.arg("refresh")?).await }); d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) }); + d.add("agent_stop_background", |call| async move { + stop_background(&call.host, &call.window, call.arg("task")?, call.arg("background")?).await + }); d.add("agent_respond", |c| async move { respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?) }); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 074cbcad5..b2b14aad7 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -5,10 +5,11 @@ use crate::rpc::Dispatcher; use crate::{Host, Project, apply_workspace_edit}; use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::maven::PomGoto; use ide_build::pom_edit::{ArtifactKey, Exclusion}; use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; use ide_core::settings::Settings; -use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; +use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit}; use std::collections::BTreeMap; use std::path::Path; @@ -47,8 +48,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { } } for g in &mut model.gradle { - g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version)); g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned(); + g.env = ide_sdk::apply::gradle_build_env(root, &root.join(&g.dir), &g.command, settings, g.java_version); } // A failed sync of a project no installed JDK can build says so first (with a Download JDK action). let missing = |release: Option| match jdk { @@ -76,6 +77,42 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { model } +/// Build and Run use the same selected toolchain and module-local runtime discovery. +fn prepare_plan(mut plan: compile::BuildPlan, root: &Path, settings: &Settings) -> compile::BuildPlan { + let kind = match plan.system { + compile::BuildSystem::Cargo => "cargo", + compile::BuildSystem::Go => "go", + compile::BuildSystem::TypeScript => "npm", + _ => return plan, + }; + for step in &mut plan.steps { + let mut config = ide_run::RunConfig { + id: String::new(), + name: String::new(), + kind: kind.into(), + command: step.program.clone(), + args: step.args.clone(), + cwd: Some(step.cwd.clone().into()), + env: step.env.clone(), + detected: true, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + config.extras.module = Some(ide_core::ipc_path::to_ipc(&step.cwd)); + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, settings); + if kind == "go" { + config.extras.params.insert("useTags".into(), "true".into()); + ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags); + } + step.program = config.command; + step.args = config.args; + step.env = config.env; + } + plan +} + /// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from /// the tool window do. fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel { @@ -404,6 +441,27 @@ pub async fn maven_dependency_declaration( .map_err(join_err)? } +/// The go-to-declaration target at `pos` of the Maven POM `pom` (project-relative, or +/// absolute for a parent POM opened from the local repository): the definition of the +/// `${property}` at the caret, or the `` / `` +/// entry providing the dependency's / plugin's version, along the parent chain (into +/// the local repository when a parent is not in the project). `None` when nothing +/// resolvable is under the caret. +pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult> { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let given = Path::new(&pom); + let path = if given.is_absolute() { given.to_path_buf() } else { project.root.join(given) }; + let text = project.text_of(&path)?; + let settings = project.settings.effective(); + let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings) + .or_else(ide_build::maven::default_local_repo); + Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo)) + }) + .await + .map_err(join_err)? +} + /// Adds an `` of `exclusion` to the declaration of the direct dependency /// `dependency` of the Maven module `pom`, through the open document when there is one /// (saved). Returns the declaration; nothing changes when it already excludes it. @@ -467,7 +525,11 @@ pub async fn build_run( let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); let member_cache = cache.join(crate::project::project_key(member)); let model = with_profiles(load(member, &member_cache, &settings), &member_cache); - let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?; + let plan = prepare_plan( + compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?, + member, + &settings, + ); match &mut combined { Some(combined) => combined.steps.extend(plan.steps), None => combined = Some(plan), @@ -477,13 +539,17 @@ pub async fn build_run( plan.title = workspace.name; plan } else { - compile::plan( + prepare_plan( + compile::plan( + &root, + &with_profiles(load(&root, &cache, &settings), &cache), + kind, + file.as_deref(), + settings.cmake.profiles.as_deref(), + )?, &root, - &with_profiles(load(&root, &cache, &settings), &cache), - kind, - file.as_deref(), - settings.cmake.profiles.as_deref(), - )? + &settings, + ) }; let result = compile::run(&root, &plan, &token, &|e| { let _ = on_event.send(e); @@ -538,6 +604,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("maven_dependency_declaration", |c| async move { maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await }); + d.add("maven_goto_declaration", |c| async move { + maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await + }); d.add("maven_exclude_dependency", |c| async move { maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await }); @@ -559,6 +628,46 @@ pub(crate) fn register(d: &mut Dispatcher) { mod gradle_settings_tests { use super::*; + #[test] + fn builds_apply_the_same_explicit_toolchains_as_runs() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let tool = |name: &str| format!("{name}{}", std::env::consts::EXE_SUFFIX); + for name in ["cargo", "go"] { + let path = root.join("sdk").join("bin").join(tool(name)); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + let mut settings = Settings::default(); + settings.languages.rust.toolchain = "sdk".into(); + settings.languages.go.goroot = "sdk".into(); + settings.languages.go.build_tags.custom = "integration".into(); + settings.languages.node.package_manager = "yarn".into(); + for (marker, source, program) in [ + ( + "Cargo.toml", + "[package]\nname='demo'\nversion='0.1.0'", + root.join("sdk").join("bin").join(tool("cargo")).to_string_lossy().into_owned(), + ), + ("go.mod", "module demo", root.join("sdk").join("bin").join(tool("go")).to_string_lossy().into_owned()), + ("package.json", r#"{"scripts":{"build":"echo built"},"packageManager":"pnpm@10"}"#, "yarn".into()), + ] { + let dir = root.join(marker.replace('.', "-")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(marker), source).unwrap(); + let plan = prepare_plan( + compile::plan(&dir, &Default::default(), BuildKind::Artifacts, None, None).unwrap(), + root, + &settings, + ); + assert_eq!(plan.steps[0].program, program); + assert_eq!(plan.steps[0].cwd, dir); + if marker == "go.mod" { + assert!(plan.steps[0].args.contains(&"-tags=integration".into())); + } + } + } + #[test] fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/coverage.rs b/crates/ide-host/src/ops/coverage.rs index 007d701db..f57b538f4 100644 --- a/crates/ide-host/src/ops/coverage.rs +++ b/crates/ide-host/src/ops/coverage.rs @@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS blocking(move || ide_coverage::collect(&report)).await } -/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML, +/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset, /// with paths mapped into this project. pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult { let root = host.project(window)?.root.clone(); diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 827223544..8cf3a44d1 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -162,6 +162,11 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th host.debugger.pause(session, thread_id).await } +/// Types `text` into the session's program (a `runInTerminal` program). +pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> { + host.debugger.send_input(session, &text) +} + pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> { host.debugger.hot_reload(session).await } @@ -381,6 +386,9 @@ pub(crate) fn register(d: &mut Dispatcher) { "debug_pause", |c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await }, ); + d.add("debug_send_input", |c| async move { + debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await + }); d.add("debug_step", |c| async move { debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await }); diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs index 422ade156..3f169c620 100644 --- a/crates/ide-host/src/ops/java/cache.rs +++ b/crates/ide-host/src/ops/java/cache.rs @@ -242,6 +242,29 @@ mod tests { assert!(build().is_err()); assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5); } + /// An external tool (e.g. an AI code editor) rewrites a source file with the same byte + /// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as + /// "unchanged" and would reuse the stale snapshot; the content hash must catch it. + #[test] + fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first"); + + let source = root.join("src/main/kotlin/Main.kt"); + let mtime = fs::metadata(&source).unwrap().modified().unwrap(); + fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first" + fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap(); + let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild"); + assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other"); + } + #[test] fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 2e2402f3f..c2dff41be 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -3,6 +3,7 @@ use crate::Host; use crate::apply_workspace_edit as apply_edit; use crate::plugins::{ProviderKind, editor_providers_for as editor_providers}; use crate::rpc::Dispatcher; +use ide_core::error::ErrorKind; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; use ide_plugin::ProviderDocument; @@ -46,7 +47,7 @@ pub async fn lsp_completion( let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result }; let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default(); if items.is_empty() { - return result; + return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() })); } let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() }); list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item))); @@ -204,20 +205,82 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if kind == GotoKind::Definition + && let Some(plugins) = crate::plugins::editor_plugin_host() + { + let project = host.project(window)?; + let (path, version) = project.documents.identity(doc)?; + if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + }) { + return Ok(found.targets); + } + } let result = host.project(window)?.lsp.goto(doc, pos, kind).await; if kind != GotoKind::Definition { return result; } let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result }; let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default(); - if found.is_empty() { - return result; + let found: Vec = + found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }).collect(); + let in_string = !found.is_empty() && in_string_literal(host, window, doc, pos).await; + definition_targets(result, found, in_string) +} + +async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool { + let Ok(project) = host.project(window) else { return false }; + blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Ok(false); + } + let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text); + Ok(string_at(&project.structure, &s, pos)) + }) + .await + .unwrap_or(false) +} + +fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapshot, pos: Pos) -> bool { + let cancel = ide_core::CancellationToken::new(); + let Ok(Some(at)) = structure.node_at(snap, Range::new(pos, pos), &cancel) else { return false }; + let stringy = |kind: &str| kind.contains("string") || kind.contains("heredoc") || kind.contains("nowdoc"); + let part = |kind: &str| kind.contains("content") || kind.contains("fragment") || kind.contains("escape"); + stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind))) +} + +fn definition_targets( + server: IdeResult>, + plugin: Vec, + in_string: bool, +) -> IdeResult> { + if plugin.is_empty() { + return server.map(without_generated_helpers); } - let mut out = result.unwrap_or_default(); - out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None })); + if in_string { + return Ok(plugin); + } + let same_place = |l: &Location| plugin.iter().any(|p| p.path == l.path && p.range.start.line == l.range.start.line); + let server: Vec = + without_generated_helpers(server.unwrap_or_default()).into_iter().filter(|l| !same_place(l)).collect(); + let mut out = plugin; + out.extend(server); Ok(out) } +fn without_generated_helpers(locations: Vec) -> Vec { + let generated = |l: &Location| { + let path = l.path.to_string_lossy().replace('\\', "/"); + let file = path.rsplit('/').next().unwrap_or(&path); + file.starts_with("_ide_helper") || file == ".phpstorm.meta.php" || path.contains("/vendor/_laravel_ide") + }; + if locations.iter().all(generated) { + return locations; + } + locations.into_iter().filter(|l| !generated(l)).collect() +} + /// Gutter markers from plugins (`line-markers` providers) for `doc`. pub async fn plugin_line_markers( host: &Host, @@ -257,6 +320,28 @@ pub async fn lsp_implementations( result } +/// Go to Implementation of the symbol at `pos`. Implementations found before a slower +/// search for the rest (Java: the project's, before the dependencies') arrive on +/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`. +pub async fn lsp_goto_implementation( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + on_partial: super::Channel>, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp + .goto_implementation(doc, pos, cancel, &|locs| { + let _ = on_partial.send(locs); + }) + .await; + host.end_request(request); + result +} + /// Contents of a read-only language-server document, including JDT library /// classes and disk class files. Cached per project. pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { @@ -274,7 +359,11 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path:: /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols -/// leave it off. Cancel with `cancel_request(request)`. +/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK +/// are not searched; with it, those found in the sources arrive on `on_partial` before +/// the slower search for the rest, and the result has them all. Cancel with +/// `cancel_request(request)`. +#[allow(clippy::too_many_arguments)] pub async fn lsp_references( host: &Host, window: &str, @@ -283,10 +372,24 @@ pub async fn lsp_references( pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, + on_partial: super::Channel, ) -> IdeResult { let lsp = host.project(window)?.lsp.clone(); let cancel = host.begin_request(request); - let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; + let result = lsp + .references_streaming( + doc, + pos, + include_declaration, + project_wide, + libraries, + cancel, + Some(&|refs| { + let _ = on_partial.send(refs); + }), + ) + .await; host.end_request(request); result } @@ -563,7 +666,22 @@ pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range } pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult> { - host.project(window)?.lsp.semantic_tokens(doc).await + let lsp = host.project(window)?.lsp.clone(); + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SemanticTokens) else { + return lsp.semantic_tokens(doc).await; + }; + let (result, tokens) = + tokio::join!(lsp.semantic_tokens(doc), plugin_call(move || plugins.provide_semantic_tokens(&d))); + let tokens = tokens.unwrap_or_default(); + if tokens.is_empty() { + return result; + } + let server = match result { + Ok(server) => server, + Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None, + Err(e) => return Err(e), + }; + Ok(Some(crate::plugins::merge_semantic_tokens(server, tokens))) } pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult> { @@ -746,12 +864,17 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_implementations", |c| async move { lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await }); + d.add("lsp_goto_implementation", |c| async move { + let on_partial = c.sender("onPartial")?; + lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await + }); d.add( "lsp_resolve_class_file", |c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await }, ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_references", |c| async move { + let on_partial = c.sender("onPartial")?; lsp_references( &c.host, &c.window, @@ -760,6 +883,8 @@ pub(crate) fn register(d: &mut Dispatcher) { c.arg("pos")?, c.arg("includeDeclaration")?, c.arg("projectWide")?, + c.arg("libraries")?, + on_partial, ) .await }); @@ -882,6 +1007,79 @@ mod tests { assert!(eager.edit.is_some() && eager.data.is_none()); } + #[test] + fn plugin_definitions_own_strings_and_join_the_server_elsewhere() { + let at = |path: &str, line: u32| Location { + path: path.into(), + range: Range::new(Pos::new(line, 0), Pos::new(line, 4)), + uri: None, + }; + let paths = |found: IdeResult>| { + found.unwrap().into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::>() + }; + let xml = at("/p/src/main/resources/UserMapper.xml", 3); + let java = at("/p/src/main/java/UserMapper.java", 7); + let helper = at("/p/_ide_helper.php", 1); + let route = at("/p/routes/web.php", 2); + let guess = at("/p/app/helpers.php", 9); + + assert_eq!( + paths(definition_targets(Ok(vec![java.clone(), helper.clone()]), vec![xml.clone()], false)), + ["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"] + ); + assert_eq!( + paths(definition_targets(Ok(vec![xml.clone(), java.clone()]), vec![xml.clone()], false)), + ["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"] + ); + assert_eq!( + paths(definition_targets(Ok(vec![guess.clone(), helper.clone()]), vec![route.clone()], true)), + ["/p/routes/web.php"] + ); + assert_eq!( + paths(definition_targets(Err(ide_core::IdeError::unavailable("no server")), vec![route.clone()], false)), + ["/p/routes/web.php"] + ); + assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]); + } + + #[test] + fn positions_inside_string_literals_are_told_apart() { + let syntax = std::sync::Arc::new(ide_language::Syntax::new()); + let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax))); + let php = ide_core::LanguageId::new("php"); + let text = "| found.into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::>(); + let real = at("/app/app/Models/User.php"); + let helper = at("/app/_ide_helper_models.php"); + let meta = at("/app/.phpstorm.meta.php"); + let vendor = at("/app/vendor/_laravel_idea/_ide_helper.php"); + assert_eq!( + paths(without_generated_helpers(vec![helper.clone(), real.clone(), meta.clone(), vendor])), + ["/app/app/Models/User.php"] + ); + assert_eq!( + paths(without_generated_helpers(vec![helper.clone(), meta.clone()])), + ["/app/_ide_helper_models.php", "/app/.phpstorm.meta.php"] + ); + assert!(without_generated_helpers(Vec::new()).is_empty()); + } + #[test] fn every_completion_kind_the_plugin_api_documents_is_understood() { let wit = include_str!("../../../../wit/providers.wit"); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index a71a8da5e..513d6d0dd 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -1,5 +1,6 @@ use super::Emit; use crate::Host; +use ide_core::remote_settings::RunTargetKind; use ide_core::{IdeError, IdeResult, RunId}; use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions}; use std::time::Duration; @@ -27,9 +28,12 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { task.target = format!("workspace:{}:{}", key, task.target); } } + for id in &mut config.extras.compound { + *id = format!("workspace:{key}:{id}"); + } config.id = format!("workspace:{}:{}", key, config.id); config.name = format!("{name} / {}", config.name); - config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into()); + ide_run::scope::rebase(&mut config, &member, None); config.detected = true; if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) { all.push(config); @@ -50,10 +54,18 @@ fn detected_and_saved( suppressed: &[String], ) -> Vec { let excluded_poms = crate::project::unlinked_maven_poms(root, settings); + let gradle = settings + .languages + .java + .gradle + .linked_projects + .as_ref() + .map(|_| ide_build::gradle::load_linked(root, &settings.languages.java.gradle)); let mut all: Vec = ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref()) .into_iter() .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .filter(|d| linked_config(root, gradle.as_deref(), &excluded_poms, d)) .collect(); all.extend(saved.iter().cloned()); for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) { @@ -62,6 +74,41 @@ fn detected_and_saved( all } +fn linked_config( + root: &std::path::Path, + gradle: Option<&[ide_build::GradleModel]>, + excluded_poms: &[std::path::PathBuf], + config: &RunConfig, +) -> bool { + let module = config + .extras + .module + .as_deref() + .map(std::path::Path::new) + .or(config.cwd.as_deref()) + .unwrap_or(std::path::Path::new("")); + let owner = ide_core::project_roots::resolve(root, module); + if matches!(config.kind.as_str(), "maven" | "application" | "spring-boot") + && excluded_poms.iter().any(|pom| ide_core::project_roots::resolve(root, pom).parent() == Some(owner.as_path())) + { + return false; + } + if gradle.is_some() + && (config.kind == "gradle" + || (matches!(config.kind.as_str(), "application" | "spring-boot") + && ["build.gradle", "build.gradle.kts"].iter().any(|name| owner.join(name).is_file()))) + { + return gradle.unwrap_or_default().iter().any(|build| { + if config.kind == "gradle" { + root.join(&build.dir) == owner + } else { + build.projects.iter().any(|p| root.join(&p.dir) == owner) + } + }); + } + true +} + /// Saves the user configurations. They come from [`configs`], toolchains applied, so what /// settings derived is dropped again: only the user's own choices reach `run.toml`. pub fn save_configs( @@ -86,11 +133,22 @@ pub fn save_configs( fn settings_for( host: &Host, project: &crate::project::Project, - config: &RunConfig, + config: &mut RunConfig, ) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> { - let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { - let cwd = config.cwd.as_deref()?; - workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count()) + let workspace = ide_workspace::workspace::Workspace::load(&project.root)?; + if workspace.is_some() { + if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") { + config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc); + } + ide_run::scope::rebase(config, &project.root, None); + } + let member = workspace.and_then(|workspace| { + let owner = ide_run::scope::owner(&project.root, config); + workspace + .projects + .into_iter() + .filter(|root| owner.starts_with(root)) + .max_by_key(|root| root.components().count()) }); Ok(match member { Some(root) => { @@ -108,11 +166,11 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec) -> Id let project = host.project(window)?; configs .into_iter() - .map(|c| { + .map(|mut c| { if c.kind == "gradle" { return Ok(c); } - let (root, settings) = settings_for(host, &project, &c)?; + let (root, settings) = settings_for(host, &project, &mut c)?; Ok(ide_sdk::apply::without_derived(&c, &root, &settings)) }) .collect() @@ -126,7 +184,7 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec) -> Ide .into_iter() .map(|mut c| { if c.kind != "gradle" { - let (root, settings) = settings_for(host, &project, &c)?; + let (root, settings) = settings_for(host, &project, &mut c)?; ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings); } Ok(c) @@ -140,25 +198,46 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); } - // Settings › Run Targets: over SSH. + // Settings › Run Targets: over SSH, or in a container. if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { return Err(IdeError::invalid("Tomcat Local requires the local machine")); } - if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { + if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) { let settings = project.settings.effective(); + let target = ide_remote::service::run_target(&settings, id)?; + if target.kind != RunTargetKind::Ssh { + // A local run of the docker CLI: the command is resolved in the container, and + // this machine's toolchains and `PYTHONPATH` don't apply. + let config = container::prepare(host, &project.root, target, config).await?; + let encoding = settings.console.encoding; + let options = StartOptions { + encoding: Some(encoding).filter(|e| !e.trim().is_empty()), + python_paths: Vec::new(), + // A folder on this machine: not in the container. + ruby_reporter: None, + }; + project.require_trust()?; + return host.processes.start( + &project.root, + &config, + &options, + project.sink.clone(), + Box::new(move |c| on_output(c)), + ); + } let out = on_output.clone(); return host .remote .run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c))) .await; } - let (root, settings) = settings_for(host, &project, &config)?; + let mut config = config; + let (root, settings) = settings_for(host, &project, &mut config)?; let root = &root; // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. // Maven re-applies too: a config listed before a settings change (daemon toggle, // Maven home) keeps the launcher that was current then; re-resolving at launch is // what makes the setting take effect without reopening the project. - let mut config = config; if config.kind == "maven" { ide_sdk::apply::prepare_maven_run(&mut config, root, &settings); } else if matches!(config.kind.as_str(), "gradle" | "tomcat") { @@ -198,6 +277,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) }, + ruby_reporter: ruby_reporter_dir(host, &config), }; project.require_trust()?; let mut sink = project.sink.clone(); @@ -214,6 +294,21 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c))) } +/// The Ruby test reporter's directory, installed on demand, for runs that take it. +fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option { + if !ide_run::ruby_reporter::applies(config) { + return None; + } + let dir = host.local_data_dir.join("ruby-reporter"); + match ide_run::ruby_reporter::install(&dir) { + Ok(()) => Some(dir), + Err(e) => { + tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display()); + None + } + } +} + /// Stops a run (interrupt, then kill after Settings › Run's termination timeout). pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> { let settings = match host.project(window) { @@ -255,4 +350,33 @@ pub fn list(host: &Host) -> Vec { } mod commands; +mod container; pub use commands::*; + +#[cfg(test)] +mod ownership_tests { + use super::*; + #[test] + fn unlinked_builds_do_not_reappear_as_detected_application_configs() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for name in ["maven", "gradle"] { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("src/main/java")).unwrap(); + std::fs::write( + dir.join("src/main/java/App.java"), + "@SpringBootApplication class App { public static void main(String[] a) {} }", + ) + .unwrap(); + } + std::fs::write(root.join("maven/pom.xml"), "spring-boot-maven-plugin").unwrap(); + std::fs::write(root.join("gradle/build.gradle.kts"), "plugins { id(\"org.springframework.boot\") }").unwrap(); + let defaults = detected_and_saved(root, &Default::default(), &[], &[]); + assert!(defaults.iter().any(|c| c.kind == "spring-boot")); + let mut settings = ide_core::settings::Settings::default(); + settings.languages.java.maven.unlinked_projects = vec!["maven/pom.xml".into()]; + settings.languages.java.gradle.linked_projects = Some(vec![]); + let configs = detected_and_saved(root, &settings, &[], &[]); + assert!(configs.is_empty(), "{configs:?}"); + } +} diff --git a/crates/ide-host/src/ops/run/container.rs b/crates/ide-host/src/ops/run/container.rs new file mode 100644 index 000000000..e8616befb --- /dev/null +++ b/crates/ide-host/src/ops/run/container.rs @@ -0,0 +1,108 @@ +//! Docker and Compose run targets: the configuration becomes a local run of the +//! docker CLI (see `ide_remote::docker`). + +use crate::Host; +use ide_core::remote_settings::{RunTarget, RunTargetKind}; +use ide_core::{IdeError, IdeResult}; +use ide_docker::DockerEndpoint; +use ide_remote::docker::{ + ContainerRun, compose_container_root, container_config, container_name, default_container_root, +}; +use ide_run::RunConfig; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `config` rewritten to run in a new container of `target`'s image or service. +pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult { + if config.kind == "dotnet" || config.kind == "dotnet-test" { + return Err(IdeError::invalid(".NET Project requires the local machine")); + } + let endpoint = endpoint(&host.data_dir, &target)?; + let root = root.to_path_buf(); + host.blocking(move || { + if !ide_docker::endpoint_is_local(&endpoint)? { + return Err(IdeError::invalid(format!( + "Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one", + target.name + ))); + } + let docker = ide_docker::docker_executable().ok_or_else(|| { + IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH") + })?; + let container_root = match target.container_root.trim() { + "" if target.kind == RunTargetKind::Compose => { + let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?; + compose_container_root(&json, target.service.trim(), &root)? + } + "" => default_container_root(&root), + folder => folder.to_owned(), + }; + let file_env = env_files(&root, &config)?; + let name = container_name(&config.id, &unique()); + container_config( + &config, + &ContainerRun { + target: &target, + root: &root, + container_root: &container_root, + docker: &docker.to_string_lossy(), + endpoint_env: &ide_docker::run_env(&endpoint), + file_env: &file_env, + name: &name, + user: user(&root, &target), + }, + ) + }) + .await +} + +/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon. +fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult { + let id = target.docker_connection.trim(); + if id.is_empty() { + return Ok(DockerEndpoint::Default); + } + ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else( + || { + IdeError::not_found(format!( + "Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets", + target.name + )) + }, + ) +} + +/// The configuration's `.env` files, read here: the container gets their variables. +fn env_files(root: &Path, config: &RunConfig) -> IdeResult> { + let extras = &config.extras; + if extras.env_files.is_empty() { + return Ok(BTreeMap::new()); + } + let paths: Vec = extras + .env_files + .iter() + .map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) }) + .collect(); + let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() }; + ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}"))) +} + +/// On Linux the container writes files as the project folder's owner, unless the +/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves. +fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> { + #[cfg(target_os = "linux")] + if !target.image_user { + use std::os::unix::fs::MetadataExt; + return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid())); + } + let _ = (root, target); + None +} + +/// Distinguishes runs of the same configuration. +fn unique() -> String { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNT: AtomicU32 = AtomicU32::new(0); + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed)) +} diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 702c35c2d..46db0eaad 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -46,10 +46,42 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult IdeResult> { +pub fn sdk_python_effective(host: &Host, window: &str, near: Option) -> IdeResult> { host.require_trust(window)?; let project = host.project(window)?; - Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into)) + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|w| { + let near = near.as_deref()?; + w.projects.into_iter().filter(|root| near.starts_with(root)).max_by_key(|root| root.components().count()) + }); + let (root, settings) = match member { + Some(root) => { + let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective(); + (root, settings) + } + None => (project.root.clone(), project.settings.effective()), + }; + Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into)) +} + +/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. +pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult> { + host.require_trust(window)?; + let project = host.project(window)?; + let settings = project.settings.effective(); + Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into)) +} + +/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the +/// project root). +pub async fn sdk_php_xdebug( + host: &Host, + window: &str, + php: PathBuf, + cwd: Option, +) -> IdeResult { + host.require_trust(window)?; + let cwd = cwd.or_else(|| host.project(window).ok().map(|p| p.root.clone())); + tokio::task::spawn_blocking(move || ide_sdk::php::xdebug(&php, cwd.as_deref())).await.map_err(join_err)? } /// The PHP language level of the project `path` belongs to (a workspace member, by path, as @@ -88,6 +120,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> .map_err(join_err) } +/// The Composer project `path` belongs to, when its dependencies need `composer install`. +#[derive(Clone, Debug, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ComposerInstallState { + pub root: ide_core::IpcPath, + pub reason: ide_sdk::php::ComposerInstall, +} + +/// `None` in an untrusted project too: `composer install` runs the project's scripts. +pub async fn sdk_php_composer_install( + host: &Host, + window: &str, + path: PathBuf, +) -> IdeResult> { + let project = host.project(window)?; + if !project.is_trusted() { + return Ok(None); + } + let root = project.root.clone(); + tokio::task::spawn_blocking(move || { + ide_sdk::php::composer_install(&path, &root) + .map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason }) + }) + .await + .map_err(join_err) +} + /// SDK / library source folders shown under "External Libraries" in the Project view, /// with the Maven / Gradle dependency jars of the build model. pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult> { @@ -127,10 +186,11 @@ pub async fn sdk_locate_tools( .map_err(join_err) } -/// `base -m venv location`; output lines stream through `on_output`. +/// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`. pub async fn sdk_python_create_venv( host: &Host, window: &str, + tool: ide_sdk::python::VenvTool, base: PathBuf, location: PathBuf, request: RequestId, @@ -139,7 +199,7 @@ pub async fn sdk_python_create_venv( host.require_trust(window)?; let cancel = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { - ide_sdk::python::create_venv(&base, &location, &cancel, &|line| { + ide_sdk::python::create_venv(tool, &base, &location, &cancel, &|line| { let _ = on_output.send(line); }) }) @@ -197,11 +257,17 @@ pub async fn sdk_python_pip( /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await }); - d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) }); + d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) }); + d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) }); + d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await }); d.add( "sdk_php_language_level", |c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await }, ); + d.add( + "sdk_php_composer_install", + |c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await }, + ); d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await }); d.add("sdk_locate_tools", |c| async move { sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await @@ -210,6 +276,7 @@ pub(crate) fn register(d: &mut Dispatcher) { sdk_python_create_venv( &c.host, &c.window, + c.arg("tool")?, c.arg("base")?, c.arg("location")?, c.arg("request")?, diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index b92399c2f..d3188b77a 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult { .collect(); // Godot is usually an app bundle / Flatpak outside the search path. let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); - Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) + let work_dir = (!root.as_os_str().is_empty()).then_some(root); + Ok(Tools::new(&data) + .with_custom_paths(custom) + .with_found_paths(found.into_iter().collect()) + .with_work_dir(work_dir)) } /// The language definitions plus the servers defined in the window's settings. diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 9dc44f29a..29f4d58a9 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -103,12 +103,20 @@ pub enum RemoteOp { Pull { rebase: bool, }, + /// Update Project › Reset to the Remote Branch. + ResetToUpstream, Push { force_with_lease: bool, set_upstream: bool, /// Push dialog › Push tags. #[serde(default)] tags: Option, + /// Plain `--force` (Force push anyway). + #[serde(default)] + force: bool, + /// Push dialog › Run Git hooks unchecked (`--no-verify`). + #[serde(default)] + no_verify: bool, }, /// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). DeleteBranch { @@ -125,6 +133,23 @@ pub enum RemoteOp { target: Option, #[serde(default)] remote: Option, + #[serde(default)] + force: bool, + /// Record the target as upstream (Push dialog › Set upstream). + #[serde(default)] + set_upstream: bool, + #[serde(default)] + no_verify: bool, + }, + /// Push Tag › Push to `remote`. + PushTag { + tag: String, + remote: String, + }, + /// Deletes tag `tag` on `remote` (Delete on Remote). + DeleteRemoteTag { + tag: String, + remote: String, }, /// Fast-forwards local `branch` (not checked out) to its upstream. UpdateBranch { @@ -155,11 +180,15 @@ pub struct GitExecutableInfo { pub enum GitOperation { /// Continue the merge / rebase / cherry-pick / revert in progress. Continue, + /// Stage and Retry: stage the changes of tracked files, then continue. + StageAndContinue, Abort, Skip, - /// Commits oldest first. + /// Commits oldest first; `record_origin` adds "(cherry picked from commit …)". CherryPick { commits: Vec, + #[serde(default, rename = "recordOrigin")] + record_origin: bool, }, /// Commits newest first. Revert { @@ -572,6 +601,33 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option, c blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await } +/// Edit Commit Message: rewrites the commit and its descendants without touching the +/// working tree. +pub async fn vcs_reword( + host: &Host, + window: &str, + repo: Option, + commit: String, + message: String, +) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await +} + +/// Undo of a history rewrite (Edit Commit Message, Drop, Squash). +pub async fn vcs_restore_head( + host: &Host, + window: &str, + repo: Option, + before: String, + after: String, +) -> IdeResult<()> { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await +} + pub async fn vcs_delete_branch( host: &Host, window: &str, @@ -584,6 +640,19 @@ pub async fn vcs_delete_branch( } /// "Restore" of the branch-deleted notification. +/// Deletes a local tag; the result restores it. +pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option, name: String) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await +} + +pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option, deleted: DeletedTag) -> IdeResult<()> { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await +} + pub async fn vcs_restore_branch( host: &Host, window: &str, @@ -679,26 +748,42 @@ pub async fn vcs_remote( let out = |line: String| { let _ = on_output.send(line); }; - let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull"); + let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream) + .then_some("Git pull"); project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { RemoteOp::Fetch => r.fetch(&cancel, &out), RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out), - RemoteOp::Push { force_with_lease, set_upstream, tags } => { - r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out) + RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out), + RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => { + r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out) } RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out), - RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to( + RemoteOp::PushBranch { + branch, + revision, + force_with_lease, + target, + remote, + force, + set_upstream, + no_verify, + } => r.push_branch_to( &branch, PushBranchOptions { revision: revision.as_deref(), target: target.as_deref(), remote: remote.as_deref(), force_with_lease, + force, + set_upstream, + no_verify, }, &cancel, &out, ), RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out), + RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out), + RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out), RemoteOp::PullFrom { remote, branches, rebase, options } => { r.pull_from(&remote, &branches, rebase, &options, &cancel, &out) } @@ -834,9 +919,12 @@ pub async fn vcs_operation( }; project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { GitOperation::Continue => r.continue_operation(&cancel, &out), + GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out), GitOperation::Abort => r.abort_operation(&cancel, &out), GitOperation::Skip => r.skip_operation(&cancel, &out), - GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out), + GitOperation::CherryPick { commits, record_origin } => { + r.cherry_pick_with(&commits, record_origin, &cancel, &out) + } GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out), GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out), GitOperation::InteractiveRebase { base, entries } => { @@ -1007,6 +1095,25 @@ pub async fn vcs_diff_revisions( blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await } +/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file. +pub async fn vcs_diff_numstat( + host: &Host, + window: &str, + repo: Option, + from: String, + to: String, + paths: Vec, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await +} + +/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). +pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option, name: String) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull /// Request tab's three-dot diff. pub async fn vcs_merge_base( @@ -1234,9 +1341,22 @@ pub(crate) fn register(d: &mut Dispatcher) { "vcs_undo_commit", |c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await }, ); + d.add("vcs_reword", |c| async move { + vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await + }); + d.add("vcs_restore_head", |c| async move { + vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await + }); d.add("vcs_delete_branch", |c| async move { vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await }); + d.add( + "vcs_delete_tag", + |c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await }, + ); + d.add("vcs_restore_tag", |c| async move { + vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await + }); d.add("vcs_restore_branch", |c| async move { vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await }); @@ -1357,6 +1477,10 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_diff_revisions", |c| async move { vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await }); + d.add("vcs_diff_numstat", |c| async move { + vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await + }); + d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await }); d.add("vcs_merge_base", |c| async move { vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await }); diff --git a/crates/ide-host/src/phpstan.rs b/crates/ide-host/src/phpstan.rs new file mode 100644 index 000000000..e74d7ac3c --- /dev/null +++ b/crates/ide-host/src/phpstan.rs @@ -0,0 +1,850 @@ +//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics. + +use ide_core::event::NotifyLevel; +use ide_core::output_encoding::decode_tool_output; +use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink}; +use ide_workspace::SettingsStore; +use ide_workspace::documents::DocumentObserver; +use parking_lot::Mutex; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::Notify; + +const DELAY: Duration = Duration::from_millis(750); +const SOURCE: &str = "phpstan"; + +/// Saved files waiting for a check. Each save cancels the wait or the check in progress. +#[derive(Default)] +struct Queue { + generation: u64, + cancel: CancellationToken, + files: BTreeSet, +} + +impl Queue { + fn push(&mut self, files: impl IntoIterator) { + self.files.extend(files); + self.cancel.cancel(); + self.generation += 1; + self.cancel = CancellationToken::new(); + } + fn current(&self, generation: u64) -> bool { + self.generation == generation && !self.cancel.is_cancelled() + } +} + +pub(crate) struct Phpstan { + root: PathBuf, + config_dir: PathBuf, + settings: Arc, + sink: SharedSink, + trusted: Arc, + lifetime: CancellationToken, + queue: Mutex, + changed: Notify, + /// Files with PHPStan diagnostics, the only ones a clean result or a removal clears. + shown: Mutex>, +} + +impl Phpstan { + pub(crate) fn new( + root: PathBuf, + config_dir: PathBuf, + settings: Arc, + sink: SharedSink, + trusted: Arc, + ) -> Arc { + Arc::new(Self { + root, + config_dir, + settings, + sink, + trusted, + lifetime: CancellationToken::new(), + queue: Mutex::new(Queue::default()), + changed: Notify::new(), + shown: Mutex::new(HashSet::new()), + }) + } + + pub(crate) fn start(self: &Arc, runtime: &tokio::runtime::Handle) { + let checker = self.clone(); + runtime.spawn(async move { checker.run().await }); + } + + pub(crate) fn stop(&self) { + self.lifetime.cancel(); + self.queue.lock().cancel.cancel(); + } + + /// Stops the check in progress (trust changed); its files wait for the next one, which drops + /// them while the project is in Restricted Mode. + pub(crate) fn interrupt(&self) { + self.queue.lock().push([]); + self.changed.notify_one(); + } + + fn files_saved(&self, paths: impl IntoIterator) { + let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable(); + if self.lifetime.is_cancelled() || files.peek().is_none() { + return; + } + self.queue.lock().push(files); + self.changed.notify_one(); + } + + /// Deleted files and folders lose their diagnostics. + pub(crate) fn files_removed<'a>(&self, removed: impl Iterator) { + let removed: Vec<&PathBuf> = removed.collect(); + let gone: Vec = + self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect(); + for path in gone { + self.publish(path, Vec::new()); + } + } + + fn publish(&self, path: PathBuf, items: Vec) { + let mut shown = self.shown.lock(); + if items.is_empty() { + if !shown.remove(&path) { + return; + } + } else { + shown.insert(path.clone()); + } + // Under the lock, so a removal and a result for one file reach the UI in the order decided here. + self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items }); + } + + async fn run(self: Arc) { + // The failure last reported per root: each is reported once, until a run succeeds. + let mut failures: BTreeMap = BTreeMap::new(); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + _ = self.changed.notified() => {}, + } + let (generation, cancel) = { + let queue = self.queue.lock(); + (queue.generation, queue.cancel.clone()) + }; + if !debounce(&cancel, &self.lifetime).await { + continue; + } + let files = { + let mut queue = self.queue.lock(); + if !queue.current(generation) { + continue; + } + std::mem::take(&mut queue.files) + }; + if !self.trusted.load(Ordering::Acquire) { + continue; + } + let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone()); + let Ok((checks, unchecked)) = + tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await + else { + continue; + }; + for path in unchecked { + self.publish(path, Vec::new()); + } + let mut checks = checks.into_iter(); + while let Some(check) = checks.next() { + let started = std::time::Instant::now(); + let program = check.command.get_program().to_string_lossy().into_owned(); + let output = analyse(check.command, &cancel).await; + let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else { + // A newer save: these files are checked again with it. + let mut queue = self.queue.lock(); + queue.files.extend(check.files); + queue.files.extend(checks.by_ref().flat_map(|c| c.files)); + break; + }; + tracing::debug!( + "phpstan checked {} file(s) in {} ms", + check.files.len(), + started.elapsed().as_millis() + ); + // Files deleted meanwhile make the result (or the failure) moot: check the rest again. + let (present, missing): (Vec, Vec) = + check.files.iter().cloned().partition(|f| f.is_file()); + if !missing.is_empty() { + for path in missing { + self.publish(path, Vec::new()); + } + self.files_saved(present); + continue; + } + let report = match output { + Ok(output) => { + let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr)); + parsed.map_err(|text| { + if text.is_empty() { + let status = output.status; + format!("PHPStan ended with {status} and no report") + } else { + text + } + }) + } + Err(error) => Err(format!("Cannot run {program}: {error}")), + }; + let failure = match report { + Ok(report) => { + let files = check.files.clone(); + let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files)) + .await + .unwrap_or_default(); + for (path, items) in results { + // Severe errors (a syntax error) stop the analysis of the other files. + if report.complete || !items.is_empty() { + self.publish(path, items); + } + } + report.errors.join("\n") + } + Err(text) => { + // Without a report the earlier errors of these files may be stale. + for path in check.files { + self.publish(path, Vec::new()); + } + text + } + }; + if failure.is_empty() { + failures.remove(&check.root); + } else { + self.failure(&check.root, failure, &mut failures); + } + } + } + } + + fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap) { + if failures.get(root) == Some(&text) { + return; + } + self.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "PHPStan check failed".into(), + body: Some(text.clone()), + }); + failures.insert(root.to_path_buf(), text); + } +} + +impl DocumentObserver for Phpstan { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + self.files_saved([path.to_path_buf()]); + } +} + +async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool { + tokio::select! { + _ = lifetime.cancelled() => false, + _ = cancel.cancelled() => false, + _ = tokio::time::sleep(DELAY) => true, + } +} + +/// One `vendor/bin/phpstan` run: the saved files below one PHP project. +struct Check { + root: PathBuf, + files: Vec, + command: Command, +} + +/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer +/// apply. A file belongs to the innermost linked project (with its own settings) that contains it, +/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a +/// project without PHPStan are not checked. +fn plan( + project: &Path, + config_dir: &Path, + settings: &SettingsStore, + files: BTreeSet, +) -> (Vec, Vec) { + let members = + ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default(); + let mut groups: BTreeMap> = BTreeMap::new(); + let mut unchecked = Vec::new(); + for file in files { + let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count()); + let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf())); + match root { + Some(root) if file.is_file() => groups.entry(root).or_default().push(file), + _ => unchecked.push(file), + } + } + let mut checks = Vec::new(); + for (root, files) in groups { + let effective = if root == project { + settings.effective() + } else { + SettingsStore::load(config_dir, Some(&root)).effective() + }; + let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs()); + let (files, skipped): (Vec, Vec) = files.into_iter().partition(|f| { + f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel)) + }); + unchecked.extend(skipped); + match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) { + Some(command) => checks.push(Check { root, files, command }), + None => unchecked.extend(files), + } + } + (checks, unchecked) +} + +/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it. +async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option> { + if cancel.is_cancelled() { + return None; + } + ide_core::process::prepare(&mut cmd); + ide_core::process::own_group(&mut cmd); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + let child = match cmd.spawn() { + Ok(child) => child, + Err(error) => return Some(Err(error)), + }; + let pid = child.id(); + // PHPStan may start workers of its own. + let cancelled = async { + cancel.cancelled().await; + if let Some(pid) = pid { + let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await; + } + }; + tokio::select! { + output = child.wait_with_output() => Some(output), + () = cancelled => None, + } +} + +/// A PHPStan message: 0-based line, text with its tip, identifier. +#[derive(Debug, PartialEq)] +struct Message { + line: u32, + text: String, + identifier: Option, +} + +/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and +/// whether every file was analysed (severe errors such as a syntax error stop the others). +#[derive(Debug, PartialEq)] +struct Report { + files: BTreeMap>, + errors: Vec, + complete: bool, +} + +impl Default for Report { + fn default() -> Self { + Self { files: BTreeMap::new(), errors: Vec::new(), complete: true } + } +} + +/// PHPStan's `--error-format=json` report, or the text to report when there is none (an +/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints, +/// may precede the report on stdout. +fn parse(stdout: &str, stderr: &str) -> Result { + let json = serde_json::from_str::(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| { + stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok())) + }); + let Some(Value::Object(json)) = json else { + // Every saved file is excluded by the configuration (`excludePaths`). + if stderr.contains("No files found to analyse") { + return Ok(Report::default()); + } + return Err(failure_text(stdout, stderr)); + }; + let mut report = Report::default(); + if let Some(Value::Object(files)) = json.get("files") { + for (file, result) in files { + let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten(); + report.files.insert(file.clone(), messages.filter_map(message).collect()); + } + } + let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten(); + report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect(); + report.complete = !stderr.contains("Result is incomplete"); + Ok(report) +} + +fn message(m: &Value) -> Option { + let mut text = m.get("message")?.as_str()?.to_owned(); + if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) { + text = format!("{text}\n{}", strip_console_tags(tip)); + } + let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1; + Some(Message { + line: u32::try_from(line).unwrap_or(u32::MAX), + text, + identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned), + }) +} + +/// Tips may carry console styling (`…`). +fn strip_console_tags(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('<') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let tag = rest.find('>').map(|end| &rest[..=end]); + match tag { + Some(tag) + if tag == "" + || tag.starts_with(">().join("\n") +} + +/// The diagnostics of each checked file (none for a clean one), matched to the report's files +/// by their real paths. Errors PHPStan reports in a trait name the class using it +/// (`Trait.php (in context of class Foo)`). +fn diagnostics(files: &[PathBuf], reported: BTreeMap>) -> Vec<(PathBuf, Vec)> { + let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + let mut messages: BTreeMap> = BTreeMap::new(); + for (name, list) in reported { + let name = name.split(" (in context of ").next().unwrap_or(&name); + messages.entry(real(Path::new(name))).or_default().extend(list); + } + files + .iter() + .map(|file| { + let list = messages.remove(&real(file)).unwrap_or_default(); + // Columns only: a file in another encoding than UTF-8 still gives the right lines. + let text = if list.is_empty() { + None + } else { + std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + }; + let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect(); + (file.clone(), items) + }) + .collect() +} + +fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic { + Diagnostic { + range: line_range(text, m.line), + severity: Severity::Warning, + message: m.text, + source: Some("PHPStan".into()), + server: Some(SOURCE.into()), + code: m.identifier, + unnecessary: false, + deprecated: false, + } +} + +/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16). +fn line_range(text: Option<&str>, line: u32) -> Range { + let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t)); + let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end(); + let indent = content.len() - content.trim_start().len(); + let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX); + Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{ + "/p/src/A.php":{"errors":2,"messages":[ + {"message":"Constant UNKNOWN not found.","line":11,"ignorable":true, + "tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"}, + {"message":"Access to an undefined property.","line":3,"ignorable":true, + "tip":"Learn more: https://phpstan.org/blog/solving for array","identifier":"property.notFound"}]}, + "/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[ + {"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}}, + "errors":[]}"#; + + fn message(line: u32, text: &str, identifier: &str) -> Message { + Message { line, text: text.into(), identifier: Some(identifier.into()) } + } + + #[test] + fn messages_keep_their_line_tip_and_identifier() { + let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!( + report.files["/p/src/A.php"], + [ + message( + 10, + "Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols", + "constant.notFound" + ), + message( + 2, + "Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array", + "property.notFound" + ), + ] + ); + assert_eq!( + report.files["/p/src/T.php (in context of class App\\A)"], + [message(0, "Syntax error", "phpstan.parse")] + ); + } + + #[test] + fn errors_of_the_run_are_not_file_messages() { + let stdout = + r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#; + let report = parse(stdout, "").unwrap(); + assert!(report.files.is_empty()); + assert_eq!(report.errors, ["Invalid entries in excludePaths"]); + // PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file. + let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}"); + assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]); + let marked = format!("\u{feff}{stdout}"); + assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]); + assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]); + assert!(parse(stdout, "").unwrap().complete); + let severe = " Result is incomplete because of severe errors."; + assert!(!parse(stdout, severe).unwrap().complete); + } + + #[test] + fn output_without_a_report_is_a_failure() { + let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n"; + assert_eq!( + parse("", stderr), + Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string()) + ); + assert_eq!( + parse("Fatal error: Allowed memory size exhausted", ""), + Err("Fatal error: Allowed memory size exhausted".into()) + ); + assert_eq!(parse("", ""), Err(String::new())); + // Excluded files are no failure: nothing to report. + assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default())); + } + + #[test] + fn checked_files_get_their_messages_and_clean_files_none() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php")); + std::fs::write(&a, ">(), + [(a, 1), (t, 1), (clean, 0)] + ); + let d = &out[0].1[0]; + // From the first non-blank character to the end of the line, in UTF-16 columns. + assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } }); + assert_eq!( + (d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity), + (Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning) + ); + assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } }); + } + + #[tokio::test(start_paused = true)] + async fn burst_saves_give_one_check_of_all_files() { + let mut queue = Queue::default(); + queue.push([PathBuf::from("/p/A.php")]); + let token = queue.cancel.clone(); + let lifetime = CancellationToken::new(); + let pending_lifetime = lifetime.clone(); + let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await }); + tokio::task::yield_now().await; + tokio::time::advance(DELAY - Duration::from_millis(1)).await; + assert!(!waiting.is_finished()); + for i in 0..20 { + queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]); + } + assert!(!waiting.await.unwrap()); + let current = queue.generation; + assert!(debounce(&queue.cancel, &lifetime).await); + assert!(queue.current(current)); + assert_eq!(queue.files.len(), 4); + lifetime.cancel(); + assert!(!debounce(&queue.cancel, &lifetime).await); + } + + /// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and + /// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it. + #[cfg(unix)] + struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + events: Arc>>, + trusted: Arc, + checker: Arc, + } + + #[cfg(unix)] + impl Fixture { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(dir.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(root.join("vendor/bin")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("vendor/bin/phpstan"), "/dev/null || echo 0)\"\n\ + echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n"; + std::fs::write(&php, script).unwrap(); + std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy()); + std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap(); + let config = base.join("config"); + let settings = Arc::new(SettingsStore::load(&config, Some(&root))); + let events = Arc::new(Mutex::new(Vec::new())); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e)); + let trusted = Arc::new(AtomicBool::new(true)); + let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone()); + checker.start(&tokio::runtime::Handle::current()); + Self { _dir: dir, root, events, trusted, checker } + } + + fn file(&self, name: &str) -> PathBuf { + let path = self.root.join("src").join(name); + std::fs::write(&path, " = identifiers + .iter() + .map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true})) + .collect(); + let files = if messages.is_empty() { + serde_json::json!({}) + } else { + serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} }) + }; + std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string()) + .unwrap(); + } + + fn runs(&self) -> Vec { + std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect() + } + + async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) { + for _ in 0..200 { + if done(self) { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock()); + } + + fn diagnostics(&self) -> Vec<(PathBuf, Vec>)> { + self.events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Diagnostics { path, source, items } if source == SOURCE => { + Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect())) + } + _ => None, + }) + .collect() + } + + fn notifications(&self) -> Vec { + let events = self.events.lock(); + events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect() + } + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + for _ in 0..5 { + f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]); + } + f.until("one check", |f| f.diagnostics().len() == 1).await; + assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]); + assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]); + + // A save during a check kills it; the next check covers both files. + std::fs::write(f.root.join("pause"), "30").unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the slow check", |f| f.runs().len() == 2).await; + std::fs::write(f.root.join("pause"), "0").unwrap(); + let b = f.file("B.php"); + f.report(&a, &[]); + f.checker.files_saved([b.clone()]); + f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await; + assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs()); + // A clean result clears A; B never had diagnostics, so nothing is sent for it. + assert_eq!(f.diagnostics()[1], (a, vec![])); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the first check", |f| f.diagnostics().len() == 1).await; + + std::fs::write(f.root.join("report"), "").unwrap(); + for run in 2..=3 { + f.checker.files_saved([a.clone()]); + f.until("the failing check", |f| f.runs().len() == run).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + let notes = f.notifications(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}"); + // The failed check leaves no stale errors. + assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]); + + std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap(); + let events = f.events.lock().len(); + f.checker.files_saved([a.clone()]); + tokio::time::sleep(DELAY * 2).await; + assert_eq!(f.events.lock().len(), events); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() { + let f = Fixture::new(); + let (a, b) = (f.file("A.php"), f.file("B.php")); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check of A", |f| f.diagnostics().len() == 1).await; + // A syntax error in B: PHPStan reports B only and says the result is incomplete. + f.report(&b, &["phpstan.parse"]); + std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap(); + f.checker.files_saved([a.clone(), b.clone()]); + f.until("the check of both", |f| f.diagnostics().len() == 2).await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())])); + assert_eq!(f.diagnostics().len(), 2, "A is not cleared"); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() { + let f = Fixture::new(); + let (a, b) = (f.file("A.php"), f.file("B.php")); + f.report(&a, &["constant.notFound"]); + std::fs::write(f.root.join("pause"), "1").unwrap(); + f.checker.files_saved([a.clone(), b.clone()]); + f.until("the check", |f| f.runs().len() == 1).await; + std::fs::remove_file(&a).unwrap(); + // B is checked again on its own. + f.until("the check of B", |f| f.runs().len() == 2).await; + assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs()); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(f.events.lock().is_empty(), "{:?}", f.events.lock()); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restricted_mode_stops_the_check_in_progress() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + std::fs::write(f.root.join("pause"), "30").unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.runs().len() == 1).await; + f.trusted.store(false, Ordering::Release); + f.checker.interrupt(); + tokio::time::sleep(DELAY * 2).await; + assert_eq!(f.runs().len(), 1); + assert!(f.events.lock().is_empty()); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn files_no_longer_checked_lose_their_diagnostics() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.diagnostics().len() == 1).await; + // PHPStan removed from the project: saving A no longer checks it and drops its errors. + std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the clear", |f| f.diagnostics().len() == 2).await; + assert_eq!(f.diagnostics()[1], (a, vec![])); + assert_eq!(f.runs().len(), 1); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn removed_files_lose_their_diagnostics() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.diagnostics().len() == 1).await; + f.checker.files_removed([f.root.join("src/other")].iter()); + assert_eq!(f.diagnostics().len(), 1); + f.checker.files_removed([f.root.join("src")].iter()); + assert_eq!(f.diagnostics()[1], (a, vec![])); + f.checker.stop(); + } +} diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index a09e61a1e..e2f6977e9 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -1,14 +1,14 @@ //! [`HostApi`] for one plugin's code: what its imports reach in the IDE. //! Capabilities were checked by the runtime before any of this runs. -use super::{PluginEvent, PluginEventSink}; +use super::{PluginContextKey, PluginEvent, PluginEventSink}; use ide_core::ipc_path::to_ipc; use ide_core::{CancellationToken, TextEdit}; use ide_extension::Package; use ide_language::ProgramPin; use ide_plugin::{ - EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus, - StatusItem, + EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt, + RepositoryStatus, StatusItem, }; use parking_lot::{Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -22,6 +22,10 @@ const MAX_STORAGE: usize = 1 << 20; const MAX_KEY: usize = 256; /// Most status bar items per plugin. const MAX_STATUS_ITEMS: usize = 8; +const MAX_CONTEXT_KEYS: usize = 100; + +/// (window or `None` for every window, plugin, key). +type ContextKey = (Option, String, String); /// What plugins see of the open projects; provided by the app /// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop. @@ -30,6 +34,11 @@ pub trait PluginWorkspace: Send + Sync { fn projects(&self) -> Vec; /// Unsaved text of the open document at `path`. fn buffer_text(&self, path: &Path) -> Option; + /// Up to `limit` files of the project index below `root` whose root-relative path satisfies + /// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead). + fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option> { + None + } /// Version and text of the open document at `path`. fn document(&self, path: &Path) -> Option<(u64, String)>; /// Applies `edits` to the open document at `path` if it is still at @@ -141,6 +150,12 @@ struct TaskEntry { cancelled: bool, } +/// A question a plugin's call waits on. +struct PromptEntry { + plugin: String, + answer: std::sync::mpsc::SyncSender>>, +} + /// State shared by the host and every plugin's [`PluginApi`]. pub(super) struct Shared { pub sink: Arc, @@ -149,8 +164,15 @@ pub(super) struct Shared { pub data_dir: PathBuf, tasks: Mutex>, next_task: AtomicU64, + prompts: Mutex>, + /// Plugins being stopped: their calls may not open new prompts (taken before `prompts`). + prompts_blocked: Mutex>, + next_prompt: AtomicU64, /// (plugin, item id) → item, for windows opened later. status: Mutex>, + /// (scope, plugin, key) → JSON value. + contexts: Mutex>, + closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). pub subscriptions: Mutex>>, /// plugin → files it shows problems for, withdrawn when it stops. @@ -178,7 +200,12 @@ impl Shared { data_dir, tasks: Mutex::default(), next_task: AtomicU64::new(1), + prompts: Mutex::default(), + prompts_blocked: Mutex::default(), + next_prompt: AtomicU64::new(1), status: Mutex::default(), + contexts: Mutex::default(), + closed_clients: Mutex::default(), subscriptions: Mutex::default(), diagnostics: Mutex::default(), }) @@ -221,6 +248,34 @@ impl Shared { self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() } + pub fn context_keys(&self, client: Option<&str>) -> Vec { + let contexts = self.contexts.lock(); + let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new(); + for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.is_none()) { + keys.insert((plugin, key), value); + } + if let Some(client) = client { + for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.as_deref() == Some(client)) { + keys.insert((plugin, key), value); + } + } + keys.into_iter() + .map(|((p, k), v)| PluginContextKey { plugin_id: p.to_owned(), key: k.to_owned(), value: v.to_owned() }) + .collect() + } + + /// Drops the context keys scoped to window `client` (it closed), without events, and + /// refuses further writes for it (late commands) until [`Self::reopen_client`]. + pub fn forget_client(&self, client: &str) { + self.closed_clients.lock().insert(client.to_owned()); + self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client)); + } + + /// A window labelled `client` is being created (labels are reused after a close). + pub fn reopen_client(&self, client: &str) { + self.closed_clients.lock().remove(client); + } + /// Withdraws a plugin's run-time state (it stopped or failed). Its /// subscriptions go only with `subscriptions`, as they survive restarts. pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { @@ -235,6 +290,19 @@ impl Shared { for id in removed { self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None }); } + self.withdraw_prompts(plugin); + let removed: Vec<(Option, String)> = { + let mut contexts = self.contexts.lock(); + let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect(); + for key in &keys { + contexts.remove(key); + } + keys.into_iter().map(|(scope, _, key)| (scope, key)).collect() + }; + for (scope, key) in removed { + self.sink + .emit(scope.as_deref(), PluginEvent::ContextChanged { plugin_id: plugin.to_owned(), key, value: None }); + } let tasks: Vec = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect(); for id in tasks { self.end_task(id); @@ -260,6 +328,40 @@ impl Shared { true } + /// Closes the prompts `plugin` waits on: the calls waiting for the user + /// wake up cancelled (dropping the senders). + fn withdraw_prompts(&self, plugin: &str) { + let ids: Vec = { + let mut prompts = self.prompts.lock(); + let ids: Vec = prompts.iter().filter(|(_, p)| p.plugin == plugin).map(|(id, _)| *id).collect(); + for id in &ids { + prompts.remove(id); + } + ids + }; + for id in ids { + self.sink.emit(None, PluginEvent::PromptClosed { prompt_id: id.to_string() }); + } + } + + /// `plugin` is about to be stopped: withdraws its prompts, and its queued + /// calls can't open new ones (they'd keep the stop waiting) until + /// [`allow_prompts`](Self::allow_prompts). + pub fn block_prompts(&self, plugin: &str) { + self.prompts_blocked.lock().insert(plugin.to_owned()); + self.withdraw_prompts(plugin); + } + + pub fn allow_prompts(&self, plugin: &str) { + self.prompts_blocked.lock().remove(plugin); + } + + /// The user answered (`Some`) or dismissed (`None`) a prompt. + pub fn answer_prompt(&self, id: u64, answer: Option>) -> bool { + let Some(prompt) = self.prompts.lock().remove(&id) else { return false }; + prompt.answer.send(answer).is_ok() + } + fn end_task(&self, id: u64) { if let Some(t) = self.tasks.lock().remove(&id) { self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() }); @@ -306,14 +408,138 @@ impl PluginApi { // allowed, and installing it later approves nothing by itself. let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| { HostError::Failed(format!( - "{cap}: `{program}` was not found when this permission was allowed; \ - install it, then allow {cap} again in Settings › Plugins" + "{cap}: `{program}` was not found on PATH when this permission was allowed. \ + Restarting doesn't approve a program installed later: once `{program}` is on PATH, \ + clear {cap} in Settings › Plugins and select it again" )) })?; pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}"))) } } +impl PluginApi { + /// Runs an approved program until it exits or `cancel` fires, passing + /// each output line to `on_line`; returns its exit code. + pub(super) fn stream_process( + &self, + program: &str, + args: Vec, + cwd: Option<&Path>, + cancel: &CancellationToken, + on_line: &dyn Fn(super::PluginOutputLine), + ) -> Result, HostError> { + let binary = self.approved_program(program)?; + let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); + let _ = std::fs::create_dir_all(&cwd); + let mut cmd = std::process::Command::new(&binary); + ide_core::process::prepare(ide_core::process::own_group(&mut cmd)); + let mut child = cmd + .args(&args) + .current_dir(&cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?; + // Bounded: a program printing faster than the lines are sent waits on its pipe. + let (tx, rx) = std::sync::mpsc::sync_channel(1024); + let read = |pipe: Box, stderr: bool| { + let tx = tx.clone(); + std::thread::spawn(move || { + read_lines(pipe, MAX_OUTPUT_LINE, &mut |text| { + tx.send(super::PluginOutputLine { stderr, text }).is_ok() + }); + }); + }; + if let Some(out) = child.stdout.take() { + read(Box::new(out), false); + } + if let Some(err) = child.stderr.take() { + read(Box::new(err), true); + } + drop(tx); + // Revoking the grant removes the pin: stop then. + let cap = format!("process:{program}"); + let pin = (self.id().to_owned(), cap.clone()); + let stop = |child: &mut std::process::Child| -> Option { + if cancel.is_cancelled() { + kill_group(child); + return Some(HostError::Cancelled); + } + if !self.shared.pins.read().contains_key(&pin) { + kill_group(child); + return Some(HostError::Denied(cap.clone())); + } + None + }; + loop { + if let Some(e) = stop(&mut child) { + return Err(e); + } + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(line) => on_line(line), + // Both pipes closed: the program is done (or detached them). + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + } + loop { + if let Some(status) = child.try_wait().map_err(failed)? { + return Ok(status.code()); + } + if let Some(e) = stop(&mut child) { + return Err(e); + } + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +/// Longest output line a followed program sends; the rest of the line is dropped. +const MAX_OUTPUT_LINE: usize = 16 << 10; + +/// Passes `pipe`'s lines (without `\r\n`, at most `max` bytes each) to `line` +/// until it returns false or the pipe closes, holding at most one line. +fn read_lines(pipe: Box, max: usize, line: &mut dyn FnMut(String) -> bool) { + use std::io::{BufRead, Read}; + let mut reader = std::io::BufReader::new(pipe); + let mut buf = Vec::new(); + loop { + buf.clear(); + match (&mut reader).take(max as u64).read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + if buf.last() != Some(&b'\n') && buf.len() == max { + // Too long: skip to the end of the line. + let mut rest = Vec::new(); + loop { + rest.clear(); + match (&mut reader).take(max as u64).read_until(b'\n', &mut rest) { + Ok(0) | Err(_) => break, + Ok(_) if rest.last() == Some(&b'\n') => break, + Ok(_) => {} + } + } + } + let end = buf.strip_suffix(b"\n").unwrap_or(&buf); + let end = end.strip_suffix(b"\r").unwrap_or(end); + if !line(String::from_utf8_lossy(end).into_owned()) { + return; + } + } +} + +/// Kills a program spawned in its own group and what it started. A kill that +/// lands while the program forks can miss the new process, so the group is +/// signalled again before the program is reaped (until then its group id +/// can't be reused). +fn kill_group(child: &mut std::process::Child) { + ide_core::process::kill_tree_of(child.id()); + std::thread::sleep(Duration::from_millis(50)); + ide_core::process::kill_tree(child); +} + fn failed(e: impl std::fmt::Display) -> HostError { HostError::Failed(e.to_string()) } @@ -337,6 +563,52 @@ impl HostApi for PluginApi { Ok(()) } + fn ask( + &self, + client: Option<&str>, + prompt: Prompt, + cancel: &CancellationToken, + ) -> Result>, HostError> { + let client = from_window(client, "ask the user")?; + let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed); + let (answer, answers) = std::sync::mpsc::sync_channel(1); + { + let blocked = self.shared.prompts_blocked.lock(); + if blocked.contains(self.id()) { + return Err(HostError::Cancelled); + } + self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer }); + } + let prompt_id = id.to_string(); + self.shared.sink.emit( + Some(client), + PluginEvent::PromptRequested { plugin_id: self.id().to_owned(), prompt_id: prompt_id.clone(), prompt }, + ); + loop { + match answers.recv_timeout(Duration::from_millis(100)) { + Ok(answer) => return Ok(answer), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !cancel.is_cancelled() => {} + Err(_) => { + self.shared.prompts.lock().remove(&id); + self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id }); + return Err(HostError::Cancelled); + } + } + } + } + + fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> { + let client = from_window(client, "reveal a view")?; + let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view); + if !declared { + return Err(HostError::NotFound(format!("the plugin declares no view {view:?}"))); + } + self.shared + .sink + .emit(Some(client), PluginEvent::RevealView { plugin_id: self.id().to_owned(), view: view.to_owned() }); + Ok(()) + } + fn status_item(&self, id: &str, item: Option) { let key = (self.id().to_owned(), id.to_owned()); { @@ -365,6 +637,37 @@ impl HostApi for PluginApi { .emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item }); } + fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { + let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned()); + { + let mut contexts = self.shared.contexts.lock(); + if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) { + return Ok(()); + } + match &value { + Some(value) => { + if contexts.get(&map_key) == Some(value) { + return Ok(()); + } + let count = contexts.keys().filter(|(_, p, _)| p == self.id()).count(); + if !contexts.contains_key(&map_key) && count >= MAX_CONTEXT_KEYS { + return Err(HostError::Invalid(format!("more than {MAX_CONTEXT_KEYS} context keys"))); + } + contexts.insert(map_key, value.clone()); + } + None => { + if contexts.remove(&map_key).is_none() { + return Ok(()); + } + } + } + } + self.shared + .sink + .emit(client, PluginEvent::ContextChanged { plugin_id: self.id().to_owned(), key: key.to_owned(), value }); + Ok(()) + } + fn projects(&self) -> Vec { self.shared.workspace().map(|w| w.projects()).unwrap_or_default() } @@ -458,8 +761,28 @@ impl HostApi for PluginApi { .build() .map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))? .compile_matcher(); + // Project scope visits hidden files and folders (`.github`, `.config`): + // only `.git` internals stay out, as in the walk below. + let visible = |rel: &Path| !rel.iter().any(|c| c == ".git"); + // The project index already lists the files: walking a large tree on every call can take + // longer than an editor provider's deadline (the plugin is then stopped as crashed). + if let Some(files) = self + .shared + .workspace() + .and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit)) + { + let mut out: Vec = files.iter().map(|p| to_ipc(p)).collect(); + out.sort(); + return Ok(out); + } let mut out = Vec::new(); - for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() { + for (n, entry) in ignore::WalkBuilder::new(root) + .hidden(false) + .filter_entry(|e| e.file_name() != ".git") + .build() + .flatten() + .enumerate() + { if n >= MAX_WALK || out.len() >= limit { break; } @@ -737,6 +1060,12 @@ impl PluginWorkspace for HostWorkspace { HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path)) } + fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option> { + // The index of the project rooted at `root` (a workspace member's files are in its own). + let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?; + project.index.files_matching(root, matches, limit) + } + fn document(&self, path: &Path) -> Option<(u64, String)> { let (project, doc) = self.open_document(path)?; let snap = project.documents.snapshot(doc).ok()?; @@ -820,6 +1149,67 @@ mod tests { assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root)); } + /// A project index that lists `files` (root-relative), whatever is on disk. + struct Indexed(PathBuf, Vec<&'static str>); + impl PluginWorkspace for Indexed { + fn projects(&self) -> Vec { + vec![self.0.clone()] + } + fn buffer_text(&self, _: &Path) -> Option { + None + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + None + } + fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result { + Err(HostError::Stale) + } + fn trusted(&self, _: &Path) -> bool { + true + } + fn project_of(&self, _: &str) -> Option { + None + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> Result<(), HostError> { + Ok(()) + } + fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option> { + (root == self.0).then(|| { + self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect() + }) + } + } + + #[test] + fn find_files_uses_the_project_index_when_there_is_one() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let api = api(&root, Arc::default()); + *api.shared.workspace.write() = Some(Arc::new(Indexed( + root.clone(), + vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", ".git/index.xml", "pom.xml"], + ))); + // Listed by the index, not walked: none of these exist on disk. + assert_eq!( + api.find_files(&root, "**/*Mapper.xml", 10).unwrap(), + [to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))] + ); + // Hidden folders are visited like any project scope; `.git` internals are not. + assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 4); + assert_eq!(api.find_files(&root, ".idea/*.xml", 10).unwrap(), [to_ipc(&root.join(".idea/workspace.xml"))]); + assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1); + // Another root: no index covers it, so the disk is walked. + let other = root.join("other"); + std::fs::create_dir_all(other.join(".config/tool")).unwrap(); + std::fs::write(other.join("a.xml"), "").unwrap(); + std::fs::write(other.join(".config/tool/index.xml"), "").unwrap(); + assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]); + assert_eq!( + api.find_files(&other, ".config/tool/*.xml", 10).unwrap(), + [to_ipc(&other.join(".config/tool/index.xml"))] + ); + } + #[test] fn programs_run_only_as_approved() { let tmp = tempfile::tempdir().unwrap(); @@ -831,7 +1221,8 @@ mod tests { // Allowed while the program was missing: say how to fix it rather than "not granted". let err = api.approved_program("lint").unwrap_err().to_string(); assert!( - err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"), + err.contains("was not found on PATH when this permission was allowed") + && err.contains("clear process:lint in Settings › Plugins and select it again"), "{err}" ); diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs index 60b1ac2e0..99dfe3d11 100644 --- a/crates/ide-host/src/plugins/icons.rs +++ b/crates/ide-host/src/plugins/icons.rs @@ -26,6 +26,7 @@ use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id}; use super::icon_settings::{self, SettingsSection}; +use ide_extension::package::PluginIcon; use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; @@ -33,8 +34,10 @@ use std::path::PathBuf; pub const POINT_ID: &str = "iconThemes"; const MAX_ICONS: usize = 5000; -const MAX_SVG: usize = 64 << 10; +pub(super) const MAX_SVG: usize = 64 << 10; const MAX_MAPPINGS: usize = 20_000; +/// A plugin's own icon is inline in every plugin listing and index entry. +pub const MAX_PLUGIN_ICON: usize = 16 << 10; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -90,16 +93,24 @@ pub fn svg_prefix(theme: &str) -> String { format!("iconTheme/{theme}/svg/") } -/// An SVG icon of the package, size-checked and sanitised. -pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result { +/// An SVG icon of the package, size-checked (`max` bytes) and sanitised. +pub(super) fn read_icon(pkg: &Package, path: &std::path::Path, max: usize) -> Result { check_relative(path, "icon")?; let svg = pkg.read_text(path)?; - if svg.len() > MAX_SVG { - return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); + if svg.len() > max { + return Err(format!("{}: larger than {} KiB", path.display(), max >> 10)); } sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display())) } +/// The manifest's `icon` / `icon-dark`, read and sanitised. +pub fn plugin_icon(pkg: &Package) -> Result, String> { + let read = |path: &std::path::Path| read_icon(pkg, path, MAX_PLUGIN_ICON); + let Some(light) = &pkg.manifest.icon else { return Ok(None) }; + let dark = pkg.manifest.icon_dark.as_deref().map(read).transpose()?; + Ok(Some(PluginIcon { light: read(light)?, dark })) +} + pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { let d: Decl = decode(POINT_ID, decl)?; validate_item_id(&d.id, "icon theme id")?; @@ -166,7 +177,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result, + pub indexing: bool, + pub extensions: Vec, +} + +/// File discovery is separate from clicking: the UI schedules small background +/// batches after initial index completion and updates only changed files later. +pub async fn plugin_navigation_files( + host: &crate::Host, + plugins: Arc, + window: &str, +) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + let extensions = plugins.navigation_extensions(&project.root); + if extensions.is_empty() { + return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] }); + } + let indexing = project.index.status().indexing; + let files = project.index.files_with_extensions(&extensions, 20_001); + if files.len() > 20_000 { + return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files")); + } + Ok(NavigationFiles { + paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(), + indexing, + extensions, + }) + }) + .await +} + +pub fn plugin_navigation_invalidate( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Option>, +) -> IdeResult<()> { + let project = host.project(window)?; + plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref()); + Ok(()) +} + +pub async fn plugin_navigation_index( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + if paths.len() > 16 { + return Err(IdeError::invalid("navigation batches are limited to 16 files")); + } + let project = host.project(window)?; + let cancel = host.begin_project_request(window, request); + let client = window.to_owned(); + let result = blocking(move || { + if !project.is_trusted() { + return Err(IdeError::unavailable("project is not trusted")); + } + for path in paths { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else { + continue; + }; + let extensions = plugins.navigation_extensions(&project.root); + if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) { + continue; + } + let document = project.documents.by_path(&path); + let (text, version, language) = if let Some(id) = document { + let snapshot = project.documents.text_snapshot(id)?; + if snapshot.large { + continue; + } + (snapshot.text, Some(snapshot.version), snapshot.language) + } else { + use std::io::Read; + if !std::fs::metadata(&path) + .is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64) + { + continue; + } + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + let mut text = String::new(); + if file + .take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64) + .read_to_string(&mut text) + .is_err() + { + continue; + } + let language = project.languages.detect(&path, text.lines().next()); + (text, None, language) + }; + if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES { + continue; + } + let symbols = project + .index + .symbols_for_text(&path, &text) + .unwrap_or_else(|| project.syntax.syntax().tags(&language, &text)); + let roots = project.roots(); + let scope = roots + .roots + .iter() + .filter(|r| path.starts_with(project.root.join(&r.path))) + .max_by_key(|r| r.path.len()) + .and_then(|r| r.detected_from.as_deref()) + .unwrap_or(""); + plugins.index_navigation( + &project.root, + crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope }, + Invocation { + client: Some(client.clone()), + workspace_root: Some(project.root.clone()), + editor: None, + selected_paths: Vec::new(), + }, + )?; + // A document edit while WASM was executing must not publish stale ranges. + if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) { + plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path])); + } + } + Ok(()) + }) + .await; + host.end_request(request); + result +} + +pub async fn plugin_navigation_goto( + host: &crate::Host, + plugins: Arc, + window: &str, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + let (path, version) = project.documents.identity(doc)?; + Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +pub async fn plugin_navigation_markers( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > 64 { + return Err(IdeError::invalid("marker batches are limited to 64 files")); + } + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index fdf2e5147..789d3800b 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -6,9 +6,10 @@ //! Release builds pin the official publisher to the keys they were built //! with, so no index can sign `openidea.*` plugins with another key. +use super::icons::{MAX_PLUGIN_ICON, sanitize_svg}; use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install}; use ide_core::{IdeError, IdeResult}; -use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE}; +use ide_extension::package::{self, Index, IndexEntry, PackageSignature, PluginIcon, SIGNATURE_FILE}; use ide_extension::{MANIFEST_FILE, Manifest}; use semver::Version; use serde::{Deserialize, Serialize}; @@ -67,6 +68,14 @@ fn pinned(mut index: Index, official_keys: &[String]) -> Index { index } +/// An index's icon as the UI may show it: sanitised again, as any index may +/// have written it; without a light variant that passes, none. +fn safe_icon(icon: PluginIcon) -> Option { + // Before sanitising too: an index may hold megabytes of it. + let clean = |svg: String| (svg.len() <= MAX_PLUGIN_ICON).then(|| sanitize_svg(&svg).ok()).flatten(); + Some(PluginIcon { light: clean(icon.light)?, dark: icon.dark.and_then(clean) }) +} + /// Reads `url`: `http(s)://…`, `file://…` or a local path. pub fn read_url(url: &str, max: usize) -> Result, String> { if url.starts_with("https://") || url.starts_with("http://") { @@ -242,7 +251,9 @@ impl PluginHost { .get(&entry.id) .filter(|p| p.source == PluginSource::Installed) .map(|p| p.manifest().version.clone()); - MarketPlugin { entry: entry.clone(), installed, compatible } + let mut entry = entry.clone(); + entry.icon = entry.icon.and_then(safe_icon); + MarketPlugin { entry, installed, compatible } }) .collect() } @@ -350,7 +361,7 @@ impl PluginHost { pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult> { let manifest = Manifest::load(dir).map_err(IdeError::invalid)?; let pkg = ide_extension::Package::new(manifest, dir.to_path_buf()); - let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&pkg).map_err(IdeError::invalid)?; Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect()) } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0b82a9350..7d2d13c38 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -20,14 +20,17 @@ mod icon_extensions; mod icon_patterns; mod icon_settings; mod icons; +pub mod index_ops; mod install; pub mod intellij; mod market; +pub mod navigation; mod points; mod providers; pub mod remote; pub mod remote_host; mod run_detectors; +mod semantic_tokens; mod theme; mod types; pub mod ui_points; @@ -35,14 +38,17 @@ pub mod views; pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; -pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; +pub use icons::{ + MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix, +}; pub use market::{ INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER, read_url as read_market_url, resolve as resolve_market_href, }; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; -pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; +pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for}; +pub(crate) use semantic_tokens::merge as merge_semantic_tokens; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; @@ -55,7 +61,8 @@ use ide_extension::{ use ide_language::plugin_point::PluginLanguages; use ide_language::{LanguageConfig, ProgramPin}; use ide_plugin::{ - Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup, + Actor, ActorError, CANCELLED, CallError, HostApi, HostError, Invocation, Limits, PluginEngine, Runtime, + RuntimeSetup, }; use parking_lot::{Condvar, Mutex, RwLock}; use serde::{Deserialize, Serialize}; @@ -182,6 +189,8 @@ const IDLE_BATCH: Duration = Duration::from_secs(2); struct LoadedPlugin { pkg: Package, + /// Read at load so disabled and failed plugins show it too. + icon: Option, /// The manifest failed to parse: `pkg` is a placeholder. invalid: bool, source: PluginSource, @@ -308,6 +317,7 @@ pub struct PluginHost { run_detectors: Arc, /// `[[contributes.providers]]` of enabled plugins. providers: Arc, + navigation: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -396,6 +406,7 @@ impl PluginHost { languages, run_detectors, providers, + navigation: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -1066,6 +1077,66 @@ impl PluginHost { }) } + /// The Services pane of node `key` of the plugin's `location = "services"` + /// view `view`. + pub fn service_detail( + &self, + id: &str, + view: &str, + key: &str, + invocation: Invocation, + ) -> IdeResult> { + self.services_view(id, view)?; + let (view, key) = (view.to_owned(), key.to_owned()); + self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| { + rt.service_detail(&view, &key, inv) + }) + } + + /// Runs the program of the log tab `tab` of node `key`'s pane, with the + /// arguments of the toggles in `on`, streaming its output until it exits + /// or `cancel` fires; returns its exit code. The pane is asked of the + /// plugin again, so the UI never names what runs. Needs the plugin's + /// `process:` grant. + #[allow(clippy::too_many_arguments)] + pub fn stream_service_log( + &self, + id: &str, + view: &str, + key: &str, + tab: &str, + on: &[String], + invocation: Invocation, + cancel: &ide_core::CancellationToken, + on_line: &dyn Fn(PluginOutputLine), + ) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let cwd = invocation.workspace_root.clone(); + let tabs = self.service_detail(id, view, key, invocation)?; + let Some(ide_plugin::ServicePane::Log(log)) = tabs.into_iter().find(|t| t.id == tab).map(|t| t.body) else { + return Err(IdeError::not_found(format!("the pane of {key:?} has no log tab {tab:?}"))); + }; + let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args); + let args: Vec = log.args.into_iter().chain(toggled).collect(); + let plugin = + self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("plugin {id:?}")))?; + let cap = Capability::Process(log.program.clone()); + if !plugin.meta.lock().granted.iter().any(|g| g.covers(&cap)) { + let denied = HostError::Denied(cap.to_string()).to_string(); + return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, denied)); + } + let api = api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }; + api.stream_process(&log.program, args, cwd.as_deref(), cancel, on_line).map_err(|e| match e { + HostError::Cancelled => IdeError::cancelled(), + e @ HostError::Denied(_) => IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e.to_string()), + HostError::NotFound(m) => IdeError::not_found(m), + HostError::Invalid(m) => IdeError::invalid(m), + e => IdeError::unavailable(e.to_string()), + }) + } + fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> { let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS] .iter() @@ -1073,6 +1144,19 @@ impl PluginHost { if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } } + fn services_view(&self, id: &str, view: &str) -> IdeResult<()> { + let services = self + .ui + .items(ui_points::VIEWS, Some(id)) + .iter() + .any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)); + if services { + Ok(()) + } else { + Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}"))) + } + } + /// One call into a plugin's code for the UI: checks it may run (asking /// for missing consent), activates it, retries once when the instance was /// reclaimed meanwhile, and reports crashes. @@ -1252,11 +1336,34 @@ impl PluginHost { self.shared.status_items() } + /// Plugin context keys in effect for window `client`: global keys overlaid with its own. + pub fn context_keys(&self, client: Option<&str>) -> Vec { + self.shared.context_keys(client) + } + + /// A window labelled `label` is about to exist again (labels are reused). + pub fn reopen_client(&self, label: &str) { + self.shared.reopen_client(label); + } + + /// Window `label` closed: drops its context keys and queued events, and ignores + /// context writes of commands still running for it. + pub fn forget_client(&self, label: &str) { + self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label)); + self.shared.forget_client(label); + } + /// The user cancelled a plugin task (`task_id` from `TaskStarted`). pub fn cancel_task(&self, task_id: &str) -> bool { task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) } + /// The user answered (`Some`) or dismissed (`None`) a prompt + /// (`PromptRequested`); false when it is no longer waiting. + pub fn answer_prompt(&self, prompt_id: &str, answer: Option>) -> bool { + prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer)) + } + /// Kinds of workbench events some running or activatable plugin wants; /// the UI reports only these. pub fn event_kinds(&self) -> BTreeSet { @@ -1385,6 +1492,7 @@ impl PluginHost { workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), client: client.clone(), editor: None, + selected_paths: Vec::new(), }; let kinds: BTreeSet = events.iter().map(WorkbenchEvent::kind).collect(); { @@ -1524,7 +1632,7 @@ impl PluginHost { continue; } let pkg = Package::new(m.clone(), dir.clone()); - let problem = match self.registry.prepare(&pkg) { + let problem = match self.prepare(&pkg) { Err(e) => Some(e), Ok(_) => m .wasm @@ -1547,6 +1655,7 @@ impl PluginHost { authors: m.authors, homepage: m.homepage, change_notes: m.change_notes, + icon: plugin_icon(&pkg).ok().flatten(), path: dir.into(), problem, }); @@ -1558,6 +1667,9 @@ impl PluginHost { /// Deactivates every plugin (also done on drop). pub fn shutdown(&self) { let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); + for id in self.plugins.read().keys() { + self.shared.block_prompts(id); + } for a in &actors { a.stop(false); } @@ -1641,11 +1753,24 @@ impl PluginHost { self.plugins.read().values().any(|p| p.dir() == dir) } + /// Validates the manifest's icon and every declaration of `pkg`; the + /// icon files join the package files the declarations reference. + fn prepare(&self, pkg: &Package) -> Result { + plugin_icon(pkg)?; + let mut set = self.registry.prepare(pkg)?; + for icon in [&pkg.manifest.icon, &pkg.manifest.icon_dark].into_iter().flatten() { + if !set.files.contains(icon) { + set.files.push(icon.clone()); + } + } + Ok(set) + } + /// Parses and validates a package directory; nothing takes effect. fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> { let manifest = Manifest::load(src).map_err(IdeError::invalid)?; let pkg = Package::new(manifest, src.to_path_buf()); - let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&pkg).map_err(IdeError::invalid)?; if let Some(wasm) = &pkg.manifest.wasm { if !src.join(wasm).is_file() { return Err(IdeError::invalid(format!( @@ -1678,6 +1803,7 @@ impl PluginHost { pkg = pkg.with_digest(digest.trim()); } let plugin = Arc::new(LoadedPlugin { + icon: plugin_icon(&pkg).ok().flatten(), pkg, invalid, source, @@ -1727,7 +1853,7 @@ impl PluginHost { let publisher = self.verified_publisher(plugin.dir()); plugin.meta.lock().publisher = publisher; } - let set = match self.registry.prepare(&plugin.pkg) { + let set = match self.prepare(&plugin.pkg) { Ok(set) => set, Err(e) => return self.set_failed(plugin, e), }; @@ -1857,6 +1983,7 @@ impl PluginHost { Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))), }; *runtime = Some(actor.clone()); + self.shared.allow_prompts(plugin.id()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Active; meta.has_syntax = has_syntax; @@ -1881,6 +2008,7 @@ impl PluginHost { } } let Some(error) = actor.take_crash() else { return }; + self.clear_plugin_navigation(plugin.id()); if error.starts_with(CANCELLED) { // Cancelled on request: restart on next use, no backoff. let mut meta = plugin.meta.lock(); @@ -1952,6 +2080,9 @@ impl PluginHost { } fn stop(&self, plugin: &LoadedPlugin) { + self.clear_plugin_navigation(plugin.id()); + // A call waiting on the user would keep the join below waiting. + self.shared.block_prompts(plugin.id()); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(true); } @@ -1961,6 +2092,7 @@ impl PluginHost { } fn set_failed(&self, plugin: &LoadedPlugin, error: String) { + self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "{error}"); self.registry.dispose_plugin(plugin.id()); let mut meta = plugin.meta.lock(); @@ -1969,6 +2101,7 @@ impl PluginHost { } fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}"); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(false); @@ -2078,7 +2211,7 @@ impl PluginHost { return h.trim().to_owned(); } // Installed before hashes were recorded: hash once and remember. - let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); + let files = self.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); let files: Vec = files.into_iter().chain(plugin.manifest().wasm.clone()).collect(); let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default(); let _ = std::fs::write(&file, &hash); @@ -2145,6 +2278,7 @@ impl PluginHost { authors: m.authors.clone(), homepage: m.homepage.clone(), change_notes: m.change_notes.clone(), + icon: p.icon.clone(), source: p.source, path: p.dir().to_path_buf().into(), enabled: !self.state.lock().disabled.contains(&m.id), @@ -2254,6 +2388,8 @@ fn placeholder_manifest(dir: &Path) -> Manifest { license: None, change_notes: None, declared_categories: Vec::new(), + icon: None, + icon_dark: None, engine: ide_extension::ENGINE_VERSION.into(), legacy: false, wasm: None, diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs new file mode 100644 index 000000000..efa8ef5db --- /dev/null +++ b/crates/ide-host/src/plugins/navigation.rs @@ -0,0 +1,462 @@ +//! API 1.x document indexers: background snapshots become project-scoped +//! associations. Navigation and gutter markers only read validated memory indexes. +use super::PluginHost; +use ide_core::{IdeError, IdeResult, Location, Pos, Range}; +use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode}; +use ide_language::SymbolTag; +use ide_plugin::Invocation; +use serde::Deserialize; + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; + +pub const MAX_DOCUMENT_BYTES: usize = 2 << 20; +const MAX_FILES: usize = 20_000; +const MAX_EDGES: usize = 4096; + +pub struct Snapshot<'a> { + pub path: &'a Path, + pub version: Option, + pub text: &'a str, + pub symbols: &'a [SymbolTag], + pub scope: &'a str, +} + +pub use ide_plugin::indexing::*; + +struct File { + version: Option, + scope: String, + edges: Edges, +} +#[derive(Default)] +struct Graph { + instance: Weak, + files: BTreeMap, + definitions: HashMap>, + aliases: HashMap>, + edges: usize, +} +impl Graph { + fn targets(&self, key: &str) -> Vec<&Location> { + let mut keys = vec![key.to_owned()]; + let mut seen = std::collections::HashSet::new(); + let mut targets = vec![]; + while let Some(key) = keys.pop() { + if seen.len() >= 64 || !seen.insert(key.clone()) { + continue; + } + if let Some(locations) = self.definitions.get(&key) { + targets.extend(locations.iter().take(256usize.saturating_sub(targets.len()))); + continue; + } + if let Some((prefix, suffix)) = key.split_once('#') + && let Some(aliases) = self.aliases.get(&format!("{prefix}#")) + { + for (_, target) in aliases.iter().take(64) { + keys.push(format!("{target}{suffix}")); + } + } + } + targets + } + fn remove(&mut self, path: &Path) { + if let Some(file) = self.files.remove(path) { + self.edges -= file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in file.edges.aliases { + if let Some(entries) = self.aliases.get_mut(&alias.prefix) { + entries.retain(|(p, _)| p != path); + if entries.is_empty() { + self.aliases.remove(&alias.prefix); + } + } + } + for anchor in file.edges.definitions { + if let Some(locations) = self.definitions.get_mut(&anchor.key) { + locations.retain(|l| l.path.0.as_path() != path); + if locations.is_empty() { + self.definitions.remove(&anchor.key); + } + } + } + } + } + fn insert(&mut self, path: PathBuf, file: File) { + self.remove(&path); + self.edges += file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in &file.edges.aliases { + self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone())); + } + for anchor in &file.edges.definitions { + self.definitions.entry(anchor.key.clone()).or_default().push(Location { + path: path.clone().into(), + range: anchor.range, + uri: None, + }); + } + self.files.insert(path, file); + } +} + +#[derive(Default)] +pub(crate) struct NavigationIndex { + projects: HashMap<(PathBuf, Option), HashMap>, + generation: u64, + epochs: HashMap, +} + +fn contains(range: Range, pos: Pos) -> bool { + (range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col) +} +fn valid_range(lines: &[usize], range: Range) -> bool { + let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length); + pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col) +} + +impl PluginHost { + pub(crate) fn clear_plugin_navigation(&self, id: &str) { + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + let roots: Vec<_> = index.projects.keys().cloned().collect(); + for root in roots { + index.epochs.insert(root.0, epoch); + } + for graphs in index.projects.values_mut() { + graphs.remove(id); + } + drop(index); + self.emit(None, super::PluginEvent::DocumentIndexesChanged); + } + + /// Extensions with an active, permissioned navigation provider for this root. + pub fn navigation_extensions(&self, root: &Path) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + self.plugins + .read() + .values() + .filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root)) + .filter_map(|p| declaration(p)) + .flat_map(|d| d.extensions) + .collect() + } + + /// Remove stale contributions before scheduling replacement work. `None` + /// disposes the project, including reverse-index entries, on project close. + pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, None, paths); + } + + pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, Some(client), paths); + } + + fn invalidate_navigation_scope(&self, root: &Path, client: Option>, paths: Option<&[PathBuf]>) { + let matches = |key: &(PathBuf, Option)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c); + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + index.epochs.insert(root.to_owned(), epoch); + if index.epochs.len() > 64 + && let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone()) + { + index.epochs.remove(&oldest); + } + match paths { + None => { + index.projects.retain(|key, _| !matches(key)); + } + Some(paths) => { + for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) { + for graph in graphs.values_mut() { + for path in paths { + graph.remove(path); + } + } + } + } + } + } + + /// Index one bounded snapshot. No directory traversal or parsing happens in + /// navigation_at. Call off the UI thread, in small cancellable batches. + pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> { + let Snapshot { path, version, text, symbols, scope } = snapshot; + if !(self.project_policy.read())(root) { + return Err(IdeError::unavailable("project is not trusted")); + } + if text.len() > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("navigation document is too large")); + } + let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?; + if real != path { + return Err(IdeError::invalid("navigation requires a canonical project path")); + } + let project_key = (root.to_owned(), invocation.client.clone()); + let generation = self.navigation.lock().epochs.get(root).copied(); + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default(); + let plugins: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| { + p.status().is_live() + && p.dev_root.as_deref().is_none_or(|r| r == root) + && p.meta.lock().granted.contains(&Capability::ProjectRead) + && declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension)) + }) + .cloned() + .collect(); + if plugins.is_empty() { + return Ok(()); + } + let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect(); + for plugin in plugins { + let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?; + if activated { + self.changed(); + } + let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned()); + let symbols = symbols + .iter() + .map(|s| ide_plugin::indexing::IndexSymbol { + name: s.name.clone(), + kind: serde_json::to_value(s.kind) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(), + container: s.container.clone(), + range: s.range, + }) + .collect::>(); + let ctx = invocation.clone(); + let started = std::time::Instant::now(); + let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx)); + let edges = self + .finish_provider_call(&plugin, &actor, started, result) + .ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?; + let output_bytes = edges + .definitions + .iter() + .chain(&edges.references) + .chain(&edges.markers) + .map(|a| a.key.len().saturating_add(32)) + .sum::() + .saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::()); + if output_bytes > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("document index output is too large")); + } + if edges.aliases.len() > 256 + || edges.aliases.iter().any(|a| { + !a.prefix.ends_with('#') + || !a.target.ends_with('#') + || a.prefix.len() > 1024 + || a.target.len() > 1024 + }) + || edges.definitions.len() + edges.references.len() > MAX_EDGES + || edges + .definitions + .iter() + .chain(&edges.references) + .any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation anchors")); + } + if edges.markers.len() > MAX_EDGES + || edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation markers")); + } + let mut index = self.navigation.lock(); + if index.epochs.get(root).copied() != generation { + return Err(IdeError::cancelled()); + } + // Keep a bounded number of project graphs, including closed windows + // whose frontend did not get an opportunity to dispose its state. + if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) { + return Err(IdeError::unavailable("navigation project limit reached")); + } + let graph = + index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default(); + if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) { + *graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() }; + } + if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) { + return Err(IdeError::unavailable("navigation file limit reached")); + } + graph.remove(path); + if graph.edges + + edges.definitions.len() + + edges.references.len() + + edges.aliases.len() + + edges.markers.len() + > 200_000 + { + return Err(IdeError::unavailable("navigation association limit reached")); + } + graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges }); + } + Ok(()) + } + + /// Resolves only current live providers. Disable/reload/trap cannot retain + /// navigable stale data, even if a background snapshot finished concurrently. + pub fn navigation_at( + &self, + root: &Path, + path: &Path, + version: u32, + pos: Pos, + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Option { + if !(self.project_policy.read())(root) { + return None; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?; + let mut result: Option = None; + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if file.version != Some(version) { + continue; + } + for reference in &file.edges.references { + if !contains(reference.range, pos) { + continue; + } + let targets = graph.targets(&reference.key); + if targets.is_empty() { + continue; + } + let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] }); + let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + for target in targets.iter().take(256) { + let Some(target_file) = graph.files.get(&target.path.0) else { + continue; + }; + if local && target_file.scope != file.scope { + continue; + } + if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) { + continue; + } + if (target.path.0.as_path() != path || !contains(target.range, pos)) + && !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range) + { + answer.targets.push((*target).clone()); + } + } + } + } + result.filter(|r| !r.targets.is_empty()) + } + /// Gutter markers of visible files. Uses only existing graph entries; never executes WASM. + pub fn navigation_markers( + &self, + root: &Path, + paths: &[PathBuf], + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else { + return vec![]; + }; + let mut result = vec![]; + for path in paths { + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if version_of(path).is_some_and(|v| file.version != Some(v)) { + continue; + } + let mut markers = vec![]; + for anchor in &file.edges.markers { + let candidates = graph.targets(&anchor.key); + let local = + candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + let mut targets = vec![]; + for target in candidates { + let Some(other) = graph.files.get(&target.path.0) else { continue }; + if (local && other.scope != file.scope) + || version_of(&target.path).is_some_and(|v| other.version != Some(v)) + || (target.path.0 == *path && target.range == anchor.range) + { + continue; + } + if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) { + targets.push(target.clone()); + } + } + if !targets.is_empty() { + markers.push(NavigationResult { range: anchor.range, targets }); + } + } + if !markers.is_empty() { + result.push(FileMarkers { path: path.clone(), version: file.version, markers }); + } + } + } + result + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + extensions: Vec, +} +fn declaration(p: &super::LoadedPlugin) -> Option { + p.manifest() + .declarations("documentIndexers") + .into_iter() + .next() + .and_then(|d| decode("documentIndexers", d.clone()).ok()) +} +pub struct IndexersPoint; +impl ExtensionPoint for IndexersPoint { + fn id(&self) -> &'static str { + "documentIndexers" + } + fn placement(&self) -> Placement { + Placement::Workspace + } + fn prepare(&self, pkg: &Package, value: toml::Value) -> Result { + let d: Decl = decode(self.id(), value)?; + if pkg.manifest.wasm.is_none() + || d.extensions.is_empty() + || d.extensions.len() > 32 + || d.extensions + .iter() + .any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric())) + { + return Err("documentIndexers requires wasm and 1-32 file extensions".into()); + } + if pkg.manifest.declarations(self.id()).len() > 1 { + return Err("declare one document indexer per plugin".into()); + } + Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead)) + } +} diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index bdf23c660..940ac19c2 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -56,6 +56,7 @@ pub fn register_builtin_points( registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?; registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?; registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?; + registry.register(Arc::new(super::navigation::IndexersPoint))?; registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?; registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?; use super::ui_points as p; diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index b37c12e15..7b8d15b82 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -37,6 +37,7 @@ pub enum ProviderKind { CodeActions, Definition, LineMarkers, + SemanticTokens, } #[derive(Clone, Debug, Deserialize)] @@ -112,6 +113,9 @@ impl ExtensionPoint for ProvidersPoint { if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 { return Err("contributes.providers: give `kinds` and 1–64 `languages`".into()); } + if d.kinds.contains(&ProviderKind::SemanticTokens) { + pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?; + } for l in &d.languages { if l != "*" && (l.is_empty() @@ -161,7 +165,7 @@ use super::{LoadedPlugin, PluginHost, PluginStatus}; use ide_core::CancellationToken; use ide_plugin::{ ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, - ProviderDocument, Runtime, + ProviderDocument, Runtime, SemanticToken, }; use std::sync::Weak; use std::time::{Duration, Instant}; @@ -180,10 +184,14 @@ pub fn set_editor_providers(host: Option<&Arc>) { /// The plugin host whose providers take part, when any declares `kind`. pub(crate) fn editor_providers(kind: ProviderKind) -> Option> { - let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?; + let host = editor_plugin_host()?; host.providers.any(kind).then_some(host) } +pub(crate) fn editor_plugin_host() -> Option> { + EDITOR_PROVIDERS.read().as_ref()?.upgrade() +} + impl PluginHost { /// Usable plugins declaring `kind` for `language`. fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { @@ -327,6 +335,16 @@ impl PluginHost { .collect() } + pub fn provide_semantic_tokens(&self, doc: &ProviderDocument) -> Vec { + let d = Arc::new(doc.clone()); + self.edit_calls(ProviderKind::SemanticTokens, &doc.language, move |rt, timeout| { + rt.provide_semantic_tokens(&d, timeout) + }) + .into_iter() + .flat_map(|(_, tokens)| tokens) + .collect() + } + /// Ghost text from the first plugin that has some. Not bound by the /// edit-path soft deadline (it may need the network) but by `cancel`: /// the editor cancels a request as soon as the user types on. diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 7b845a84d..6815ca382 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -13,8 +13,9 @@ //! //! The commands a window needs at run time (`plugin_run_command`, //! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, -//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`, -//! `plugin_view_render`, `plugin_view_event`) +//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`, +//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`, +//! `plugin_service_log`) //! carry the desktop commands' names and arguments, so a remote window's UI //! routes them here unchanged. Plugin results come back as `plugin` //! notifications ([`PluginEventMessage`]); secrets stay in the desktop's @@ -107,6 +108,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { event, PluginEvent::Notification { .. } | PluginEvent::StatusItemChanged { .. } + | PluginEvent::ContextChanged { .. } | PluginEvent::TaskStarted { .. } | PluginEvent::TaskProgress { .. } | PluginEvent::TaskEnded { .. } @@ -117,6 +119,9 @@ pub fn forwarded(event: &PluginEvent) -> bool { | PluginEvent::OpenLocation { .. } | PluginEvent::CopyToClipboard { .. } | PluginEvent::OpenUrl { .. } + | PluginEvent::PromptRequested { .. } + | PluginEvent::PromptClosed { .. } + | PluginEvent::RevealView { .. } ) } @@ -124,7 +129,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { impl PluginHost { fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult> { - let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&plugin.pkg).map_err(IdeError::invalid)?; Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect()) } @@ -367,10 +372,12 @@ impl RemotePlugins { let command_id: String = c.arg("commandId")?; let arg: Option = c.arg("arg")?; let editor: Option = c.arg("editor")?; + let paths: Option> = c.arg("paths")?; let invocation = Invocation { client: Some(c.window.clone()), workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), + selected_paths: paths.unwrap_or_default(), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } @@ -400,11 +407,21 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_context_keys", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.context_keys(Some(&c.window))) } + }); + let h = host.clone(); d.add("plugin_cancel_task", move |c| { let h = h.clone(); async move { Ok(alive(&h)?.cancel_task(&c.arg::("taskId")?)) } }); let h = host.clone(); + d.add("plugin_answer_prompt", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.answer_prompt(&c.arg::("promptId")?, c.arg("answer")?)) } + }); + let h = host.clone(); d.add("plugin_set_active_file", move |c| { let h = h.clone(); async move { @@ -448,6 +465,78 @@ impl RemotePlugins { .await } }); + let h = host.clone(); + d.add("plugin_service_detail", move |c| { + let h = h.clone(); + async move { + super::views::service_detail( + &c.host, + alive(&h)?, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("key")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_service_log", move |c| { + let h = h.clone(); + async move { + super::views::service_log( + &c.host, + alive(&h)?, + &c.window, + c.arg("pluginId")?, + c.arg("view")?, + c.arg("key")?, + c.arg("tab")?, + c.arg("on")?, + c.arg("request")?, + c.sender("onLine")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_files", move |c| { + let h = h.clone(); + async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await } + }); + let h = host.clone(); + d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move { + super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?) + } }); + let h = host.clone(); + d.add("plugin_navigation_index", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_index( + &c.host, + alive(&h)?, + &c.window, + c.arg("paths")?, + c.arg("request")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_goto", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_markers", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await + } + }); d.add("plugin_line_markers", |c| async move { crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/plugins/semantic_tokens.rs b/crates/ide-host/src/plugins/semantic_tokens.rs new file mode 100644 index 000000000..d0ca4c004 --- /dev/null +++ b/crates/ide-host/src/plugins/semantic_tokens.rs @@ -0,0 +1,131 @@ +use ide_lsp::types::SemanticTokens; +use ide_plugin::SemanticToken; +use std::collections::HashMap; + +pub(crate) fn merge(server: Option, plugin: Vec) -> SemanticTokens { + let mut out = server.unwrap_or_else(|| SemanticTokens { + data: Vec::new(), + token_types: Vec::new(), + token_modifiers: Vec::new(), + }); + let mut added: Vec<[u32; 5]> = Vec::with_capacity(plugin.len()); + let mut lines: HashMap> = HashMap::new(); + for token in plugin { + let (start, end) = (token.range.start, token.range.end); + if start.line != end.line || end.col <= start.col { + continue; + } + let kind = camel_case(&token.kind); + let kind = match out.token_types.iter().position(|t| *t == kind) { + Some(i) => i, + None => { + out.token_types.push(kind); + out.token_types.len() - 1 + } + }; + let mut bits = 0u32; + for modifier in &token.modifiers { + let modifier = camel_case(modifier); + let i = match out.token_modifiers.iter().position(|m| *m == modifier) { + Some(i) => i, + None if out.token_modifiers.len() < 32 => { + out.token_modifiers.push(modifier); + out.token_modifiers.len() - 1 + } + None => continue, + }; + bits |= 1 << i; + } + lines.entry(start.line).or_default().push((start.col, end.col)); + added.push([start.line, start.col, end.col - start.col, kind as u32, bits]); + } + let covered = |t: &[u32; 5]| { + lines.get(&t[0]).is_some_and(|ranges| ranges.iter().any(|&(from, to)| t[1] < to && from < t[1] + t[2])) + }; + let mut data: Vec<[u32; 5]> = out.data.as_chunks::<5>().0.iter().filter(|t| !covered(t)).copied().collect(); + data.extend(added); + data.sort_by_key(|t| (t[0], t[1])); + out.data = data.into_iter().flatten().collect(); + out +} + +fn camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper = false; + for c in name.chars() { + if c == '-' { + upper = true; + } else if upper { + out.extend(c.to_uppercase()); + upper = false; + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::{Pos, Range}; + + fn token(line: u32, from: u32, to: u32, kind: &str, modifiers: &[&str]) -> SemanticToken { + SemanticToken { + range: Range::new(Pos::new(line, from), Pos::new(line, to)), + kind: kind.into(), + modifiers: modifiers.iter().map(|m| (*m).into()).collect(), + } + } + + fn server() -> SemanticTokens { + SemanticTokens { + data: vec![0, 0, 6, 0, 0, 1, 4, 5, 1, 1, 1, 12, 3, 1, 0], + token_types: vec!["variable".into(), "property".into()], + token_modifiers: vec!["readonly".into()], + } + } + + #[test] + fn plugin_tokens_replace_the_server_tokens_they_overlap() { + let merged = merge(Some(server()), vec![token(1, 6, 10, "method", &[])]); + assert_eq!(merged.token_types, ["variable", "property", "method"]); + assert_eq!(merged.data, [0, 0, 6, 0, 0, 1, 6, 4, 2, 0, 1, 12, 3, 1, 0]); + } + + #[test] + fn kinds_and_modifiers_use_the_lsp_names() { + let merged = merge(None, vec![token(2, 0, 3, "type-parameter", &["default-library", "readonly"])]); + assert_eq!(merged.token_types, ["typeParameter"]); + assert_eq!(merged.token_modifiers, ["defaultLibrary", "readonly"]); + assert_eq!(merged.data, [2, 0, 3, 0, 0b11]); + } + + #[test] + fn known_names_reuse_the_server_legend() { + let merged = merge(Some(server()), vec![token(3, 1, 2, "property", &["readonly"])]); + assert_eq!(merged.token_types.len(), 2); + assert_eq!(merged.token_modifiers.len(), 1); + assert_eq!(merged.data[15..], [3, 1, 1, 1, 1]); + } + + #[test] + fn tokens_spanning_lines_or_empty_are_ignored() { + let multi = SemanticToken { + range: Range::new(Pos::new(0, 4), Pos::new(1, 2)), + kind: "method".into(), + modifiers: Vec::new(), + }; + let merged = merge(Some(server()), vec![multi, token(0, 3, 3, "method", &[])]); + assert_eq!(merged.data, server().data); + } + + #[test] + fn modifiers_past_thirty_two_are_dropped() { + let names: Vec = (0..40).map(|i| format!("m{i}")).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let merged = merge(None, vec![token(0, 0, 1, "type", &refs)]); + assert_eq!(merged.token_modifiers.len(), 32); + assert_eq!(merged.data[4], u32::MAX); + } +} diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 53c737e54..5090998a8 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -1,11 +1,22 @@ //! UI-facing plugin types and the event stream. use super::theme::ThemeDef; +use ide_extension::package::PluginIcon; use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution}; -pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent}; +pub use ide_plugin::{ + ActionPerformed, ActionSource, EventKind, NotificationLevel, PickItem, Prompt, StatusItem, WorkbenchEvent, +}; use serde::{Deserialize, Serialize}; use specta::Type; +/// A line a view's `process` body printed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginOutputLine { + pub stderr: bool, + pub text: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum PluginSource { @@ -50,6 +61,7 @@ pub struct PluginInfo { pub authors: Vec, pub homepage: Option, pub change_notes: Option, + pub icon: Option, pub source: PluginSource, /// Package directory. pub path: ide_core::IpcPath, @@ -113,6 +125,7 @@ pub struct CatalogPlugin { pub authors: Vec, pub homepage: Option, pub change_notes: Option, + pub icon: Option, /// Package directory; install with `install_from_dir(path)`. pub path: ide_core::IpcPath, pub has_code: bool, @@ -153,6 +166,8 @@ pub struct PluginUpdate { pub enum PluginEvent { /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. CommandsChanged, + /// Cached document associations were disposed (reload, disable or failure). + DocumentIndexesChanged, /// The marketplace offers newer versions of installed plugins (announced /// once per set of versions). UpdatesAvailable { updates: Vec }, @@ -161,6 +176,10 @@ pub enum PluginEvent { /// A plugin's status bar item was set (`item`) or removed. #[serde(rename_all = "camelCase")] StatusItemChanged { plugin_id: String, id: String, item: Option }, + /// A plugin's context key was set (`value`: JSON text) or removed, in the + /// emitted window (every window when the client is `None`). + #[serde(rename_all = "camelCase")] + ContextChanged { plugin_id: String, key: String, value: Option }, /// A plugin began a task: show it with the background tasks. #[serde(rename_all = "camelCase")] TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool }, @@ -203,6 +222,16 @@ pub enum PluginEvent { /// Open `url` (`http(s)://`) in the system browser. #[serde(rename_all = "camelCase")] OpenUrl { plugin_id: String, url: String }, + /// A command waits for the user's answer: show `prompt`, then report it with + /// `plugin_answer_prompt(prompt_id, …)`. + #[serde(rename_all = "camelCase")] + PromptRequested { plugin_id: String, prompt_id: String, prompt: Prompt }, + /// The call that asked gave up (cancelled, or its plugin stopped): close the prompt. + #[serde(rename_all = "camelCase")] + PromptClosed { prompt_id: String }, + /// Bring the tool window of the plugin's view `view` to the front. + #[serde(rename_all = "camelCase")] + RevealView { plugin_id: String, view: String }, } /// Delivers events to the UI. `client` is the window that invoked the plugin @@ -249,6 +278,15 @@ pub struct PluginStatusItem { pub item: StatusItem, } +/// A plugin's context key for `when` clauses (`value` is JSON text). +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginContextKey { + pub plugin_id: String, + pub key: String, + pub value: String, +} + /// A gutter marker from a plugin's `line-markers` provider. #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 135ddd7d7..b5bd0047e 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -5,9 +5,10 @@ //! //! ```toml //! [[contributes.menus]] -//! menu = "editor.context" # projectView.context, editorTab.context, tools +//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context //! command = "check" //! when = "editorLang == java" +//! # view = "" # required for (only allowed on) viewItem.context //! //! [[contributes.keymaps]] //! command = "check" @@ -25,7 +26,7 @@ //! id = "jobs" //! title = "Jobs" //! icon = "server" -//! location = "right" # left, right, bottom +//! location = "right" # left, right, bottom, services (nodes of the Services tool window) //! //! [[contributes.customEditors]] //! id = "table" @@ -54,6 +55,9 @@ use serde_json::Value; use std::collections::BTreeSet; use std::path::PathBuf; +/// A view whose tree shows in the Services tool window; the plugin's +/// `service-provider` renders the selected node's pane. +pub const SERVICES_LOCATION: &str = "services"; pub const MENUS: &str = "menus"; pub const KEYMAPS: &str = "keymaps"; pub const STATUS_BAR: &str = "statusBar"; @@ -63,7 +67,8 @@ pub const CUSTOM_EDITORS: &str = "customEditors"; pub const FILE_ICONS: &str = "fileIcons"; pub const FILE_TEMPLATES: &str = "fileTemplates"; -const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"]; +const VIEW_ITEM_MENU: &str = "viewItem.context"; +const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU]; const MAX_TITLE: usize = 256; const MAX_SETTINGS: usize = 200; const MAX_PATTERNS: usize = 16; @@ -77,6 +82,10 @@ fn commands(pkg: &Package) -> BTreeSet { .collect() } +fn declared_view(pkg: &Package, id: &str) -> Option { + pkg.manifest.declarations(VIEWS).into_iter().find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id)) +} + fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> { if commands(pkg).contains(command) { Ok(()) @@ -103,6 +112,8 @@ struct MenuDecl { command: String, #[serde(default, skip_serializing_if = "Option::is_none")] when: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + view: Option, } pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { @@ -114,7 +125,24 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { if let Some(w) = &d.when { validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?; } - Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?)) + let key = match (d.menu == VIEW_ITEM_MENU, &d.view) { + (true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")), + (true, Some(v)) => { + pkg.manifest.needs_api(&format!("contributes.menus: {VIEW_ITEM_MENU}"), 2)?; + let Some(view) = declared_view(pkg, v) else { + return Err(format!("contributes.menus: view {v:?} is not declared under [[contributes.views]]")); + }; + if view.get("web").is_some() { + return Err(format!( + "contributes.menus: view {v:?} is a web view (viewItem.context needs a tree or list the plugin renders)" + )); + } + format!("{VIEW_ITEM_MENU}/{v}/{}", d.command) + } + (false, Some(_)) => return Err(format!("contributes.menus: `view` is only for {VIEW_ITEM_MENU}")), + (false, None) => format!("{}/{}", d.menu, d.command), + }; + Ok(UiDecl::new(key, d.command.clone(), json(&d)?)) } #[derive(Debug, Deserialize, Serialize)] @@ -370,8 +398,14 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { validate_item_id(&d.id, "view id")?; title(VIEWS, &d.title)?; let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?; - if !["left", "right", "bottom"].contains(&d.location.as_str()) { - return Err(format!("view {:?}: `location` is left, right or bottom", d.id)); + if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) { + return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id)); + } + if d.location == SERVICES_LOCATION { + pkg.manifest.needs_api(&format!("view {:?}: `location = \"services\"`", d.id), 3)?; + if d.web.is_some() { + return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id)); + } } if d.icon.as_ref().is_some_and(|i| i.len() > 64) { return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); @@ -380,7 +414,7 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { out.files = files; if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) { let path = std::path::PathBuf::from(icon); - let svg = super::icons::read_icon(pkg, &path)?; + let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?; out.assets.push((view_icon_asset(&d.id), svg)); out.files.push(path); } @@ -473,7 +507,7 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -62,6 +68,47 @@ pub async fn event( blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await } +/// The Services pane of node `key` of a plugin's services view. +pub async fn service_detail( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + key: String, +) -> IdeResult> { + let invocation = view_invocation(host, window, None, None)?; + blocking(move || plugins.service_detail(&plugin_id, &view, &key, invocation)).await +} + +/// Runs the program of the log tab `tab` of node `key`'s pane in the window's +/// project root, streaming its lines until it exits or request `request` is +/// cancelled; returns its exit code. +#[allow(clippy::too_many_arguments)] +pub async fn service_log( + host: &crate::Host, + plugins: Arc, + window: &str, + plugin_id: String, + view: String, + key: String, + tab: String, + on: Vec, + request: ide_core::RequestId, + on_line: crate::ops::Channel, +) -> IdeResult> { + let invocation = view_invocation(host, window, None, None)?; + let cancel = host.begin_project_read_request(window, request); + let result = blocking(move || { + plugins.stream_service_log(&plugin_id, &view, &key, &tab, &on, invocation, &cancel, &|line| { + let _ = on_line.send(line); + }) + }) + .await; + host.end_request(request); + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0bdd984c9..2ca25b503 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -55,6 +55,7 @@ pub struct Project { /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, pub(crate) kotlin_bridge: Option>, + pub(crate) phpstan: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -292,12 +293,21 @@ impl Host { trusted.clone(), ); documents.add_observer(kotlin_bridge.clone()); + let phpstan = crate::phpstan::Phpstan::new( + root.clone(), + self.config_dir.clone(), + settings.clone(), + sink.clone(), + trusted.clone(), + ); + documents.add_observer(phpstan.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { let kotlin_bridge = kotlin_bridge.clone(); + let phpstan = phpstan.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); @@ -321,6 +331,7 @@ impl Host { let (ignored, ignored_gone) = ignored_changes(batch); index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); + phpstan.files_removed(batch.removed.iter()); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); if composer_changed(&root, changed.iter().chain(&batch.removed)) { @@ -440,6 +451,7 @@ impl Host { &history, &lsp, Some(&kotlin_bridge), + Some(&phpstan), )?; Some((r.clone(), w)) }) @@ -486,6 +498,7 @@ impl Host { agents, notebooks, kotlin_bridge: Some(kotlin_bridge), + phpstan: Some(phpstan), watcher, content_watchers: Mutex::new(content_watchers), lsp_watchers: Mutex::new(Vec::new()), @@ -513,9 +526,13 @@ impl Host { })); } self.insert_project(window, project.clone()); + project.lsp.prewarm_java_configuration(); if let Some(bridge) = &project.kotlin_bridge { bridge.start(&self.runtime); } + if let Some(phpstan) = &project.phpstan { + phpstan.start(&self.runtime); + } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } @@ -626,6 +643,7 @@ impl Host { agents, notebooks, kotlin_bridge: None, + phpstan: None, watcher, content_watchers: Mutex::new(Vec::new()), lsp_watchers: Mutex::new(Vec::new()), @@ -1002,6 +1020,7 @@ impl Project { &self.history, &self.lsp, self.kotlin_bridge.as_ref(), + self.phpstan.as_ref(), ) { watchers.push((r, w)); } @@ -1137,8 +1156,9 @@ fn content_root_watcher( history: &Arc, lsp: &Arc, kotlin_bridge: Option<&Arc>, + phpstan: Option<&Arc>, ) -> Option { - let kotlin_bridge = kotlin_bridge.cloned(); + let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned()); let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone()); let root = dir.to_path_buf(); Watcher::start( @@ -1156,6 +1176,9 @@ fn content_root_watcher( if let Some(bridge) = &kotlin_bridge { bridge.files_changed(changed.iter().chain(&batch.removed)); } + if let Some(phpstan) = &phpstan { + phpstan.files_removed(batch.removed.iter()); + } documents.on_disk_changed(&[&changed[..], &ignored].concat()); record_external_changes(&history, &changed, &batch.removed); }), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index ca3194b50..759eddb8f 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -74,6 +74,21 @@ impl TrustStore { Ok(()) } + pub fn add_location(&self, path: &Path) -> IdeResult<()> { + let root = dunce::canonicalize(path)?; + if !root.is_dir() { + return Err(IdeError::invalid("trusted location must be an existing directory")); + } + let mut state = self.state.lock(); + let mut next = state.clone()?; + if !next.locations.contains(&root) { + next.locations.push(root); + persist(&self.file, &next)?; + *state = Ok(next); + } + Ok(()) + } + pub fn remove_location(&self, path: &Path) -> IdeResult<()> { let mut state = self.state.lock(); let mut next = state.clone()?; @@ -109,6 +124,9 @@ impl crate::Host { if let Some(bridge) = &project.kotlin_bridge { bridge.schedule(); } + if let Some(phpstan) = &project.phpstan { + phpstan.interrupt(); + } if !trusted { project.lsp.set_execution_allowed(false); } @@ -118,6 +136,7 @@ impl crate::Host { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } project.lsp.set_execution_allowed(true); + project.lsp.prewarm_java_configuration(); } else { self.environment.project_closed(); project.agents.shutdown(); @@ -265,6 +284,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "agent_commands" | "agent_unqueue" | "agent_cancel" + | "agent_stop_background" | "agent_archive" | "agent_unarchive" | "agent_registry_list" @@ -335,7 +355,9 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_compare_scope" | "vcs_compare_paths" | "vcs_diff_revisions" + | "vcs_diff_numstat" | "vcs_merge_base" + | "vcs_has_hook" | "vcs_shelves" | "vcs_shelf_patch" | "vcs_changelists_load" diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index 38002f363..ff23881f5 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -1,7 +1,7 @@ //! Versioned installs with automatic rollback, signed packages from //! a static marketplace index, and revocation. -use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation}; +use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PluginIcon, Publisher, PublisherKey, Revocation}; use ide_host::plugins::*; use ide_plugin::{Invocation, Limits}; use parking_lot::Mutex; @@ -58,7 +58,7 @@ fn fixture() -> Fixture { } fn inv() -> Invocation { - Invocation { client: Some("main".into()), workspace_root: None, editor: None } + Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() } } fn misbehave() -> PathBuf { @@ -169,6 +169,7 @@ fn signed( capabilities: vec![], engine: "^1.0".into(), categories: vec!["tools".into()], + icon: None, }; (out, entry) } @@ -509,3 +510,25 @@ fn official_plugins_need_the_key_this_ide_was_built_with() { let dir = f.host.download_market_plugin("openidea.hello").unwrap(); assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER)); } + +#[test] +fn marketplace_icons_are_sanitised_or_dropped() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let key = PublisherKey::from_seed([3; 32]); + let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host); + let icon = |light: &str| Some(PluginIcon { light: light.into(), dark: None }); + let good = + IndexEntry { icon: icon(r#""#), ..entry.clone() }; + let hostile = IndexEntry { id: "acme.hostile".into(), icon: icon(r#""#), ..entry }; + let padded = format!("", " ".repeat(20 << 10)); + let padded = IndexEntry { id: "acme.padded".into(), icon: icon(&padded), ..hostile.clone() }; + f.host.set_market_url(Some(write_index(&market, &key, vec![good, hostile, padded], vec![], false))); + + let listed = f.host.refresh_market().unwrap(); + let by_id = |id: &str| listed.iter().find(|p| p.entry.id == id).unwrap_or_else(|| panic!("{id} not listed")); + assert!(by_id("acme.hello").entry.icon.as_ref().unwrap().light.contains(" Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn config(target: &str, root: &Path) -> RunConfig { + RunConfig { + id: "app".into(), + name: "app".into(), + kind: "python".into(), + command: root.join(".venv/bin/python").to_string_lossy().into_owned(), + args: vec![root.join("main.py").to_string_lossy().into_owned()], + cwd: None, + env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() }, + } +} + +async fn output_of(host: &Host, config: RunConfig) -> String { + let out = Arc::new(Mutex::new(String::new())); + let sink = out.clone(); + ops::run::start( + host, + "main", + config, + Arc::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + sink.lock().push_str(&text) + } + }), + ) + .await + .unwrap(); + for _ in 0..200 { + if out.lock().contains("Process finished") { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + out.lock().clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docker_targets_run_through_the_docker_cli() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let docker = base.join("docker"); + std::fs::write( + &docker, + "#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\ + for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n", + ) + .unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + // SAFETY: the only test of this binary, before any thread looks docker up. + unsafe { + std::env::set_var("OPENIDEA_DOCKER", &docker); + std::env::remove_var("DOCKER_HOST"); + } + + let root = base.join("proj"); + std::fs::create_dir_all(&root).unwrap(); + let host = host(&base.join("app")); + std::fs::create_dir_all(&host.data_dir).unwrap(); + std::fs::write( + host.data_dir.join("docker.json"), + r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#, + ) + .unwrap(); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let targets = serde_json::json!([ + { "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" }, + { "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" }, + ]); + ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "runTargets", + &targets.to_string(), + ) + .unwrap(); + + let out = output_of(&host, config("py", &root)).await; + let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect(); + assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}"); + assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}"); + assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}"); + assert!(out.contains("DATA=/workspaces/proj/data"), "{out}"); + assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line"); + + let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err(); + assert!(err.message.contains("another machine"), "{}", err.message); +} diff --git a/crates/ide-host/tests/docker_run_targets_real.rs b/crates/ide-host/tests/docker_run_targets_real.rs new file mode 100644 index 000000000..16b7a1c66 --- /dev/null +++ b/crates/ide-host/tests/docker_run_targets_real.rs @@ -0,0 +1,159 @@ +//! Docker and Compose run targets against a real daemon. Ignored by default: +//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount +//! (on this machine, or at the same path in a VM such as OrbStack's), and +//! `docker` with the Compose plugin must be on `PATH`. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use ide_run::{OutputChunk, RunConfig}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +fn host(base: &Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig { + RunConfig { + id: id.into(), + name: id.into(), + kind: "python".into(), + command: root.join(".venv/bin/python").to_string_lossy().into_owned(), + args: args.iter().map(|a| a.to_string()).collect(), + cwd: None, + env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() }, + } +} + +type Output = Arc>; + +async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) { + let out: Output = Arc::default(); + let sink = out.clone(); + let info = ops::run::start( + host, + "main", + config, + Arc::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + sink.lock().push_str(&text) + } + }), + ) + .await + .unwrap(); + (info.id, out) +} + +async fn finished(out: &Output, secs: u64) -> String { + for _ in 0..secs * 10 { + if out.lock().contains("Process finished") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + out.lock().clone() +} + +fn containers(label: &str) -> String { + let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"] +async fn runs_in_docker_and_compose_containers() { + let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR")); + let root = dunce::canonicalize(root).unwrap(); + let name = root.file_name().unwrap().to_string_lossy().into_owned(); + std::fs::write( + root.join("main.py"), + "import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\ + open('out.txt', 'w').write('ok')\n", + ) + .unwrap(); + std::fs::write( + root.join("compose.yaml"), + "services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n", + ) + .unwrap(); + let state = tempfile::tempdir().unwrap(); + let host = host(state.path()); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let targets = serde_json::json!([ + { "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" }, + { "kind": "compose", "id": "app", "name": "Compose", "service": "app" }, + ]); + ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "runTargets", + &targets.to_string(), + ) + .unwrap(); + + let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await; + let out = finished(&out, 120).await; + assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}"); + assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}"); + let written = root.join("out.txt"); + assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok"); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap()); + assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner"); + } + std::fs::remove_file(&written).unwrap(); + assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends"); + + let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await; + let out = finished(&out, 120).await; + assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}"); + std::fs::remove_file(&written).unwrap(); + + // Stop: `docker stop` ends the container; `--rm` removes it. + let (id, out) = + start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"])) + .await; + for _ in 0..600 { + if out.lock().contains("up\n") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock()); + ops::run::stop(&host, "main", id).unwrap(); + let out = finished(&out, 30).await; + assert!(out.contains(" stop openidea-sleep-"), "{out}"); + for _ in 0..100 { + if containers("label=openidea.run=sleep").is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(containers("label=openidea.run=sleep"), "", "{out}"); + Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap(); +} diff --git a/crates/ide-host/tests/language_smoke.rs b/crates/ide-host/tests/language_smoke.rs index 8e32b39ee..ec9701397 100644 --- a/crates/ide-host/tests/language_smoke.rs +++ b/crates/ide-host/tests/language_smoke.rs @@ -303,7 +303,18 @@ impl Smoke { 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; + let found = ops::lsp::lsp_references( + &self.host, + WINDOW, + RequestId(1), + doc, + pos, + true, + true, + false, + ops::Channel::new(|_| {}), + ) + .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)))), diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 5fa245536..7861b684c 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -160,7 +160,7 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() { assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok()); let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err(); assert!(error.message.contains("Restricted Mode")); - assert!(ops::sdk::sdk_python_effective(&host, "main").is_err()); + assert!(ops::sdk::sdk_python_effective(&host, "main", None).is_err()); assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err()); let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err(); assert!(error.message.contains("Restricted Mode")); diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs new file mode 100644 index 000000000..045531a54 --- /dev/null +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -0,0 +1,241 @@ +//! Generic document-indexer contract through a minimal real WASM fixture. +use ide_core::{Pos, Range, SymbolKind}; +use ide_host::plugins::navigation::Snapshot; +use ide_host::plugins::{Consent, PluginHost}; +use ide_language::SymbolTag; +use ide_plugin::{Invocation, Limits}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +struct Fixture { + _tmp: tempfile::TempDir, + root: PathBuf, + host: PluginHost, +} +impl Fixture { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + host.set_project_policy(Arc::new(|_| true)); + host.install_from_dir( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"), + &Consent::all(), + ) + .unwrap(); + Self { _tmp: tmp, root, host } + } + fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option, scope: &str) -> PathBuf { + let path = self.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + self.host + .index_navigation( + &self.root, + Snapshot { path: &path, version, text, symbols, scope }, + Invocation { workspace_root: Some(self.root.clone()), ..Default::default() }, + ) + .unwrap(); + path + } +} +fn method() -> SymbolTag { + SymbolTag { + name: "item".into(), + kind: SymbolKind::Method, + container: None, + range: Range::new(Pos::new(0, 4), Pos::new(0, 8)), + } +} +const SOURCE: &str = "def source:item\nref mapping:item\n"; +const MAPPING: &str = "def mapping:item\nref source:item\n"; +fn reference_pos() -> Pos { + Pos::new(1, 5) +} + +#[test] +fn bidirectional_navigation_versions_and_incremental_deletion() { + let f = Fixture::new(); + let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app"); + let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, source); + assert_eq!(result.range.end.col - result.range.start.col, 11); + let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(reverse.targets[0].path.0, mapping); + assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none()); + f.host.invalidate_navigation(&f.root, Some(&[source])); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn module_candidates_reload_disable_and_project_isolation() { + let f = Fixture::new(); + let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a"); + f.index("b/source.idx", SOURCE, &[method()], Some(1), "b"); + let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets.len(), 1); + assert_eq!(result.targets[0].path.0, first); + let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c"); + assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2); + assert!( + f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none() + ); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.set_enabled("test.indexer", true).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.reload("test.indexer").unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn warmed_clicks_never_read_or_parse_files() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + // Files become unreadable; the watcher invalidates separately. Resolution + // must remain purely in-memory, with no disk reads or component execution. + std::fs::remove_file(&source).unwrap(); + std::fs::remove_file(&mapping).unwrap(); + let calls = f.host.info_of("test.indexer").unwrap().stats.calls; + for _ in 0..10_000 { + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(), + 1 + ); + } + assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm"); + f.host.invalidate_navigation(&f.root, None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn permissions_and_paths_are_checked_before_indexing() { + let f = Fixture::new(); + let external = tempfile::NamedTempFile::new().unwrap(); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); + f.host.set_project_policy(Arc::new(|_| false)); + assert!(f.host.navigation_extensions(&f.root).is_empty()); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); +} + +#[test] +fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() { + let f = Fixture::new(); + let symbol = method(); + let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app"); + f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app"); + let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, definition); + assert_eq!(result.targets[0].range, method().range); + f.index("unicode.idx", "def 😀\n", &[], Some(1), "app"); + let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap(); + assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6))); +} + +#[test] +fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + for client in ["one", "two"] { + let mut symbol = method(); + if client == "two" { + symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9)); + } + for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] { + f.host + .index_navigation( + &f.root, + Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" }, + Invocation { + client: Some(client.into()), + workspace_root: Some(f.root.clone()), + editor: None, + selected_paths: Vec::new(), + }, + ) + .unwrap(); + } + } + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0] + .range + .start + .col, + 4 + ); + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0] + .range + .start + .col, + 5 + ); + f.host.invalidate_client_navigation(&f.root, Some("one"), None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some()); +} + +#[test] +fn markers_and_disable_follow_graph_state() { + let f = Fixture::new(); + let tags = vec![]; + let source = f.index("source.idx", SOURCE, &tags, Some(1), "app"); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app"); + let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None); + let source_markers = entries.iter().find(|e| e.path == source).unwrap(); + assert_eq!(source_markers.markers.len(), 1); + assert_eq!(source_markers.markers[0].targets[0].path.0, mapping); + let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap(); + assert_eq!(mapping_markers.markers.len(), 1); + assert_eq!(mapping_markers.markers[0].targets[0].path.0, source); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty()); + f.host.invalidate_navigation(&f.root, Some(&[mapping])); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty()); +} + +#[test] +fn invalid_ranges_and_oversized_documents_never_publish() { + let f = Fixture::new(); + let path = f.root.join("invalid.idx"); + std::fs::write(&path, "invalid key\n").unwrap(); + for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] { + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" }, + Invocation { workspace_root: Some(f.root.clone()), ..Default::default() }, + ) + .is_err() + ); + } + assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty()); +} diff --git a/crates/ide-host/tests/plugin_semantic_tokens.rs b/crates/ide-host/tests/plugin_semantic_tokens.rs new file mode 100644 index 000000000..4b6872991 --- /dev/null +++ b/crates/ide-host/tests/plugin_semantic_tokens.rs @@ -0,0 +1,56 @@ +use ide_host::plugins::{Consent, PluginHost}; +use ide_plugin::{Limits, ProviderDocument}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/semantic-tokens") +} + +fn host(dir: &Path) -> PluginHost { + let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }; + PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap() +} + +fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument { + ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() } +} + +const ORDER: &str = + "user;\n$order->total;\n$order->user();\n"; + +#[test] +fn relation_accesses_and_columns_get_their_own_kinds() { + let tmp = tempfile::tempdir().unwrap(); + let host = host(tmp.path()); + host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap(); + let tokens = host.provide_semantic_tokens(&doc(tmp.path(), "php", ORDER)); + let found: Vec<_> = tokens + .iter() + .map(|t| (t.range.start.line, t.range.start.col, t.range.end.line, t.range.end.col, t.kind.as_str())) + .collect(); + assert_eq!(found, [(5, 8, 5, 12, "method"), (6, 8, 6, 13, "property")]); + assert!(tokens.iter().all(|t| t.modifiers.is_empty())); +} + +#[test] +fn only_declared_languages_are_asked() { + let tmp = tempfile::tempdir().unwrap(); + let host = host(tmp.path()); + host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap(); + assert!(host.provide_semantic_tokens(&doc(tmp.path(), "javascript", "a->b")).is_empty()); +} + +#[test] +fn the_kind_needs_plugin_api_1_3() { + let tmp = tempfile::tempdir().unwrap(); + let host = host(tmp.path()); + let pkg = tmp.path().join("old"); + std::fs::create_dir_all(pkg.join("dist")).unwrap(); + std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap(); + let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap(); + std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap(); + let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message); +} diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index ee5db393a..a6cf5b29c 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -231,7 +231,12 @@ fn fixture(limits: Limits) -> Fixture { impl Fixture { fn inv(&self) -> Invocation { - Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None } + Invocation { + client: Some("main".into()), + workspace_root: Some(self.project.clone()), + editor: None, + selected_paths: Vec::new(), + } } fn install(&self, dir: &Path) -> PluginInfo { @@ -357,7 +362,7 @@ fn read_file_is_confined_to_the_project() { assert!(err.contains("outside the project root"), "{err}"); assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err()); // Without a project there is nothing to read. - let inv = Invocation { client: None, workspace_root: None, editor: None }; + let inv = Invocation::default(); let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; assert!(err.contains("no project is open"), "{err}"); assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project)); @@ -668,6 +673,7 @@ fn contribution_only_plugins() { let theme = f.install(&theme_template()); assert_eq!(theme.status, PluginStatus::Static); assert!(!theme.has_code); + assert!(theme.icon.is_none()); assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night"); let lang = f.install(&sample("language-toy")); assert_eq!(lang.status, PluginStatus::Static); @@ -1934,6 +1940,69 @@ fn official_csv_editor_edits_the_shared_document() { assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); } +#[test] +fn view_item_menus_name_a_declared_view() { + /// The menu is new in API 1.2. + fn api_1_2(m: &str) -> String { + m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"") + } + fn with_menu(extra: &str) -> impl Fn(&str) -> String + '_ { + move |m: &str| format!("{}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n", api_1_2(m)) + } + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + + let second_view = + "[[contributes.views]]\nid = \"more\"\ntitle = \"More\"\nicon = \"plugin\"\nlocation = \"right\"\n"; + let ok = hello_variant(tmp.path(), |m| { + let on_greetings = + with_menu("menu = \"viewItem.context\"\nview = \"greetings\"\nwhen = \"viewItemKind == English\"")(m); + let on_more = with_menu("menu = \"viewItem.context\"\nview = \"more\"")(""); + format!("{on_greetings}\n{second_view}\n{on_more}") + }); + assert_eq!(f.install(&ok).status, PluginStatus::Inactive); + let item = f.host.ui_items("menus").into_iter().find(|i| i.decl["menu"] == "viewItem.context").unwrap(); + assert_eq!( + (item.key.as_str(), item.decl["view"].as_str()), + ("viewItem.context/greetings/word-count", Some("greetings")) + ); + let keys: Vec = f.host.ui_items("menus").into_iter().map(|i| i.key).collect(); + for view in ["greetings", "more"] { + assert!(keys.contains(&format!("viewItem.context/{view}/word-count")), "{keys:?}"); + } + f.host.uninstall("hello").unwrap(); + + for (extra, message) in [ + ("menu = \"viewItem.context\"", "needs `view`"), + ("menu = \"viewItem.context\"\nview = \"nope\"", "view \"nope\" is not declared"), + ("menu = \"projectView.context\"\nview = \"greetings\"", "`view` is only for viewItem.context"), + ] { + let dir = tempfile::tempdir().unwrap(); + let err = f.host.install_from_dir(&hello_variant(dir.path(), with_menu(extra)), &Consent::all()).unwrap_err(); + assert!(err.message.contains(message), "{extra}: {}", err.message); + } + + let dir = tempfile::tempdir().unwrap(); + let web_view = "[[contributes.views]]\nid = \"page\"\ntitle = \"Page\"\nicon = \"plugin\"\nlocation = \"right\"\nweb = \"web/index.html\"\n"; + let pkg = hello_variant(dir.path(), |m| { + let on_page = with_menu("menu = \"viewItem.context\"\nview = \"page\"")(""); + format!("{}\n{web_view}\n{on_page}", api_1_2(m)) + }); + std::fs::create_dir_all(pkg.join("web")).unwrap(); + std::fs::write(pkg.join("web/index.html"), "

hi

").unwrap(); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains("is a web view"), "{}", err.message); + + let dir = tempfile::tempdir().unwrap(); + let old = hello_variant(dir.path(), |m| { + format!( + "{m}\n[[contributes.menus]]\nmenu = \"viewItem.context\"\nview = \"greetings\"\ncommand = \"word-count\"\n" + ) + }); + let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err(); + assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message); +} + /// The sample plugin makes a tool window with a tree view. /// Its menus, shortcut and status bar item are declarations, listed before /// any code runs; the tree is rendered by the code, which loads on first show. @@ -2078,6 +2147,17 @@ fn views_open_project_files_at_a_line() { assert!(opened.is_none()); } +#[test] +fn commands_see_the_paths_they_were_run_on() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let (a, b) = (f.project.join("src/a.txt"), f.project.join("src/b.txt")); + let inv = Invocation { selected_paths: vec![a.clone(), b.clone()], ..f.inv() }; + let out = f.host.run_command("jobs", "selection", Some(&to_ipc(&a)), inv).unwrap(); + assert_eq!(out.as_deref(), Some(format!("{}\n{}", to_ipc(&a), to_ipc(&b)).as_str())); + assert_eq!(f.run("jobs", "selection", None).unwrap().as_deref(), Some("")); +} + #[test] fn vcs_remotes_clipboard_and_open_url() { let f = fixture(Limits::default()); @@ -2134,6 +2214,126 @@ fn vcs_remotes_clipboard_and_open_url() { } } +/// Runs `cmd` of the jobs plugin; once it asks the user, answers after `delay` +/// (`None`: dismisses). Returns the command's result and what it asked. +fn run_and_answer( + f: &Fixture, + cmd: &str, + arg: Option<&str>, + answer: Option<&[&str]>, + delay: Duration, +) -> (Result, String>, ide_plugin::Prompt) { + std::thread::scope(|scope| { + let running = scope.spawn(|| f.run("jobs", cmd, arg)); + let deadline = Instant::now() + Duration::from_secs(10); + let (client, prompt_id, prompt) = loop { + let asked = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)), + _ => None, + }); + if let Some(asked) = asked { + break asked; + } + assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked"); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command"); + std::thread::sleep(delay); + let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect()); + assert!(f.host.answer_prompt(&prompt_id, answer)); + assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once"); + (running.join().unwrap(), prompt) + }) +} + +#[test] +fn commands_ask_the_user_and_reveal_their_views() { + use ide_plugin::{PickItem, Prompt}; + let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() }); + let info = f.install(&sample("jobs")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + let none = Duration::ZERO; + + let (result, prompt) = run_and_answer(&f, "pick", None, Some(&["b"]), none); + assert_eq!(result.unwrap().as_deref(), Some("b")); + let items = vec![ + PickItem { key: "a".into(), label: "Alpha".into(), description: Some("first".into()) }, + PickItem { key: "b".into(), label: "Beta".into(), description: None }, + PickItem { key: "c".into(), label: "Gamma".into(), description: None }, + ]; + assert_eq!(prompt, Prompt::QuickPick { title: "Pick".into(), items, multiple: false }); + + let (result, prompt) = run_and_answer(&f, "pick-many", None, Some(&["a", "c"]), none); + assert_eq!(result.unwrap().as_deref(), Some("a,c")); + assert!(matches!(prompt, Prompt::QuickPick { multiple: true, .. })); + assert_eq!(run_and_answer(&f, "pick", None, None, none).0.unwrap().as_deref(), Some("none")); + + let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none); + assert_eq!(result.unwrap().as_deref(), Some("Other")); + assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) }); + assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none")); + + let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none); + assert_eq!(result.unwrap().as_deref(), Some("true")); + assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() }); + assert_eq!(run_and_answer(&f, "confirm", None, None, none).0.unwrap().as_deref(), Some("false")); + + // The wait is not part of the call's 500 ms budget. + let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::from_millis(1200)); + assert_eq!(result.unwrap().as_deref(), Some("true")); + + // Keys come back as given: one that doesn't fit is refused, not cut. + f.take_events(); + let err = f.run("jobs", "pick", Some("long-key")).unwrap_err(); + assert!(err.contains("item key is longer"), "{err}"); + assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. }))); + + // Without a window there is nothing to attach the dialog to. + f.take_events(); + let no_window = Invocation { client: None, ..f.inv() }; + for cmd in ["pick", "input", "confirm"] { + let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message; + assert!(err.contains("from a command the user invoked"), "{cmd}: {err}"); + } + assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. }))); + + // `views.reveal` only knows the plugin's own declared views. + f.take_events(); + f.run("jobs", "reveal", Some("jobs")).unwrap(); + let revealed = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::RevealView { plugin_id, view } => Some((client, plugin_id, view)), + _ => None, + }); + assert_eq!(revealed, Some((Some("main".into()), "jobs".into(), "jobs".into()))); + for view in ["greetings", ""] { + let err = f.run("jobs", "reveal", Some(view)).unwrap_err(); + assert!(err.contains("declares no view"), "{view:?}: {err}"); + } + assert!(f.run("jobs", "reveal", Some("jobs")).is_ok(), "the instance survives"); +} + +#[test] +fn disabling_a_plugin_that_waits_for_an_answer_does_not_hang() { + let f = fixture(Limits::default()); + let info = f.install(&sample("jobs")); + assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error); + std::thread::scope(|scope| { + let running = scope.spawn(|| f.run("jobs", "confirm", None)); + let deadline = Instant::now() + Duration::from_secs(10); + while !f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })) { + assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked"); + std::thread::sleep(Duration::from_millis(10)); + } + let started = Instant::now(); + f.host.set_enabled("jobs", false).unwrap(); + assert!(started.elapsed() < Duration::from_secs(5), "disabling waited for the user"); + assert!(running.join().unwrap().is_err(), "the waiting command ends with an error"); + }); + f.host.set_enabled("jobs", true).unwrap(); + let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::ZERO); + assert_eq!(result.unwrap().as_deref(), Some("true"), "prompts work again after the restart"); +} + #[test] fn views_can_use_an_svg_icon_from_the_package() { let f = fixture(Limits::default()); @@ -2610,3 +2810,368 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() { let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap(); assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint"))); } + +/// `hello` as a services view, which needs API 1.3. +fn services_variant(text: &str) -> String { + text.replace("location = \"right\"", "location = \"services\"") + .replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"") +} + +#[test] +fn services_views_need_api_1_3_and_code() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let info = f.install(&hello_variant(tmp.path(), services_variant)); + assert!(info.error.is_none(), "{:?}", info.error); + assert_eq!(f.host.ui_items("views").remove(0).decl["location"].as_str(), Some("services")); + + for (edit, message) in [ + ( + Box::new(|t: &str| t.replace("location = \"right\"", "location = \"services\"")) + as Box String>, + "new in plugin API 1.3", + ), + ( + Box::new(|t: &str| t.replace("location = \"right\"", "location = \"middle\"")), + "left, right, bottom or services", + ), + ( + Box::new(|t: &str| { + services_variant(t) + .replace("location = \"services\"", "location = \"services\"\nweb = \"web/index.html\"") + }), + "a services view is rendered by the plugin's code (no `web`)", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), edit); + std::fs::create_dir_all(pkg.join("web")).unwrap(); + std::fs::write(pkg.join("web/index.html"), "

x

").unwrap(); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains(message), "{message}: {err:?}"); + } +} + +#[test] +fn services_views_have_a_detail_pane() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + f.install(&hello_variant(tmp.path(), services_variant)); + let tabs = f.host.service_detail("hello", "greetings", "pod:default/nginx", f.inv()).unwrap(); + assert_eq!(tabs.iter().map(|t| t.id.as_str()).collect::>(), ["log", "info"]); + assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: pod:default/nginx".into())); + let ide_plugin::ServicePane::Log(log) = &tabs[0].body else { panic!("a log: {:?}", tabs[0].body) }; + assert_eq!( + (log.program.as_str(), log.args.as_slice()), + ("sh", ["-c".to_owned(), "pod:default/nginx".to_owned()].as_slice()) + ); + + let f = fixture(Limits::default()); + f.install(&sample("hello")); + let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err(); + assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}"); +} + +/// `hello` as a services view that may run `sh`, granted or not. +#[cfg(unix)] +fn services_with_sh(f: &Fixture, dir: &Path, grant: bool) { + let sh = PathBuf::from("/bin"); + f.host.set_program_path(Arc::new(move || Some(sh.clone().into_os_string()))); + let pkg = hello_variant(dir, |t| { + services_variant(t).replace( + "required = [\"editor.read\", \"editor.write\"]", + "required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]", + ) + }); + f.install(&pkg); + f.host.set_capability("hello", "process:sh", grant).unwrap(); +} + +/// Follows the Log tab of the node whose key is `script`. +#[cfg(unix)] +fn stream( + f: &Fixture, + script: &str, + on: &[&str], + cancel: &ide_core::CancellationToken, + lines: impl Fn(PluginOutputLine), +) -> ide_core::IdeResult> { + let on: Vec = on.iter().map(|s| (*s).to_owned()).collect(); + f.host.stream_service_log("hello", "greetings", script, "log", &on, f.inv(), cancel, &lines) +} + +#[cfg(unix)] +#[test] +fn service_logs_stream_lines_with_the_toggles_args() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let lines = Mutex::new(Vec::new()); + let script = "printf 'a\\nb\\n'; printf 'e\\n' >&2; exit 3"; + let status = stream(&f, script, &[], &Default::default(), |l| lines.lock().push(l)); + assert_eq!(status.unwrap(), Some(3)); + let mut lines = lines.into_inner(); + lines.sort_by_key(|l| l.stderr); + let line = |stderr: bool, text: &str| PluginOutputLine { stderr, text: text.into() }; + assert_eq!(lines, [line(false, "a"), line(false, "b"), line(true, "e")]); + + // `sh -c script arg` names the script `arg` ($0): the toggle's args are appended. + let named = Mutex::new(Vec::new()); + stream(&f, "echo $0", &["named"], &Default::default(), |l| named.lock().push(l.text)).unwrap(); + assert_eq!(named.into_inner(), ["named"]); + + let long = Mutex::new(Vec::new()); + stream(&f, "head -c 40000 /dev/zero | tr '\\0' x; echo; echo next", &[], &Default::default(), |l| { + long.lock().push(l.text.len()) + }) + .unwrap(); + assert_eq!(long.into_inner(), [16 << 10, 4], "a long line is cut, the next one is whole"); +} + +#[cfg(unix)] +#[test] +fn service_logs_need_the_grant_and_a_log_tab() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), false); + let marker = tmp.path().join("ran"); + let script = format!("touch {}", marker.display()); + let err = stream(&f, &script, &[], &Default::default(), |_| {}).unwrap_err(); + assert_eq!(err.kind, ErrorKind::PermissionDenied); + assert!(err.message.contains("permission denied: process:sh is not granted"), "{err:?}"); + assert!(!marker.exists(), "nothing was started"); + + f.host.set_capability("hello", "process:sh", true).unwrap(); + let info = + f.host.stream_service_log("hello", "greetings", &script, "info", &[], f.inv(), &Default::default(), &|_| {}); + assert_eq!(info.unwrap_err().kind, ErrorKind::NotFound, "a text tab runs nothing"); + let cancelled = ide_core::CancellationToken::default(); + cancelled.cancel(); + assert_eq!(stream(&f, &script, &[], &cancelled, |_| {}).unwrap_err().kind, ErrorKind::Cancelled); + assert!(!marker.exists(), "a cancelled request starts nothing"); +} + +#[cfg(unix)] +#[test] +fn service_log_cancel_kills_the_child() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let cancel = ide_core::CancellationToken::default(); + let started = Instant::now(); + let result = stream(&f, "echo up; sleep 30.4321", &[], &cancel, |l| { + if l.text == "up" { + cancel.cancel(); + } + }); + assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed()); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + let orphan = || std::process::Command::new("pgrep").args(["-f", "sleep 30.4321"]).status().unwrap().success(); + assert!(eventually(|| !orphan()), "what the program started is killed too"); +} + +#[cfg(unix)] +#[test] +fn service_log_stops_when_the_grant_is_revoked() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + services_with_sh(&f, tmp.path(), true); + let started = Instant::now(); + let watchdog = ide_core::CancellationToken::default(); + let timer = watchdog.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(3)); + timer.cancel(); + }); + let result = stream(&f, "while :; do echo tick; sleep 0.05; done", &[], &watchdog, |l| { + if l.text == "tick" && started.elapsed() < Duration::from_secs(1) { + f.host.set_capability("hello", "process:sh", false).unwrap(); + } + }); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + let err = result.unwrap_err(); + assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}"); +} + +#[test] +fn plugins_set_their_own_context_keys() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.take_events(); + f.run("jobs", "context", Some("connected=true")).unwrap(); + let changed: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::ContextChanged { plugin_id, key, value } => Some((client, plugin_id, key, value)), + _ => None, + }) + .collect(); + assert_eq!(changed, [(Some("main".into()), "jobs".into(), "connected".into(), Some("true".into()))]); + assert_eq!(f.host.context_keys(Some("main")).len(), 1); + assert!(f.host.context_keys(Some("other")).is_empty(), "window-scoped"); + + for bad in ["bad key=true", "a.b=true", "x=NaN"] { + assert!(f.run("jobs", "context", Some(bad)).unwrap_err().contains("invalid"), "{bad}"); + } + + f.run("jobs", "context", Some("connected=")).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty()); + + f.run("jobs", "context", Some("connected=true")).unwrap(); + f.host.set_enabled("jobs", false).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty(), "cleared when the plugin stops"); +} + +#[test] +fn context_keys_are_capped_and_windows_overlay_global_ones() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + for i in 0..100 { + f.run("jobs", "context", Some(&format!("k{i}=1"))).unwrap(); + } + assert!(f.run("jobs", "context", Some("k100=1")).unwrap_err().contains("invalid")); + f.run("jobs", "context", Some("k0=2")).unwrap(); + for i in 1..4 { + f.run("jobs", "context", Some(&format!("k{i}="))).unwrap(); + } + + let global = Invocation { client: None, ..f.inv() }; + f.host.run_command("jobs", "context", Some("g=1"), global.clone()).unwrap(); + f.host.run_command("jobs", "context", Some("w=global"), global).unwrap(); + f.run("jobs", "context", Some("w=window")).unwrap(); + let w = |client| f.host.context_keys(Some(client)).into_iter().find(|k| k.key == "w").map(|k| k.value); + assert_eq!(w("main").as_deref(), Some("\"window\"")); + assert_eq!(w("other").as_deref(), Some("\"global\"")); + + f.run("jobs", "context", Some("w=")).unwrap(); + assert_eq!(w("main").as_deref(), Some("\"global\""), "removing the window key reveals the global one"); +} + +#[test] +fn context_values_are_stored_as_json_and_bounded() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let value = |arg: &str| { + f.run("jobs", "context", Some(arg)).unwrap(); + f.host.context_keys(Some("main")).into_iter().find(|k| k.key == "x").unwrap().value + }; + assert_eq!(value("x=true"), "true"); + assert_eq!(value("x=2.5"), "2.5"); + assert_eq!(value("x=say \"hi\""), "\"say \\\"hi\\\"\""); + assert_eq!(value("x=1"), "1"); + f.take_events(); + f.run("jobs", "context", Some("x=1.0")).unwrap(); + assert!(f.take_events().is_empty(), "the same value emits nothing"); + let long = format!("x={}", "a".repeat(1025)); + assert!(f.run("jobs", "context", Some(&long)).unwrap_err().contains("invalid")); +} + +#[test] +fn forgetting_a_window_drops_its_context_keys_silently() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.run("jobs", "context", Some("w=1")).unwrap(); + f.take_events(); + f.host.forget_client("main"); + assert!(f.host.context_keys(Some("main")).is_empty()); + assert!(f.take_events().is_empty()); +} + +#[test] +fn a_closed_window_ignores_late_context_writes_until_its_label_is_reused() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + f.host.forget_client("main"); + f.take_events(); + f.run("jobs", "context", Some("late=1")).unwrap(); + assert!(f.host.context_keys(Some("main")).is_empty()); + let events = f.take_events(); + assert!( + !events.iter().any(|(_, e)| matches!(e, PluginEvent::ContextChanged { .. })), + "no context event: {events:?}" + ); + + f.host.reopen_client("main"); + f.run("jobs", "context", Some("fresh=1")).unwrap(); + assert_eq!(f.host.context_keys(Some("main"))[0].key, "fresh"); +} + +#[test] +fn stopping_a_plugin_withdraws_each_context_key_in_its_own_scope() { + let f = fixture(Limits::default()); + f.install(&sample("jobs")); + let global = Invocation { client: None, ..f.inv() }; + f.host.run_command("jobs", "context", Some("g=1"), global).unwrap(); + f.run("jobs", "context", Some("w=1")).unwrap(); + f.take_events(); + f.host.set_enabled("jobs", false).unwrap(); + let mut removed: Vec<_> = f + .take_events() + .into_iter() + .filter_map(|(client, e)| match e { + PluginEvent::ContextChanged { key, value: None, .. } => Some((client, key)), + _ => None, + }) + .collect(); + removed.sort(); + assert_eq!(removed, [(None, "g".to_owned()), (Some("main".to_owned()), "w".to_owned())]); +} + +/// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`. +fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> PathBuf { + let pkg = dir.join(id); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + format!("id = \"{id}\"\nname = \"Iconic\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n{extra}"), + ) + .unwrap(); + for (name, text) in files { + std::fs::write(pkg.join(name), text).unwrap(); + } + pkg +} + +const RECT: &str = r#""#; +const CIRCLE: &str = r#""#; + +#[test] +fn plugin_icons_install_with_the_package() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = icon_package( + tmp.path(), + "iconic", + "icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n", + &[("icon.svg", RECT), ("icon_dark.svg", CIRCLE)], + ); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let icon = info.icon.expect("icon"); + assert!(icon.light.contains("{}", "".repeat(1000)); + let padded = format!("", " ".repeat(20 << 10)); + let cases = [ + ("scripted", "icon = \"icon.svg\"\n", "".to_owned(), "icon.svg"), + ("oversized", "icon = \"icon.svg\"\n", big, "16 KiB"), + ("padded", "icon = \"icon.svg\"\n", padded, "16 KiB"), + ("missing", "icon = \"missing.svg\"\n", RECT.to_owned(), "missing.svg"), + ]; + for (id, extra, svg, expected) in cases { + let pkg = icon_package(tmp.path(), id, extra, &[("icon.svg", &svg)]); + let err = f.host.preview_install(&pkg).map(|_| ()).unwrap_err().message; + assert!(err.contains(expected), "{id}: {err}"); + } +} diff --git a/crates/ide-host/tests/project_smoke.rs b/crates/ide-host/tests/project_smoke.rs index 9ed4cd4f4..029b7f01e 100644 --- a/crates/ide-host/tests/project_smoke.rs +++ b/crates/ide-host/tests/project_smoke.rs @@ -276,7 +276,7 @@ async fn python_venv() { // The project's virtualenv: listed first, and the interpreter the project uses. let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap(); assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}"); - let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter"); + let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW, None).unwrap().expect("an interpreter"); assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}"); } diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index dd3bd868e..9153b8ccf 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -300,5 +300,31 @@ async fn remote_projects_get_plugin_views_and_custom_editors() { }) }) .await; + + invoke( + &peer, + "plugin_run_command", + json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }), + ) + .await + .unwrap(); + until("the context key's event", 5, || { + client.plugin_events.lock().iter().any(|m| { + m.window.as_deref() == Some("main") + && matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true")) + }) + }) + .await; + let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap(); + assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}"); + + let selected = invoke( + &peer, + "plugin_run_command", + json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }), + ) + .await + .unwrap(); + assert_eq!(selected, "/srv/src/a.txt"); plugins.host().shutdown(); } diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index b50d5abce..74766decd 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -349,6 +349,29 @@ fn trusted_locations_use_canonical_directory_boundaries() { assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap()); } +#[test] +fn add_location_trusts_every_project_under_it() { + let tmp = tempfile::tempdir().unwrap(); + let location = tmp.path().join("projects"); + let inside = location.join("nested/deep/app"); + let outside = tmp.path().join("projects-other/app"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = tmp.path().join("not-a-dir"); + std::fs::write(&file, "x").unwrap(); + let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data")); + trust.add_location(&location).unwrap(); + assert!(trust.trusted(&inside).unwrap()); + assert!(!trust.trusted(&outside).unwrap()); + assert_eq!(trust.decisions().unwrap().locations.len(), 1); + trust.add_location(&location).unwrap(); + assert_eq!(trust.decisions().unwrap().locations.len(), 1, "re-adding is a no-op"); + assert!(trust.add_location(&file).is_err(), "a file is not a location"); + assert!(trust.add_location(&tmp.path().join("missing")).is_err(), "a missing path is not a location"); + let reloaded = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data")); + assert!(reloaded.trusted(&inside).unwrap()); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() { diff --git a/crates/ide-host/tests/unity.rs b/crates/ide-host/tests/unity.rs index f38fdbdd7..148d0f407 100644 --- a/crates/ide-host/tests/unity.rs +++ b/crates/ide-host/tests/unity.rs @@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in assert!(unity.projects.is_empty() && unity.solutions.is_empty()); assert!(project.index.wait_idle(Duration::from_secs(20))); assert_eq!(project.index.find_files("Player.cs", 100).len(), 1); + let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap(); + assert_eq!(listed.len(), 1); for name in ["Cached.cs", "Temporary.cs", "Editor.log"] { assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}"); } diff --git a/crates/ide-http/src/exec.rs b/crates/ide-http/src/exec.rs index 963da518f..53409cb97 100644 --- a/crates/ide-http/src/exec.rs +++ b/crates/ide-http/src/exec.rs @@ -87,12 +87,19 @@ pub fn prepare( } else { with_scheme(&url) }; + // Multipart bodies delimit parts with CRLF per RFC 2046 §5.1; joining + // with bare LF leaves servers (e.g. Tomcat) unable to find the boundary. + let is_multipart = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-type")) + .is_some_and(|(_, v)| v.to_ascii_lowercase().starts_with("multipart/")); + let eol = if is_multipart { "\r\n" } else { "\n" }; Ok(PreparedRequest { name: raw.block.name.clone(), method: raw.block.method.clone(), url, headers, - body: (!body_lines.is_empty()).then(|| body_lines.join("\n")), + body: (!body_lines.is_empty()).then(|| body_lines.join(eol)), }) } @@ -205,6 +212,22 @@ mod tests { assert!(err.message.contains("host"), "{}", err.message); } + #[test] + fn multipart_bodies_join_lines_with_crlf() { + let f = parse( + "POST https://api.test/upload\nContent-Type: multipart/form-data; boundary=xyz\n\n--xyz\nfield\n--xyz--\n", + ); + let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap(); + assert_eq!(p.body.as_deref(), Some("--xyz\r\nfield\r\n--xyz--")); + } + + #[test] + fn non_multipart_bodies_join_lines_with_lf() { + let f = parse("POST https://api.test/x\nContent-Type: application/json\n\n{\n\"a\": 1\n}\n"); + let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap(); + assert_eq!(p.body.as_deref(), Some("{\n\"a\": 1\n}")); + } + #[test] fn relative_urls_use_the_host_header() { let f = parse("GET /status\nHost: localhost:9000\n"); diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index 711373fe4..b52f666e1 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -189,6 +189,43 @@ impl ProjectIndex { out } + /// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or + /// not-yet-indexed contents fall back to the caller's parser. + pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option> { + let hash = blake3::hash(text.as_bytes()); + let data = self.shared.data.read(); + let file = data.get(path)?; + if &file.hash != hash.as_bytes() { + return None; + } + Some( + file.symbols + .iter() + .map(|s| ide_language::SymbolTag { + name: s.name.to_string(), + kind: s.kind, + container: s.container.as_ref().map(ToString::to_string), + range: s.range, + }) + .collect(), + ) + } + + /// Bounded file inventory for background plugin indexers; honors the same + /// project/exclude scope as search and never walks the filesystem. + pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec { + self.shared + .data + .read() + .files + .iter() + .flatten() + .filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e))) + .take(limit) + .map(|f| f.path.clone()) + .collect() + } + /// Fuzzy file search. IDEA-like ranking: file-name matches beat directory /// matches; `/` in the query matches path segments; recently opened files boost. pub fn find_files(&self, query: &str, limit: usize) -> Vec { @@ -197,6 +234,28 @@ impl ProjectIndex { matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit) } + /// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to + /// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is + /// indexed yet (first scan without a cache). + pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option> { + let d = self.shared.data.read(); + if d.file_count == 0 { + return None; + } + let mut out = Vec::new(); + for file in d.files.iter().flatten().filter(|f| !f.ignored) { + if out.len() >= limit { + break; + } + if let Ok(rel) = file.path.strip_prefix(dir) + && matches(rel) + { + out.push(file.path.clone()); + } + } + Some(out) + } + /// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`. pub fn find_dirs(&self, query: &str, limit: usize) -> Vec { matching::find_dirs(&self.shared.data.read(), query, limit) diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index d8dadee2f..765f8cc17 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -632,3 +632,23 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() { assert!(idx.wait_idle(WAIT)); assert!(idx.find_files("", 100).is_empty()); } + +#[test] +fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() { + let java = "package demo; interface UserMapper { void find(); }"; + let f = Fixture::new(&[ + ("src/UserMapper.java", java), + ("src/mapper.xml", ""), + ("target/Generated.java", "class Generated {}"), + ("other.txt", "ignored"), + ]); + let index = f.open(); + let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100); + assert_eq!(paths.len(), 2); + assert!(paths.iter().all(|p| p.starts_with(f.root.join("src")))); + assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1); + let path = f.root.join("src/UserMapper.java"); + let symbols = index.symbols_for_text(&path, java).unwrap(); + assert!(symbols.iter().any(|s| s.name == "find")); + assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none()); +} diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index 4bda809a6..560d5a5f5 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0" tree-sitter-make = "1.1.1" tree-sitter-cmake = "0.7.4" tree-sitter-xml = "0.7.0" -tree-sitter-haskell = "0.23.1" +tree-sitter-haskell = "0.24.1" tree-sitter-dart = "0.2.0" tree-sitter-graphql = "0.3.0" tree-sitter-gdscript = "6.1.0" diff --git a/crates/ide-language/queries/haskell/outline.scm b/crates/ide-language/queries/haskell/outline.scm index 4f5c6e2b8..94af21f59 100644 --- a/crates/ide-language/queries/haskell/outline.scm +++ b/crates/ide-language/queries/haskell/outline.scm @@ -1,7 +1,7 @@ (header module: (module) @name) @outline.module (data_type name: (name) @name) @def.enum (newtype name: (name) @name) @def.struct -(type_synomym name: (name) @name) @def.type +(type_synonym name: (name) @name) @def.type (type_family name: (name) @name) @def.type (class name: (name) @name) @def.interface (instance name: (name) @name patterns: (_) @detail) @outline.namespace diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 8d606d3df..3c6ca323a 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -93,6 +93,16 @@ pub struct IndentConfig { pub insert_spaces: bool, } +/// Additions to the tsserver project of a tree without `tsconfig.json` / `jsconfig.json`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TsserverProjectExtras { + /// File extensions a tsserver plugin handles, registered with the sources (`vue`). + pub extensions: Vec, + /// `compilerOptions.paths` with absolute targets: the bundler's module aliases + /// (`@/*` → `/src/*` in a Vue CLI project). + pub paths: BTreeMap>, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfig { @@ -155,6 +165,9 @@ pub struct LanguageServerConfig { /// Register source files with tsserver when no project configuration exists. #[serde(default)] pub tsserver_project: bool, + /// Set by the settings hook: what that registered project adds to its defaults. + #[serde(skip)] + pub tsserver_project_extras: TsserverProjectExtras, /// Re-sends the open documents (unchanged) once `workspace/configuration` is answered: /// the server (Intelephense) applies some settings, such as the PHP version, only when /// it parses a document. diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index aff9d7de2..5927d53d0 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, OnceLock}; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ ("asciidoc", include_str!("../../../languages/asciidoc.toml")), + ("astro", include_str!("../../../languages/astro.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -533,6 +534,7 @@ mod tests { "Julia", "Kotlin", "LiveScript", + "Log", "Lua", "mIRC", "Mathematica", @@ -601,6 +603,7 @@ mod tests { "Angular Template", "ERB", "JSP", + "Astro", ]; fn reg() -> LanguageRegistry { @@ -636,6 +639,7 @@ mod tests { for (lang, servers) in [ ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), + ("java", vec!["jdtls", "kmp-lsp"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); let got: Vec<(&str, bool)> = cfg @@ -674,7 +678,7 @@ mod tests { let root = tmp.path(); std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); - for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); @@ -888,6 +892,7 @@ mod tests { ("App.svelte", "svelte"), ("index.jsp", "jsp"), ("WEB-INF/tags/card.tag", "jsp"), + ("index.astro", "astro"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), @@ -949,6 +954,14 @@ mod tests { assert_eq!(res.line_comment.as_deref(), Some(";")); } + #[test] + fn astro_server_reads_the_project_typescript() { + let reg = reg(); + let ls = ®.get(&LanguageId::new("astro")).unwrap().language_servers[0]; + let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str()); + assert_eq!(tsdk, Some("node_modules/typescript/lib")); + } + #[test] fn detects_shebangs() { let reg = reg(); diff --git a/crates/ide-lsp/src/external_project.rs b/crates/ide-lsp/src/external_project.rs index b00900a4e..81de5cf40 100644 --- a/crates/ide-lsp/src/external_project.rs +++ b/crates/ide-lsp/src/external_project.rs @@ -14,6 +14,8 @@ const SCAN_LIMIT: usize = 100_000; /// tsserver turns its language service off past 20 MiB of JavaScript in a project. const SCRIPT_BYTES_LIMIT: u64 = 16 << 20; const REFRESH_DELAY: Duration = Duration::from_millis(300); +/// Bounds the loading phase reported after registering a project. +const LOADING_LIMIT: Duration = Duration::from_secs(60); const TIMEOUT: Duration = Duration::from_secs(15); #[derive(Default)] @@ -47,7 +49,13 @@ fn is_source(ext: &str) -> bool { /// All-or-nothing at the caps: a partial project could displace an inferred /// project while omitting some of its files. Configured trees stay with tsserver. -fn source_files(root: &Path, exclude_root: &Path, excludes: Vec, limit: usize) -> Vec { +fn source_files( + root: &Path, + exclude_root: &Path, + excludes: Vec, + extra: &[String], + limit: usize, +) -> Vec { if root.ancestors().any(configured) { return Vec::new(); } @@ -80,8 +88,9 @@ fn source_files(root: &Path, exclude_root: &Path, excludes: Vec, limit: return Vec::new(); } let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default(); - if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) { - if is_script(ext) { + let plugin = extra.iter().any(|e| e == ext); + if entry.file_type().is_some_and(|t| t.is_file()) && (is_source(ext) || plugin) { + if is_script(ext) || plugin { script_bytes += entry.metadata().map_or(0, |m| m.len()); if script_bytes > SCRIPT_BYTES_LIMIT { tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects"); @@ -104,16 +113,22 @@ fn project_name(server: &Server) -> PathBuf { } async fn request(run: &Running, command: &str, args: Value) -> bool { + send(run, command, args, false, TIMEOUT).await +} + +/// `is_async`: a command tsserver answers with events, such as `geterr` (vtsls returns once +/// they all arrived). +async fn send(run: &Running, command: &str, args: Value, is_async: bool, timeout: Duration) -> bool { let request = run.peer.request::<_, Value>( "workspace/executeCommand", json!({ "command": COMMAND, - "arguments": [command, args, { "isAsync": false, "expectsResult": true }] + "arguments": [command, args, { "isAsync": is_async, "expectsResult": true }] }), None, ); - match tokio::time::timeout(TIMEOUT, request).await { - Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true, + match tokio::time::timeout(timeout, request).await { + Ok(Ok(result)) if is_async || result.get("success").and_then(Value::as_bool) == Some(true) => true, result => { tracing::debug!("tsserver {command}: {result:?}"); false @@ -126,7 +141,7 @@ impl LspManager { *self.excludes_hook.write() = Some(Arc::from(hook)); } - pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) { + pub(crate) async fn refresh_external_project(&self, server: &Arc, run: &Running) { if !supported(server, run) { return; } @@ -135,11 +150,13 @@ impl LspManager { *state = State { generation: run.generation, files: Vec::new() }; server.external_diagnostics.store(false, Ordering::Release); } + let extras = server.effective_config().tsserver_project_extras; let root = server.root.clone(); let exclude_root = self.project_root_for(&root); let hook = self.excludes_hook.read().clone(); + let extensions = extras.extensions.clone(); let files = tokio::task::spawn_blocking(move || { - source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT) + source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), &extensions, LIMIT) }) .await .unwrap_or_default(); @@ -155,17 +172,19 @@ impl LspManager { } server.external_diagnostics.store(true, Ordering::Release); let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect(); + // What an inferred project assumes, so sharing a project changes no diagnostics. + let mut options = json!({ "allowJs": true, "target": "ES2022", "module": "ESNext", + "moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve", + "allowSyntheticDefaultImports": true, "strictNullChecks": true, + "strictFunctionTypes": true, "moduleDetection": "force" }); + // Absolute targets need no `baseUrl` (deprecated in TypeScript 6). + if !extras.paths.is_empty() { + options["paths"] = json!(extras.paths); + } if request( run, "openExternalProject", - json!({ - "projectFileName": project_name(server), "rootFiles": roots, - // What an inferred project assumes, so sharing a project changes no diagnostics. - "options": { "allowJs": true, "target": "ES2022", "module": "ESNext", - "moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve", - "allowSyntheticDefaultImports": true, "strictNullChecks": true, - "strictFunctionTypes": true, "moduleDetection": "force" } - }), + json!({ "projectFileName": project_name(server), "rootFiles": roots, "options": options }), ) .await { @@ -175,24 +194,32 @@ impl LspManager { files.len(), server.root.display() ); + if state.files.is_empty() { + self.await_external_project(server, run.generation, files[0].clone()); + } state.files = files; } } /// Re-registers config-less projects after sources or configurations changed on disk. pub fn project_files_changed<'a>(&self, paths: impl IntoIterator) { - let relevant = paths.into_iter().any(|p| { - p.extension().and_then(|s| s.to_str()).is_some_and(is_source) - || p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json") - }); - if !relevant { - return; - } - for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) { + let paths: Vec<&PathBuf> = paths.into_iter().collect(); + let servers: Vec<_> = self.servers.lock().iter().filter(|s| s.config.tsserver_project).cloned().collect(); + for server in servers { + let extensions = server.effective_config().tsserver_project_extras.extensions; + let relevant = paths.iter().any(|p| { + p.extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| is_source(ext) || extensions.iter().any(|e| e == ext)) + || p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json") + }); + if !relevant { + continue; + } if server.external_refresh_queued.swap(true, Ordering::AcqRel) { continue; } - let (me, server) = (self.me.clone(), server.clone()); + let me = self.me.clone(); self.runtime.spawn(async move { tokio::time::sleep(REFRESH_DELAY).await; server.external_refresh_queued.store(false, Ordering::Release); @@ -203,6 +230,36 @@ impl LspManager { } } + /// vtsls answers requests from its single-file syntax server until the semantic server + /// reports a project loaded or diagnostics; a project registered this way reports no + /// loading, so the first usage counts would all be zero. Until the diagnostics of an open + /// file arrive, the server is loading: empty answers are incomplete and asked again. + fn await_external_project(&self, server: &Arc, generation: u64, fallback: PathBuf) { + server.external_loading.store(generation, Ordering::Release); + server.set_loading(&self.sink, true); + let (me, server) = (self.me.clone(), server.clone()); + self.runtime.spawn(async move { + // Documents are (re)opened once the instance is ready. An unopened file has no + // project in tsserver until one of its files is opened: no diagnostics come. + if let Some(run) = server.wait_ready(LOADING_LIMIT).await.filter(|r| r.generation == generation) + && let Some(file) = me.upgrade().map(|me| { + me.docs + .lock() + .values() + .find(|d| d.servers.iter().any(|a| a.server.id == server.id)) + .map_or(fallback, |d| d.path.clone()) + }) + { + send(&run, "geterr", json!({ "files": [file], "delay": 0 }), true, LOADING_LIMIT).await; + } + if server.external_loading.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire).is_ok() + && let Some(me) = me.upgrade() + { + server.set_loading(&me.sink, false); + } + }); + } + pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) { let mut state = server.external_project.lock().await; if state.generation == run.generation && !state.files.is_empty() { @@ -241,17 +298,32 @@ mod tests { } std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); assert_eq!( - source_files(root, root, vec!["excluded".into()], LIMIT), + source_files(root, root, vec!["excluded".into()], &[], LIMIT), vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")] ); - assert!(source_files(root, root, vec![], 1).is_empty()); + assert!(source_files(root, root, vec![], &[], 1).is_empty()); std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap(); - assert!(source_files(root, root, vec![], LIMIT).is_empty()); + assert!(source_files(root, root, vec![], &[], LIMIT).is_empty()); std::fs::remove_file(root.join("large.js")).unwrap(); std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap(); - assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4); + assert_eq!(source_files(root, root, vec!["excluded".into()], &[], LIMIT).len(), 4); std::fs::remove_file(root.join("large.ts")).unwrap(); - assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2); + assert_eq!(source_files(&root.join("src"), root, vec![], &[], LIMIT).len(), 2); + } + + #[test] + fn plugin_extensions_join_the_sources() { + let dir = tempfile::tempdir().unwrap(); + for path in ["src/main.js", "src/App.vue", "src/page.svelte"] { + write(dir.path(), path); + } + assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), vec![dir.path().join("src/main.js")]); + assert_eq!( + source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT), + vec![dir.path().join("src/App.vue"), dir.path().join("src/main.js")] + ); + std::fs::File::create(dir.path().join("src/Big.vue")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap(); + assert!(source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT).is_empty()); } #[test] @@ -261,13 +333,13 @@ mod tests { let root = dir.path(); write(root, "a.ts"); write(root, "pkg/b.js"); - assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2); + assert_eq!(source_files(root, root, vec![], &[], LIMIT).len(), 2); write(root, &format!("pkg/{name}")); - assert!(source_files(root, root, vec![], LIMIT).is_empty()); + assert!(source_files(root, root, vec![], &[], LIMIT).is_empty()); std::fs::remove_file(root.join("pkg").join(name)).unwrap(); write(root, name); - assert!(source_files(root, root, vec![], LIMIT).is_empty()); - assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty()); + assert!(source_files(root, root, vec![], &[], LIMIT).is_empty()); + assert!(source_files(&root.join("pkg"), root, vec![], &[], LIMIT).is_empty()); } } @@ -275,12 +347,12 @@ mod tests { fn additions_deletions_and_renames_change_membership() { let dir = tempfile::tempdir().unwrap(); write(dir.path(), "a.js"); - let first = source_files(dir.path(), dir.path(), vec![], LIMIT); + let first = source_files(dir.path(), dir.path(), vec![], &[], LIMIT); write(dir.path(), "b.ts"); - assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2); + assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).len(), 2); std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap(); - assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first); + assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), first); std::fs::remove_file(dir.path().join("a.js")).unwrap(); - assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty()); + assert!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).is_empty()); } } diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index c8b8f9f05..17650b117 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -136,6 +136,12 @@ impl ClientHandler { fn show_message(&self, mgr: &LspManager, params: &Value) { let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned(); + // rust-analyzer built from source reports its own slow main-loop turns as errors, meant for + // its developers (`poke_rust_analyzer_developer`); nothing for the user to act on. + if text.starts_with("overly long loop turn") { + tracing::warn!("[{}] {text}", self.server.name); + return; + } let level = match params.get("type").and_then(Value::as_u64) { Some(1) => NotifyLevel::Error, Some(2) => NotifyLevel::Warning, diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 360dfa96c..94767ace5 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -327,6 +327,36 @@ impl LspManager { Ok(changed) } + /// Start Java in trusted build-root projects during idle startup, so configuration + /// navigation can warm after import even when no Java editor has been opened. + pub fn prewarm_java_configuration(self: &Arc) { + let weak = Arc::downgrade(self); + self.runtime.spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let Some(mgr) = weak.upgrade() else { return }; + if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire) + || !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire) + { + return; + } + let root = mgr.project_root.clone(); + let java_project = tokio::task::spawn_blocking(move || { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| root.join(name).is_file()) + }) + .await + .unwrap_or(false); + if java_project { + for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) { + if server.name == SERVER { + mgr.ensure_started(&server); + } + } + } + }); + } + pub(crate) fn java_import_ready(self: &Arc, server: Arc, generation: u64) { let mgr = self.clone(); self.runtime.spawn(async move { @@ -334,6 +364,32 @@ impl LspManager { if let Err(e) = mgr.java_configure_compiler(&server, &run).await { tracing::warn!("jdtls: cannot apply compiler defaults: {e}"); } + // The extension only queues bounded, low-priority work; this request + // never waits for the configuration index itself to be built. + const WARMUP: &str = "openidea.java.configuration.warmup"; + if server.running_gen(generation).is_some() + && run.caps.read().commands().iter().any(|command| command == WARMUP) + { + let preferred: Vec<_> = mgr + .docs + .lock() + .values() + .filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties")) + .map(|doc| doc.uri.to_string()) + .collect(); + let result = tokio::time::timeout( + Duration::from_secs(5), + run.peer.request::<_, Value>( + "workspace/executeCommand", + json!({"command": WARMUP, "arguments": ["start", preferred]}), + None, + ), + ) + .await; + if !matches!(result, Ok(Ok(_))) { + tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation"); + } + } }); } @@ -758,7 +814,7 @@ mod tests { for wrapped in [false, true] { let mut cfg = java_config(); let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}}); - cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings }); + cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings }); configure_source_paths(&mut cfg, Some(vec!["custom".into()])); let settings = crate::handler::all_settings(&cfg); assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"])); diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index c8f68f0e0..e7ce586b5 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -4,7 +4,7 @@ use ide_core::CancellationToken; use ide_core::tool_path::search_path; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher { // A plugin's server: the approved program, unchanged since. Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()), None => ide_core::tool_path::find_command(&config.command, &search, root) - .and_then(|b| resolve_rustup_proxy(&config.command, b)), + .and_then(|b| resolve_rustup_proxy(&config.command, b)) + .and_then(|b| reject_dead_shim(b, &search, root)), }; let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); @@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path) } +/// What a version manager's shim says when the active version has no such tool. +const DEAD_SHIM_MESSAGES: &[&str] = + &["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"]; + +/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever +/// had the tool, but exits with an error when the version active in `root` lacks it. A +/// server that dies like that on every start is missing (an install hint), not crashing. +/// Anything but a clear "not installed" answer from the shim counts as working. +fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option { + if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) { + return Some(binary); + } + let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary)) + .arg("--version") + .current_dir(root) + .env("PATH", search) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + else { + return Some(binary); + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + // Slow or unwaitable: let the real start decide. + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Some(binary); + } + } + }; + if status.success() { + return Some(binary); + } + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr); + } + let stderr = stderr.to_lowercase(); + if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) { + tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim()); + None + } else { + Some(binary) + } +} + /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use /// the newest installed toolchain that ships the binary; with none, the tool is @@ -219,6 +274,34 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[cfg(unix)] + #[test] + fn shims_without_the_tool_are_missing() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let shims = tmp.path().join("mise/shims"); + std::fs::create_dir_all(&shims).unwrap(); + let script = |name: &str, body: &str| { + let p = shims.join(name); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + }; + let search = OsString::from("/usr/bin:/bin"); + let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1"); + assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None); + let works = script("good", "echo 1.0"); + assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works)); + // A tool with no `--version` that fails for its own reasons still starts. + let odd = script("odd", "echo 'usage: odd' >&2; exit 2"); + assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd)); + // Only shims are probed. + let plain = tmp.path().join("ruby-lsp"); + std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap(); + std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain)); + } + #[test] fn marked_root_needs_a_marker() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 556dd1edf..6390eb679 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -315,6 +315,30 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec) -> Vec, + complete: bool, +} + +/// Receives the implementations found so far, before a slower search for the rest. +type Partial<'a> = Option<&'a (dyn Fn(Vec) + Send + Sync)>; + +/// Receives the references found so far, before a slower search for the rest. +pub type PartialReferences<'a> = Option<&'a (dyn Fn(References) + Send + Sync)>; + +/// `first`, then those of `more` not in it (same file and start). +fn merge_locations(first: Vec, more: Vec) -> Vec { + let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect(); + let mut out = first; + out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start)))); + out +} + fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) { match kind { GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), @@ -773,6 +797,9 @@ impl LspManager { if server.name == "gopls" { params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] }); } + if server.name == "csharp-ls" { + params["capabilities"]["experimental"]["csharp"]["metadataUris"] = json!(true); + } if !init_options.is_null() { params["initializationOptions"] = init_options; } @@ -1068,7 +1095,15 @@ impl LspManager { cancel: Option<&CancellationToken>, timeout: Duration, ) -> IdeResult { - let fut = t.run.peer.request::(method, params, cancel.cloned()); + let fut = async { + if t.server.name == "csharp-ls" { + let mut value = t.run.peer.request::(method, params, cancel.cloned()).await?; + virtual_doc::normalize_csharp_uris(&mut value); + serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}"))) + } else { + t.run.peer.request::(method, params, cancel.cloned()).await + } + }; match tokio::time::timeout(timeout, fut).await { Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)), Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)), @@ -1229,7 +1264,22 @@ impl LspManager { } let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; - self.goto_in(&snap, &targets, method, pos, None).await + self.goto_in(&snap, &targets, method, pos, None, None).await + } + + /// Go to Implementation, with the implementations found first passed to `partial` while + /// the search goes on: jdtls answers from the project's sources quickly, then searches every + /// dependency jar for a type declared in one. Returns them all, the first ones first. + pub async fn goto_implementation( + &self, + doc: DocId, + pos: Pos, + cancel: CancellationToken, + partial: &(dyn Fn(Vec) + Send + Sync), + ) -> IdeResult> { + let (method, cap, feature) = goto_method(GotoKind::Implementation); + let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await } /// Implementations of the symbols at each of `positions` (gutter line @@ -1246,7 +1296,7 @@ impl LspManager { let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; let requests = positions.iter().copied().map(|pos| { let (snap, targets, cancel) = (&snap, &targets, &cancel); - async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await } + async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await } }); let results: Vec>> = futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await; @@ -1259,6 +1309,46 @@ impl LspManager { Ok(results.into_iter().map(Result::unwrap_or_default).collect()) } + /// jdtls: the implementations in the project's sources (`openidea.java.implementations`), + /// and whether they are all there are. Its standard request builds the type hierarchy over + /// every dependency jar too, which takes long in a project with many dependencies. + async fn java_source_implementations( + &self, + snap: &DocSnap, + t: &Target, + pos: Pos, + cancel: Option<&CancellationToken>, + ) -> (Vec, bool) { + if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) { + return (Vec::new(), false); + } + let params = snap.pos_params(t, pos); + let arguments = + json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]); + let result: IdeResult = self + .call( + t, + "workspace/executeCommand", + json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }), + cancel, + DEFAULT_TIMEOUT, + ) + .await; + match result { + Ok(found) => { + let locs = + convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations)); + let locs = library_sources(snap, pos, locs).await; + self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + (locs, found.complete) + } + Err(e) => { + tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message); + (Vec::new(), false) + } + } + } + async fn goto_in( &self, snap: &DocSnap, @@ -1266,9 +1356,24 @@ impl LspManager { method: &str, pos: Pos, cancel: Option<&CancellationToken>, + partial: Partial<'_>, ) -> IdeResult> { let mut out = Outcome::new(); for t in targets.iter().filter(|t| snap.asks(t, pos)) { + // Found in the sources before the standard request searches the dependencies too. + let mut first = Vec::new(); + if method == "textDocument/implementation" { + let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await; + if complete && !locs.is_empty() { + return Ok(locs); + } + if let Some(partial) = partial + && !locs.is_empty() + { + partial(locs.clone()); + first = locs; + } + } let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || { self.call::<_, Option>( t, @@ -1284,12 +1389,19 @@ impl LspManager { let locs = convert::goto_response(&self.conv(t.enc()), r); let locs = library_sources(snap, pos, locs).await; self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + let locs = merge_locations(first, locs); if !locs.is_empty() { return Ok(locs); } out.value = Some(Vec::new()); } + Ok(None) if !first.is_empty() => return Ok(first), Ok(None) => out.value = Some(Vec::new()), + // Those shown stay valid when the rest can't be found. + Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => { + tracing::debug!("go to implementation in the dependencies: {}", e.message); + return Ok(first); + } Err(e) => out.fail(e), } } @@ -1307,21 +1419,44 @@ impl LspManager { include_declaration: bool, project_wide: bool, cancel: CancellationToken, + ) -> IdeResult { + self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await + } + + /// [`Self::references`]. Java, `project_wide`: without `libraries`, only the + /// workspace's sources are searched (`libraries_skipped`); with it, those found + /// there arrive on `partial` before the dependencies and the JDK are searched. + #[allow(clippy::too_many_arguments)] + pub async fn references_streaming( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + libraries: bool, + cancel: CancellationToken, + partial: PartialReferences<'_>, ) -> IdeResult { let snap = self.snapshot(doc)?; - // Scoped probes back code lenses and bulk analysis. Keep their native cost; - // configuration indexing is reserved for explicit, project-wide usages. - let configuration = if project_wide || snap.language.as_str() != "java" { - self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? - } else { - None - }; if snap.language.as_str() != "java" - && let Some(result) = configuration + && let Some(result) = self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? { return Ok(result); } - let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; + // Scoped probes back code lenses and bulk analysis. Keep their native cost; + // configuration indexing is reserved for explicit, project-wide usages. + let configuration = async { + if project_wide && snap.language.as_str() == "java" { + self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await + } else { + Ok(None) + } + }; + let native = + self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial); + let (configuration, result) = futures::join!(configuration, native); + let configuration = configuration?; + let mut result = result?; if let Some(configuration) = configuration { result.locations.extend(configuration.locations); result.incomplete = result.incomplete.or(configuration.incomplete); @@ -1330,13 +1465,16 @@ impl LspManager { Ok(result) } + #[allow(clippy::too_many_arguments)] async fn native_references( &self, doc: DocId, pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, cancel: CancellationToken, + partial: PartialReferences<'_>, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; // The extension preserves method identity before JDT collapses references to locations. @@ -1348,28 +1486,37 @@ impl LspManager { let classified = async { let t = &targets[0]; let extension_error = Mutex::new(None); - let query = || async { + let (snap, cancel, extension_error) = (&snap, &cancel, &extension_error); + // `sources`: the workspace's source folders only, without dependency jars and the JDK. + let query = |sources: bool| async move { if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let params = snap.pos_params(t, pos); + let mut arguments = json!([ + params["textDocument"]["uri"], + params["position"]["line"], + params["position"]["character"], + include_declaration + ]); + if sources { + arguments.as_array_mut().expect("arguments").push(json!("sources")); + } #[derive(serde::Deserialize)] struct JavaReference { #[serde(default)] access: Option, location: lsp::Location, kind: ReferenceKind, + #[serde(default)] + text: Option, } let result: IdeResult> = self .call( t, "workspace/executeCommand", - json!({ - "command": "openidea.java.usages", - "arguments": [params["textDocument"]["uri"], params["position"]["line"], - params["position"]["character"], include_declaration] - }), - Some(&cancel), + json!({ "command": "openidea.java.usages", "arguments": arguments }), + Some(cancel), LONG_TIMEOUT, ) .await; @@ -1388,6 +1535,7 @@ impl LspManager { location, kind: r.kind, access: r.access, + text: r.text, }) }) .collect::>()) @@ -1395,10 +1543,42 @@ impl LspManager { let files = |v: &Vec| { v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect() }; + let mut libraries_skipped = false; let widened = if project_wide { - self.widened(&snap, t, pos, &cancel, query, files).await? + // A library method has thousands of usages in the dependencies and the JDK, + // which take seconds to search: those in the sources (where scope widening + // searches again) are shown first, then the rest are searched once. + let sources = self.widened(snap, t, pos, cancel, || query(true), files).await?; + if let Some(e) = extension_error.lock().take() { + return Err(e); + } + let mut seen = std::collections::HashSet::new(); + let found: Vec<_> = sources + .second + .into_iter() + .flatten() + .chain(sources.first) + .filter(|r| seen.insert(r.location.clone())) + .collect(); + let all = if !libraries { + libraries_skipped = true; + Vec::new() + } else { + if let Some(partial) = partial + && !found.is_empty() + { + partial(References { + locations: found.iter().map(|r| r.location.clone()).collect(), + classified: found.clone(), + incomplete: sources.incomplete, + libraries_skipped: false, + }); + } + query(false).await? + }; + scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete } } else { - let first = query().await?; + let first = query(false).await?; let empty = first.is_empty(); scope::Widened::only(first, empty, &t.server) }; @@ -1407,7 +1587,7 @@ impl LspManager { return Err(IdeError::cancelled()); } // Scope widening can retain the first answer after a failed second query. - if let Some(e) = extension_error.into_inner() { + if let Some(e) = extension_error.lock().take() { return Err(e); } let mut seen = std::collections::HashSet::new(); @@ -1421,7 +1601,7 @@ impl LspManager { .collect(); let locations: Vec = classified.iter().map(|r| r.location.clone()).collect(); self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref())); - Ok(References { locations, classified, incomplete: widened.incomplete }) + Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped }) } .await; match classified { @@ -1473,7 +1653,9 @@ impl LspManager { return Vec::new(); } let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await; - self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default() + self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None) + .await + .unwrap_or_default() }; let (results, declarations) = futures::join!(join_all(requests), declarations); let mut out = Outcome::new(); @@ -2459,7 +2641,14 @@ impl LspManager { }; if let Some((entry, text)) = found { let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned(); - return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id))); + return Ok(self.store_virtual( + uri, + text, + Some(title), + None, + &[], + self.origin_server(uri).map(|s| s.id), + )); } } let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class")); @@ -2470,6 +2659,7 @@ impl LspManager { let mut error = None; for t in &targets { let languages = t.server.languages(); + let mut description = None; let serves_scheme = t.caps().content_schemes().contains(&scheme); let result: IdeResult> = if serves_scheme && !class_file { self.call::<_, Option>( @@ -2484,6 +2674,21 @@ impl LspManager { } else if class_file && languages.iter().any(|l| l.as_str() == "java") { self.call::<_, Option>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT) .await + } else if scheme == "csharp" && t.server.name == "csharp-ls" { + self.call::<_, Option>( + t, + "csharp/metadata", + json!({ "textDocument": { "uri": uri } }), + None, + LONG_TIMEOUT, + ) + .await + .map(|metadata| { + metadata.map(|m| { + description = m.assembly_name.filter(|name| !name.is_empty()); + m.source + }) + }) } else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") { // The official Kotlin LSP: `decompile` answers `{ code, language }`. self.call::<_, Option>( @@ -2514,7 +2719,7 @@ impl LspManager { continue; } }; - return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id))); + return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id))); } if jar_class && error.is_none() { return Err(IdeError::not_found(format!( @@ -2531,10 +2736,15 @@ impl LspManager { uri: &str, text: String, title: Option, + description: Option, languages: &[LanguageId], server: Option, ) -> VirtualDocument { - let decompiled = virtual_doc::is_decompiled(&text); + let decompiled = if virtual_doc::scheme(uri) == "csharp" { + virtual_doc::is_csharp_decompiled(uri) + } else { + virtual_doc::is_decompiled(&text) + }; let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled)); let detected = self.languages.detect(Path::new(&title), None); let language_id = if detected == LanguageId::plain_text() { @@ -2547,7 +2757,7 @@ impl LspManager { text, language_id, title, - description: virtual_doc::description(uri), + description: description.or_else(|| virtual_doc::description(uri)), decompiled, }; if let Some(server) = server { diff --git a/crates/ide-lsp/src/pull.rs b/crates/ide-lsp/src/pull.rs index 63016c0ee..819ba88cc 100644 --- a/crates/ide-lsp/src/pull.rs +++ b/crates/ide-lsp/src/pull.rs @@ -5,7 +5,7 @@ //! and shown together. use crate::server::{Running, Server}; -use crate::{LspManager, convert}; +use crate::{LspManager, convert, virtual_doc}; use ide_core::{DocId, IdeEvent}; use ide_jsonrpc::RpcError; use lsp_types as lsp; @@ -43,6 +43,10 @@ impl LspManager { let targets: Vec = { let docs = self.docs.lock(); let Some(d) = docs.get(&doc) else { return }; + // Decompiled C# is a source view, not compilable project code (e.g. Unity's older language version). + if virtual_doc::is_csharp_decompiled(d.uri.as_str()) { + return; + } d.servers .iter() .filter(|a| only.is_none_or(|s| s.id == a.server.id)) diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 5018951bc..7c4c65777 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -121,6 +121,9 @@ pub struct Server { pub external_project: tokio::sync::Mutex, pub external_diagnostics: std::sync::atomic::AtomicBool, pub external_refresh_queued: std::sync::atomic::AtomicBool, + /// The instance whose just registered external project is loading, until an open file's + /// diagnostics arrive (0: none). + pub external_loading: std::sync::atomic::AtomicU64, } impl Server { @@ -157,6 +160,7 @@ impl Server { external_project: tokio::sync::Mutex::new(Default::default()), external_diagnostics: std::sync::atomic::AtomicBool::new(false), external_refresh_queued: std::sync::atomic::AtomicBool::new(false), + external_loading: std::sync::atomic::AtomicU64::new(0), } } @@ -412,7 +416,8 @@ impl Server { *self.loading.lock() == Some(true) } - /// Records `experimental/serverStatus`: `quiescent` false while the project loads. + /// Records `experimental/serverStatus`: `quiescent` false while the project loads (also + /// set while vtsls loads an external project, see `external_project`). pub fn set_loading(&self, sink: &SharedSink, loading: bool) { if self.loading.lock().replace(loading) == Some(loading) { return; @@ -533,6 +538,7 @@ impl Server { pub fn take_progress(&self) -> Vec<(TaskId, String)> { *self.loading.lock() = None; + self.external_loading.store(0, std::sync::atomic::Ordering::Release); self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect() } diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index d2bdd5ff8..2b6013c83 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -380,6 +380,10 @@ pub struct References { pub classified: Vec, pub locations: Vec, pub incomplete: Option, + /// The dependencies and the JDK were not searched (asked for without `libraries`). + #[serde(default)] + #[specta(optional)] + pub libraries_skipped: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] @@ -503,4 +507,8 @@ pub struct ClassifiedReference { pub access: Option, pub location: Location, pub kind: ReferenceKind, + /// The usage's line, when the server has it at hand (a library class's). + #[serde(default)] + #[specta(optional)] + pub text: Option, } diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index f05354132..1faca595d 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -2,7 +2,68 @@ //! URI (library classes from `jdt://`, disk classes from `file://`, or //! `workspace/textDocumentContent`). -use percent_encoding::percent_decode_str; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; + +const URI_ESCAPES: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'<') + .add(b'>') + .add(b'[') + .add(b']') + .add(b'\\') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + +/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped. +/// Normalize only document URI fields, before lsp-types' strict URI deserialization. +pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris), + serde_json::Value::Object(fields) => { + for (key, value) in fields { + // Opaque payloads must survive resolve/execute round trips unchanged. + if matches!(key.as_str(), "data" | "arguments") { + continue; + } + if matches!(key.as_str(), "uri" | "targetUri") + && let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:")) + { + *value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into(); + } else { + normalize_csharp_uris(value); + } + } + } + _ => {} + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CSharpMetadata { + pub source: String, + pub assembly_name: Option, +} + +/// csharp-ls serves both decompiled and source-generated documents through metadata. +pub(crate) fn is_csharp_decompiled(uri: &str) -> bool { + if scheme(uri) != "csharp" { + return false; + } + let segs = segments(uri); + // The kind follows the project file, not a directory that happens to have this name. + if let Some(kind) = segs.windows(2).find_map(|s| { + (s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str()) + }) { + return kind == "decompiled"; + } + // Older csharp-ls versions used csharp:/metadata/... for decompiled symbols. + segs.first().is_some_and(|s| s == "metadata") +} /// Recognize decompiler headers, without treating disassembler stubs as Java source. pub fn is_decompiled(text: &str) -> bool { @@ -107,6 +168,48 @@ mod tests { const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class"; + #[test] + fn csharp_metadata_titles_and_kinds() { + for (uri, expected) in [ + ("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true), + ("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true), + ("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true), + ("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false), + ("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false), + ("csharp:/metadata/App.csproj/generated/Player.g.cs", false), + ("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false), + ("file:///work/App.csproj/decompiled/Player.cs", false), + ] { + assert_eq!(is_csharp_decompiled(uri), expected, "{uri}"); + } + assert_eq!( + title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true), + "UnityEngine.MonoBehaviour.cs" + ); + assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs"); + } + + #[test] + fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() { + let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs"; + let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs"; + let mut response = serde_json::json!([ + { "uri": uri, "source": uri }, + { "location": { "targetUri": uri } }, + { "uri": encoded }, + { "uri": "file:///D:/project/App.cs" }, + { "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" }, + { "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } } + ]); + let original = response.clone(); + normalize_csharp_uris(&mut response); + assert_eq!(response[0]["uri"], encoded); + assert_eq!(response[0]["source"], uri); + assert_eq!(response[1]["location"]["targetUri"], encoded); + assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]); + assert!(encoded.parse::().is_ok()); + } + #[test] fn jdt_titles() { assert_eq!(title(JDT, true), "SpringApplication.class"); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index c1cc65ff7..2eb71d768 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -238,6 +238,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, } @@ -954,6 +955,11 @@ async fn apply_edit_hook_and_server_requests() { assert_eq!(v, Value::Null); assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err()); + peer.notify( + "window/showMessage", + json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }), + ) + .unwrap(); peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap(); peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap(); peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap(); @@ -962,7 +968,10 @@ async fn apply_edit_hook_and_server_requests() { }) .await; let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count(); - assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced"); + assert_eq!( + notifies, 2, + "showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced" + ); // Dynamically registered capability is now usable. s.respond( @@ -1664,6 +1673,18 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() { assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] })); } +#[tokio::test(flavor = "multi_thread")] +async fn alternative_java_server_uses_lsp_without_jdt_commands() { + let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]); + let (doc, path) = h.open("Sample.java", "class Sample {}"); + h.wait_status("kmp-lsp", ServerStatus::Ready).await; + assert!(h.mgr.hover(doc, Pos::new(0, 7), Default::default()).await.is_ok()); + let error = h.mgr.java_debug_server(&path).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("jdtls")); + assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty()); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. @@ -1796,6 +1817,71 @@ async fn implementations_per_position() { assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); } +#[tokio::test(flavor = "multi_thread")] +async fn java_implementations_come_from_sources_first() { + let caps = json!({ + "textDocumentSync": 1, + "implementationProvider": true, + "executeCommandProvider": { "commands": ["openidea.java.implementations"] }, + }); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let s = h.launcher.state("jdtls"); + let uri = uri_of(&path); + let at = move |lines: &[u32]| { + lines + .iter() + .map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} })) + .collect::() + }; + *s.responder.lock() = Some(Arc::new(move |_, method, params| match method { + // Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3. + "workspace/executeCommand" => Some(match params["arguments"][1].as_u64() { + Some(0) => json!({ "locations": at(&[1]), "complete": true }), + Some(2) => json!({ "locations": at(&[1]), "complete": false }), + _ => json!({ "locations": [], "complete": true }), + }), + "textDocument/implementation" => Some(at(&[3, 1])), + _ => None, + })); + let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::>(); + let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap(); + assert_eq!(lines(&found), vec![1]); + let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap(); + assert_eq!(lines(&markers[0]), vec![1]); + assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies"); + let asked = s.requested("workspace/executeCommand"); + assert_eq!(asked[0]["command"], "openidea.java.implementations"); + assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10))); + + // A dependency's type: those in the sources first, then the dependencies' too. + let partial = Mutex::new(Vec::new()); + let found = h + .mgr + .goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs))) + .await + .unwrap(); + assert_eq!(*partial.lock(), vec![vec![1]]); + assert_eq!(lines(&found), vec![1, 3]); + assert_eq!(s.requested("textDocument/implementation").len(), 1); + // Without a receiver for the first ones (gutter markers), just all of them. + let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap(); + assert_eq!(lines(&found), vec![3, 1]); + assert_eq!(s.requested("textDocument/implementation").len(), 2); + + // None in the sources: the standard request, with the dependencies. + let partial = Mutex::new(Vec::new()); + let found = h + .mgr + .goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs))) + .await + .unwrap(); + assert!(partial.lock().is_empty()); + assert_eq!(lines(&found), vec![3, 1]); + assert_eq!(s.requested("textDocument/implementation").len(), 3); +} + #[tokio::test(flavor = "multi_thread")] async fn navigation_rename_format_and_decorations() { let h = harness(&[("mock", incremental_caps())]); @@ -2179,6 +2265,106 @@ async fn noop_handler_is_usable_for_raw_peers() { // ------------------------------------------------------------------ virtual documents +#[tokio::test(flavor = "multi_thread")] +async fn csharp_metadata_navigation_and_generated_documents() { + let mut caps = incremental_caps(); + caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false }); + let h = harness_for("csharp", "cs", &[("other-csharp", incremental_caps()), ("csharp-ls", caps)], &[]); + let (doc, path) = h.open("Player.cs", "class Player : MonoBehaviour {}\n"); + h.wait_status("other-csharp", ServerStatus::Ready).await; + h.wait_status("csharp-ls", ServerStatus::Ready).await; + let s = h.launcher.state("csharp-ls"); + let other = h.launcher.state("other-csharp"); + eventually(|| !s.requested("textDocument/diagnostic").is_empty()).await; + assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"]["csharp"]["metadataUris"], true); + assert!(other.requested("initialize")[0]["capabilities"]["experimental"]["csharp"].is_null()); + + let uri = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs"; + let text = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n"; + s.respond("csharp/metadata", json!({ "assemblyName": "UnityEngine.CoreModule", "source": text })); + // Both Location and LocationLink preserve the server's type/member selection. + for (response, range) in [ + ( + json!({ "uri": uri.replace("%20", " "), "range": {"start":{"line":1,"character":13},"end":{"line":1,"character":26}} }), + rng(1, 13, 1, 26), + ), + ( + json!([{ "targetUri": uri, "targetRange": {"start":{"line":0,"character":0},"end":{"line":3,"character":1}}, "targetSelectionRange": {"start":{"line":2,"character":16},"end":{"line":2,"character":28}} }]), + rng(2, 16, 2, 28), + ), + ] { + s.respond("textDocument/definition", response); + let defs = h.mgr.goto(doc, Pos::new(0, 16), GotoKind::Definition).await.unwrap(); + assert_eq!(defs, vec![ide_core::Location { path: uri.into(), range, uri: Some(uri.into()) }]); + let vd = h.mgr.virtual_document(uri).await.unwrap(); + assert_eq!(vd.text, text); + assert_eq!(vd.title, "UnityEngine.MonoBehaviour.cs"); + assert_eq!(vd.language_id.as_str(), "csharp"); + assert_eq!(vd.description.as_deref(), Some("UnityEngine.CoreModule")); + assert!(vd.decompiled); + } + assert_eq!(s.requested("csharp/metadata"), vec![json!({"textDocument":{"uri":uri}})]); + assert!(other.requested("csharp/metadata").is_empty()); + + let vdoc = DocId::next(); + h.mgr.did_open_virtual(vdoc, uri, &h.lang, 0, text); + eventually(|| s.docs.lock().contains_key(uri)).await; + s.respond("textDocument/hover", json!({"contents":"class MonoBehaviour"})); + h.mgr.hover(vdoc, Pos::new(1, 13), Default::default()).await.unwrap(); + assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], uri); + assert!(other.requested("textDocument/hover").is_empty()); + h.mgr.did_close(vdoc); + eventually(|| !s.docs.lock().contains_key(uri)).await; + assert!(s.notified("textDocument/didChange").is_empty()); + + // Generated sources use the same request but are not decompiled, even under a directory named decompiled. + let generated = "csharp:/work/decompiled/Game.csproj/generated/Generator/Player.g.cs"; + s.respond("csharp/metadata", json!({"source":"partial class Player {}"})); + let vd = h.mgr.virtual_document(generated).await.unwrap(); + assert_eq!(vd.title, "Player.g.cs"); + assert_eq!(vd.language_id.as_str(), "csharp"); + assert!(vd.description.is_none()); + assert!(!vd.decompiled); + // Generated and ordinary source still get diagnostics; decompiled assemblies do not. + let generated_doc = DocId::next(); + h.mgr.did_open_virtual(generated_doc, generated, &vd.language_id, 0, &vd.text); + eventually(|| s.requested("textDocument/diagnostic").iter().any(|p| p["textDocument"]["uri"] == generated)).await; + assert!(s.requested("textDocument/diagnostic").iter().all(|p| p["textDocument"]["uri"] != uri)); + h.mgr.did_close(generated_doc); + + // Ordinary definitions still resolve to a disk path. + s.respond( + "textDocument/definition", + json!({"uri":uri_of(&path),"range":{"start":{"line":0,"character":6},"end":{"line":0,"character":12}}}), + ); + let defs = h.mgr.goto(doc, Pos::new(0, 6), GotoKind::Definition).await.unwrap(); + assert_eq!(defs[0].path, path); + assert!(defs[0].uri.is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn csharp_metadata_failures_can_be_retried() { + let h = harness_for("csharp", "cs", &[("csharp-ls", incremental_caps())], &[]); + h.open("App.cs", "class App {}\n"); + h.wait_status("csharp-ls", ServerStatus::Ready).await; + let s = h.launcher.state("csharp-ls"); + let uri = "csharp:/work/App.csproj/decompiled/System.String.cs"; + for response in [Value::Null, json!({"source":""})] { + s.respond("csharp/metadata", response); + let err = h.mgr.virtual_document(uri).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); + assert!(err.message.contains("System.String.cs")); + } + s.errors.lock().insert("csharp/metadata".into(), (-32603, "decompilation failed".into())); + assert!(h.mgr.virtual_document(uri).await.unwrap_err().message.contains("decompilation failed")); + s.errors.lock().remove("csharp/metadata"); + s.respond("csharp/metadata", json!({"source":"public class String {}", "assemblyName":""})); + let vd = h.mgr.virtual_document(uri).await.unwrap(); + assert!(vd.decompiled); + assert!(vd.description.is_none()); + assert_eq!(s.requested("csharp/metadata").len(), 4); +} + #[tokio::test(flavor = "multi_thread")] async fn virtual_locations_and_text_document_content() { let mut caps = incremental_caps(); @@ -2304,7 +2490,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() { let source = sources.join("p/Outer.java"); std::fs::write(&source, "package p; class Outer {} ").unwrap(); s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}")); - s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]})); + s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] })); for class in ["Outer", "Outer$Nested", "Outer$1"] { let path = h.root.join(format!("target/classes/p/{class}.class")); let resolved = h.mgr.java_class_file(&path, None).await.unwrap(); @@ -2809,7 +2995,8 @@ async fn classified_java_references_widen_without_losing_metadata() { assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite)))); assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field))); assert_eq!(refs.incomplete, None); - assert_eq!(s.requested("workspace/executeCommand").len(), 3); + // Scoped; then the sources, widened; then the dependencies too. + assert_eq!(s.requested("workspace/executeCommand").len(), 4); assert!(s.requested("textDocument/references").is_empty()); eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; for limit_hit in [true, false] { @@ -2827,6 +3014,97 @@ async fn classified_java_references_widen_without_losing_metadata() { } } +/// A library method's usages in the sources are shown before the dependencies and the JDK +/// are searched, which takes seconds; scope widening searches the sources alone again. +#[tokio::test(flavor = "multi_thread")] +async fn java_usages_in_the_sources_arrive_before_the_dependencies() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": { + "start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}}); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + (method == "workspace/executeCommand").then(|| { + let sources = params["arguments"].get(4) == Some(&json!("sources")); + let mut rows: Vec = s + .docs + .lock() + .iter() + .flat_map(|(uri, text)| occurrences(uri, text)) + .map(|location| json!({"location": location, "kind": "unknown"})) + .chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"}))) + .collect(); + rows.reverse(); + Value::Array(rows) + }) + })); + let b = h.root.join("b.java"); + std::fs::write(&b, "target();").unwrap(); + grep_candidates(&h); + let (doc, a) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let partials = Mutex::new(Vec::new()); + let refs = h + .mgr + .references_streaming( + doc, + Pos::new(0, 5), + true, + true, + true, + Default::default(), + Some(&|r| partials.lock().push(r)), + ) + .await + .unwrap(); + let partials = partials.into_inner(); + assert_eq!(partials.len(), 1); + let paths = + |r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::>(); + let mut shown = paths(&partials[0]); + shown.sort(); + assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]); + assert_eq!(partials[0].classified.len(), 2); + // All of them, those in the sources first. + assert_eq!(refs.locations.len(), 3); + assert!(refs.locations[..2].iter().all(|l| l.uri.is_none())); + assert!(refs.locations[2].uri.is_some()); + // The server gives a library class's line along. + let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone()); + assert_eq!(line(&refs).as_deref(), Some(" target();")); + assert!(!refs.libraries_skipped); + let scopes: Vec<_> = + s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect(); + assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]); + // Without the libraries: the sources alone, widened, and no partial answer. + let partials = Mutex::new(Vec::new()); + let project = h + .mgr + .references_streaming( + doc, + Pos::new(0, 5), + true, + true, + false, + Default::default(), + Some(&|r| partials.lock().push(r)), + ) + .await + .unwrap(); + assert!(partials.into_inner().is_empty()); + assert!(project.libraries_skipped); + assert_eq!(project.locations.len(), 2); + assert!(project.locations.iter().all(|l| l.uri.is_none())); + let scopes: Vec<_> = + s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect(); + assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]); + // Scoped probes (code lenses) search everything at once. + let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 2); +} + #[tokio::test(flavor = "multi_thread")] async fn failed_java_command_falls_back_to_unclassified_native_references() { for project_wide in [false, true] { @@ -3222,6 +3500,11 @@ async fn rename_edits_files_outside_the_analysis_scope() { eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; } +/// tsserver project registrations sent through vtsls, without the `geterr` that follows the first. +fn project_requests(s: &MockState) -> Vec { + s.requested("workspace/executeCommand").into_iter().filter(|r| r["arguments"][0] != "geterr").collect() +} + #[tokio::test] async fn external_projects_are_capability_gated_and_follow_lifecycle() { for capable in [false, true] { @@ -3243,24 +3526,21 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() { assert!(s.requested("workspace/executeCommand").is_empty()); continue; } - let requests = s.requested("workspace/executeCommand"); + let requests = project_requests(&s); assert_eq!(requests[0]["arguments"][0], "openExternalProject"); assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2); assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents"); std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap(); h.mgr.project_files_changed([&h.root.join("new.ts")]); - eventually(|| s.requested("workspace/executeCommand").len() >= 2).await; - assert_eq!( - s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(), - 3 - ); + eventually(|| project_requests(&s).len() >= 2).await; + assert_eq!(project_requests(&s)[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 3); std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap(); h.mgr.project_files_changed([&h.root.join("renamed.ts")]); - eventually(|| s.requested("workspace/executeCommand").len() >= 3).await; - assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts")); + eventually(|| project_requests(&s).len() >= 3).await; + assert!(project_requests(&s)[2].to_string().contains("renamed.ts")); std::fs::remove_file(h.root.join("renamed.ts")).unwrap(); h.mgr.project_files_changed([&h.root.join("renamed.ts")]); - eventually(|| s.requested("workspace/executeCommand").len() >= 4).await; + eventually(|| project_requests(&s).len() >= 4).await; let id = h.mgr.servers()[0].id; h.mgr.restart(id).await.unwrap(); h.wait_status("arbitrary-name", ServerStatus::Ready).await; @@ -3286,6 +3566,55 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() { } } +#[tokio::test] +async fn external_projects_carry_plugin_files_and_module_aliases() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] }); + let h = harness_configured("vue", "vue", &[("vtsls", caps)], |cfg| { + cfg.tsserver_project = true; + cfg.tsserver_project_extras.extensions = vec!["vue".into()]; + cfg.tsserver_project_extras.paths.insert("@/*".into(), vec!["/project/src/*".into()]); + }); + let s = h.launcher.state("vtsls"); + s.respond("workspace/executeCommand", json!({ "success": true, "body": true })); + s.respond("textDocument/references", json!([])); + // vtsls answers from one file until its project reports diagnostics: loading till then, + // so an empty usage count is not final. + let diagnostics = Arc::new(Gate::default()); + let gate = diagnostics.clone(); + *s.responder.lock() = Some(Arc::new(move |_, method, params| { + if method == "workspace/executeCommand" && params["arguments"][0] == "geterr" { + gate.hold(); + return Some(Value::Null); + } + None + })); + std::fs::write(h.root.join("format.js"), "export const sum = 1;").unwrap(); + let (doc, app) = h.open("App.vue", ""); + h.wait_status("vtsls", ServerStatus::Busy).await; + eventually(|| diagnostics.entered()).await; + let geterr = s.requested("workspace/executeCommand").into_iter().find(|r| r["arguments"][0] == "geterr").unwrap(); + assert_eq!(geterr["arguments"][1]["files"], json!([app]), "an open file has a project in tsserver"); + assert_eq!(geterr["arguments"][2]["isAsync"], true); + let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap(); + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing)); + diagnostics.open(); + h.wait_status("vtsls", ServerStatus::Ready).await; + let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap(); + assert_eq!(refs.incomplete, None); + let requests = s.requested("workspace/executeCommand"); + let args = &requests[0]["arguments"][1]; + assert_eq!(requests[0]["arguments"][0], "openExternalProject"); + assert_eq!(args["rootFiles"].as_array().unwrap().len(), 2, "{args}"); + assert_eq!(args["options"]["paths"], json!({ "@/*": ["/project/src/*"] })); + std::fs::write(h.root.join("Cart.vue"), "").unwrap(); + h.mgr.project_files_changed([&h.root.join("Cart.vue")]); + eventually(|| project_requests(&s).len() >= 2).await; + assert!(project_requests(&s)[1].to_string().contains("Cart.vue")); + assert_eq!(s.requested("workspace/executeCommand").len(), 3, "geterr only after the first registration"); + h.mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] async fn gopls_interactive_command_validates_before_execution_and_applies_edits() { let mut caps = incremental_caps(); @@ -3321,7 +3650,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits( let reply: Value = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(s.peer().request( "workspace/applyEdit", - json!({"edit": edit}), + json!({ "edit": edit }), None, )) }) @@ -3606,6 +3935,54 @@ async fn configuration_extension_failure_preserves_native_java_navigation() { assert_eq!(s.requested("textDocument/definition").len(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let s = h.launcher.state("jdtls"); + h.mgr.prewarm_java_configuration(); + h.mgr.prewarm_java_configuration(); + h.wait_status("jdtls", ServerStatus::Ready).await; + assert_eq!(s.launches.load(Ordering::SeqCst), 1); + assert!(s.requested("workspace/executeCommand").is_empty()); + let config = h.root.join("application.yml"); + h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n"); + s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap(); + eventually(|| { + s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup") + }) + .await; + let requests = s.requested("workspace/executeCommand"); + let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap(); + assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]])); + assert!(s.notified("textDocument/didOpen").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() { + let mut fixtures = Vec::new(); + for mode in ["untrusted", "closed", "non-java"] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + if mode != "non-java" { + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + } + if mode == "untrusted" { + h.mgr.set_execution_allowed(false); + } + h.mgr.prewarm_java_configuration(); + if mode == "closed" { + h.mgr.shutdown_all().await; + } + fixtures.push(h); + } + tokio::time::sleep(Duration::from_millis(2200)).await; + for h in fixtures { + assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0); + } +} + /// The configuration command indexes the whole project, so Go to Definition (and /// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java /// symbols in a Spring file go straight to JDT. diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 589b74147..a2ad393c4 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -63,6 +63,55 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig { } } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"] +async fn kmp_lsp_java_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let path = root.join("Sample.java"); + let source = "class Sample {\n static String greet() { return \"Hello\"; }\n void run() { greet(); }\n}\n"; + std::fs::write(&path, source).unwrap(); + let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap(); + java.language_servers.retain(|s| s.name == "kmp-lsp"); + assert_eq!(java.language_servers.len(), 1); + if let Ok(bin) = std::env::var("OPENIDEA_TEST_KMP_LSP") { + java.language_servers[0].command = bin; + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source); + retry("kmp-lsp ready", Duration::from_secs(30), || { + let status = last_status(&events, "kmp-lsp"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("kmp-lsp failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let hover = retry("Java hover", Duration::from_secs(30), || async { + mgr.hover(doc, Pos::new(2, 18), Default::default()).await.ok().flatten() + }) + .await; + assert!(hover.contents.value.contains("greet"), "{}", hover.contents.value); + let defs = retry("Java definition", Duration::from_secs(30), || async { + mgr.goto(doc, Pos::new(2, 18), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) + }) + .await; + assert_eq!(defs[0].path, path); + assert_eq!(defs[0].range.start.line, 1); + retry("Java completion", Duration::from_secs(30), || async { + let list = mgr.completion(doc, Pos::new(2, 20), None, Default::default()).await.ok()?; + list.items.iter().any(|i| i.label.starts_with("greet")).then_some(()) + }) + .await; + let tokens = mgr.semantic_tokens(doc).await.unwrap().unwrap(); + assert!(!tokens.data.is_empty() && tokens.data.len() % 5 == 0); + let error = mgr.java_debug_server(&path).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("jdtls")); + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires jdtls on PATH and a compatible JDK"] async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() { @@ -135,6 +184,7 @@ async fn missing_binary_is_reported_with_install_hint() { max_java: None, launch_error: None, tsserver_project: false, + tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, }; @@ -405,6 +455,7 @@ async fn typescript_language_server_end_to_end() { max_java: None, launch_error: None, tsserver_project: false, + tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, }; @@ -455,6 +506,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option, + pub references: Vec, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub markers: Vec, +} + +/// The resolved gutter markers of one file. +#[derive(Clone, Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileMarkers { + pub path: PathBuf, + pub version: Option, + pub markers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NavigationResult { + pub range: Range, + pub targets: Vec, +} + +pub struct IndexSymbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index fd27cb249..dae692bd1 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -9,6 +9,7 @@ //! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread. mod actor; +pub mod indexing; mod runtime; mod sandbox; mod types; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index e7fe25558..c66014520 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -3,15 +3,21 @@ //! sandbox goes through [`HostApi`], implemented by the host; the imports //! themselves are in `wit_host`. -use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument}; use crate::types::{ - EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, + CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken, +}; +use crate::types::{ + EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent, }; use crate::types::{ - FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, + FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent, + ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, +}; +use crate::wit_host::{ + bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings, + views_bindings, }; -use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; use ide_core::CancellationToken; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; @@ -27,13 +33,17 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use bindings::openidea::plugin::events as wit_events; use bindings::openidea::plugin::types as wit_types; +use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index; use providers_bindings::exports::openidea::plugin::providers as wit_prov; +use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem; +use services_bindings::exports::openidea::plugin::service_provider as wit_svc; +use services_bindings::openidea::plugin::service_types as wit_st; use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; use views_bindings::exports::openidea::plugin::view_provider as wit_view; use views_bindings::openidea::plugin::view_types as wit_vt; /// The API version of the world above, `major.minor` of [`host_api`]. -pub const API_VERSION: &str = "1.1"; +pub const API_VERSION: &str = "1.3"; const EPOCH_TICK: Duration = Duration::from_millis(10); pub(crate) const MAX_TEXT: usize = 1 << 20; pub(crate) const MAX_TITLE: usize = 256; @@ -54,8 +64,32 @@ pub trait HostApi: Send + Sync { let _ = (client, url); Ok(()) } + /// Shows `prompt` in `client`'s window and waits for the answer until the + /// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the + /// text (`Input`, one element) or any value (`Confirm`: OK); `None` when + /// dismissed. + fn ask( + &self, + client: Option<&str>, + prompt: Prompt, + cancel: &CancellationToken, + ) -> Result>, HostError> { + let _ = (client, prompt, cancel); + Ok(None) + } + /// Brings the plugin's declared view to the front in `client`'s window. + fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> { + let _ = (client, view); + Ok(()) + } /// Sets (`Some`) or removes the plugin's status bar item `id`. fn status_item(&self, id: &str, item: Option); + /// Sets (`Some`, validated JSON text) or removes the plugin's context key, + /// for window `client` (every window if `None`). + fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { + let _ = (client, key, value); + Ok(()) + } /// Roots of the open projects. fn projects(&self) -> Vec; /// Unsaved text of an open editor's file. @@ -357,6 +391,8 @@ pub struct StoreData { pub(crate) granted: Vec, pub(crate) host: Arc, pub(crate) invocation: Invocation, + /// The running call is a command or an event, so it may ask the user. + pub(crate) interactive: bool, /// Tasks begun by the running call and not ended yet. pub(crate) open_tasks: Vec, pub(crate) last_report: Option, @@ -484,7 +520,10 @@ struct Exports { plugin: bindings::Plugin, syntax: Option, providers: Option, + indexer: Option, views: Option, + semantic: Option, + services: Option, } /// Most results one syntax call may return (more are dropped). @@ -536,6 +575,7 @@ impl Runtime { granted: setup.granted, host: setup.host, invocation: Invocation::default(), + interactive: false, open_tasks: Vec::new(), last_report: None, }; @@ -560,8 +600,11 @@ impl Runtime { let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok(); + let index_indices = wit_index::GuestIndices::new(&pre).ok(); let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); let view_indices = wit_view::GuestIndices::new(&pre).ok(); + let semantic_indices = wit_sem::GuestIndices::new(&pre).ok(); + let service_indices = wit_svc::GuestIndices::new(&pre).ok(); let instance = pre.instantiate(&mut store).map_err(api_hint)?; let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; let syntax = match syntax_indices { @@ -572,13 +615,25 @@ impl Runtime { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let indexer = match index_indices { + Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; let views = match view_indices { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let semantic = match semantic_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; + let services = match service_indices { + Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; Ok(Self { store, - exports: Exports { plugin, syntax, providers, views }, + exports: Exports { plugin, syntax, providers, views, indexer, semantic, services }, limits: setup.limits, version: setup.version, }) @@ -651,6 +706,47 @@ impl Runtime { self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) } + pub fn index_document( + &mut self, + path: &str, + text: &str, + symbols: &[crate::indexing::IndexSymbol], + invocation: Invocation, + ) -> Result { + if self.exports.indexer.is_none() { + return Err(CallError::missing_export("document-indexer")); + } + let doc = wit_index::Document { + path: path.into(), + text: text.into(), + symbols: symbols + .iter() + .map(|s| wit_index::Symbol { + name: s.name.clone(), + kind: s.kind.clone(), + container: s.container.clone(), + range: range_to_wit(s.range), + }) + .collect(), + }; + let edges = self.call(invocation, self.limits.timeout, |e, store| { + e.indexer.as_ref().expect("checked").call_index(store, &doc) + })?; + fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor { + crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) } + } + Ok(crate::indexing::Edges { + definitions: edges.definitions.into_iter().map(anchor).collect(), + references: edges.references.into_iter().map(anchor).collect(), + aliases: edges + .aliases + .into_iter() + .map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target }) + .collect(), + markers: edges.markers.into_iter().map(anchor).collect(), + }) + } + /// The component exports `providers`. pub fn has_providers(&self) -> bool { self.exports.providers.is_some() @@ -767,6 +863,34 @@ impl Runtime { .collect()) } + pub fn provide_semantic_tokens( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + if self.exports.semantic.is_none() { + return Err(CallError::missing_export("semantic-tokens-provider")); + } + let d = wit_sem::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let tokens = self.call(Invocation::default(), timeout, |e, store| { + e.semantic.as_ref().expect("checked").call_semantic_tokens(store, &d) + })?; + Ok(tokens + .into_iter() + .take(MAX_SEMANTIC_TOKENS) + .map(|t| SemanticToken { + range: range_from_wit(t.range), + kind: clip(t.kind, 32), + modifiers: t.modifiers.into_iter().take(MAX_TOKEN_MODIFIERS).map(|m| clip(m, 32)).collect(), + }) + .collect()) + } + pub fn activate(&mut self) -> Result<(), CallError> { let data = self.store.data(); let ctx = wit_types::Context { @@ -789,6 +913,7 @@ impl Runtime { ) -> Result, CallError> { let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let r = e.plugin.openidea_plugin_commands().call_run( &mut *store, id, @@ -831,6 +956,29 @@ impl Runtime { } } + /// The Services pane of node `key` of the plugin's services view `view`. + pub fn service_detail( + &mut self, + view: &str, + key: &str, + invocation: Invocation, + ) -> Result, CallError> { + if self.exports.services.is_none() { + return Err(CallError::missing_export("service-provider")); + } + let result = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let services = e.services.as_ref().expect("checked"); + let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep())); + store.data_mut().drop_invocation(ctx); + r + })?; + match result { + Ok(tabs) => Ok(tabs.into_iter().take(MAX_SERVICE_TABS).map(service_tab_from_wit).collect()), + Err(e) => Err(CallError::Guest(e.into())), + } + } + /// A user action in a view or custom editor. pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> { if self.exports.views.is_none() { @@ -839,6 +987,7 @@ impl Runtime { let event = event_to_wit_view(event); let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let views = e.views.as_ref().expect("checked"); let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep())); store.data_mut().drop_invocation(ctx); @@ -852,6 +1001,7 @@ impl Runtime { let events: Vec = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect(); self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; + store.data_mut().interactive = true; let r = e.plugin.openidea_plugin_event_handler().call_handle( &mut *store, &events, @@ -888,6 +1038,7 @@ impl Runtime { let result = f(&self.exports, &mut self.store); let data = self.store.data_mut(); data.invocation = Invocation::default(); + data.interactive = false; // Tasks don't outlive the call that began them. for id in std::mem::take(&mut data.open_tasks) { data.host.task_end(id); @@ -915,6 +1066,8 @@ impl Runtime { /// Most items one provider call may return (more are dropped). const MAX_PROVIDER_ITEMS: usize = 10_000; +const MAX_SEMANTIC_TOKENS: usize = 1 << 16; +const MAX_TOKEN_MODIFIERS: usize = 8; fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position { wit_types::Position { line: p.line, character: p.col } @@ -1023,6 +1176,40 @@ fn opt_clip(s: Option, max: usize) -> Option { s.map(|s| clip(s, max)) } +const MAX_SERVICE_TABS: usize = 8; +const MAX_LOG_TOGGLES: usize = 8; +const MAX_LOG_ARGS: usize = 256; +const MAX_LOG_ARG: usize = 4096; + +fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab { + let body = match t.body { + wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)), + wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)), + wit_st::Pane::Log(log) => { + let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG); + if args_fit(&log.args) + && log.toggles.len() <= MAX_LOG_TOGGLES + && log.toggles.iter().all(|t| args_fit(&t.args)) + { + ServicePane::Log(ServiceLog { + program: clip(log.program, 256), + args: log.args, + toggles: log + .toggles + .into_iter() + .map(|t| ServiceLogToggle { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), args: t.args }) + .collect(), + }) + } else { + ServicePane::Message(format!( + "This log asks for more than {MAX_LOG_ARGS} arguments of {MAX_LOG_ARG} bytes or {MAX_LOG_TOGGLES} toggles." + )) + } + } + }; + ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body } +} + fn view_from_wit(v: wit_vt::View) -> View { let body = match v.body { wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)), diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index b1d7b215d..cd101ab59 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -28,6 +28,8 @@ pub struct Invocation { pub client: Option, pub workspace_root: Option, pub editor: Option, + /// Files and folders the command was run on. + pub selected_paths: Vec, } /// A failed host call, as the plugin sees it (WIT `types.error`). @@ -66,6 +68,27 @@ pub struct StatusItem { pub command: Option, } +/// An entry of a plugin's `pick` or `pick-many`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PickItem { + pub key: String, + pub label: String, + pub description: Option, +} + +/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Prompt { + #[serde(rename_all = "camelCase")] + QuickPick { title: String, items: Vec, multiple: bool }, + #[serde(rename_all = "camelCase")] + Input { prompt: String, value: Option }, + #[serde(rename_all = "camelCase")] + Confirm { title: String, message: String }, +} + /// Kinds of workbench events a plugin can subscribe to (`on-event:`). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] #[serde(rename_all = "kebab-case")] @@ -396,6 +419,13 @@ pub struct LineMarker { pub target: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SemanticToken { + pub range: ide_core::Range, + pub kind: String, + pub modifiers: Vec, +} + // ---- declarative views (wit/views.wit) --------------------------------------- #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -475,6 +505,42 @@ pub struct View { pub status: Option, } +/// A tab of the Services pane of a plugin's node (`service-provider.detail`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceTab { + pub id: String, + pub label: String, + pub body: ServicePane, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub enum ServicePane { + Message(String), + /// Read-only text that can be selected and copied. + Text(String), + /// A program the IDE runs (with the plugin's `process:` grant), followed in a log. + Log(ServiceLog), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceLog { + pub program: String, + pub args: Vec, + pub toggles: Vec, +} + +/// A switch above the log; when on, `args` are appended and the program restarts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ServiceLogToggle { + pub id: String, + pub label: String, + pub args: Vec, +} + /// The table rows the UI shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index a1f7b7533..f2326035a 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -2,9 +2,11 @@ //! types and the implementation of every import. Capabilities are checked //! here; a missing one returns `error::denied` (the plugin can degrade). -use crate::runtime::{StoreData, clip}; +use crate::runtime::{MAX_TITLE, StoreData, clip}; use crate::sandbox; -use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host}; +use crate::types::{ + EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host, +}; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; use std::path::{Path, PathBuf}; @@ -133,6 +135,8 @@ const MAX_DIAGNOSTICS: usize = 5_000; const MAX_REQUEST_BODY: usize = 8 << 20; const MAX_FIND: u32 = 10_000; const MAX_STATUS_TEXT: usize = 128; +const MAX_PICK_ITEMS: usize = 5000; +const MAX_PICK_KEY: usize = 256; type Fallible = wasmtime::Result>; @@ -212,6 +216,52 @@ impl log::Host for StoreData { } } +impl StoreData { + /// Asks the user and waits; the wait doesn't count against the call's + /// wall-clock budget. + fn ask(&mut self, prompt: Prompt) -> Result>, wit::Error> { + self.require_interactive()?; + let client = self.invocation.client.clone(); + let started = Instant::now(); + let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token()); + let waited = started.elapsed(); + self.deadline += waited; + self.call_started += waited; + answer.map_err(to_wit) + } + + fn pick_prompt( + &mut self, + title: String, + items: Vec, + multiple: bool, + ) -> Result>, wit::Error> { + if items.len() > MAX_PICK_ITEMS { + return Err(wit::Error::Invalid(format!("more than {MAX_PICK_ITEMS} items"))); + } + if items.iter().any(|i| i.key.len() > MAX_PICK_KEY) { + return Err(wit::Error::Invalid(format!("an item key is longer than {MAX_PICK_KEY} bytes"))); + } + let items = items + .into_iter() + .map(|i| PickItem { + key: i.key, + label: clip(i.label, MAX_TITLE), + description: i.description.map(|d| clip(d, 1024)), + }) + .collect(); + self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple }) + } + + /// Dialogs belong to commands and events; rendering and providers have no one to ask. + fn require_interactive(&self) -> Result<(), wit::Error> { + if self.interactive { + return Ok(()); + } + Err(wit::Error::Invalid("can only ask the user from a command or event".into())) + } +} + impl ui::Host for StoreData { fn show_notification( &mut self, @@ -244,6 +294,36 @@ impl ui::Host for StoreData { Ok(()) } + fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> { + let valid_key = + (1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')); + if !valid_key { + return Ok(Err(wit::Error::Invalid(format!("context key {key:?}: expected 1-64 of A-Za-z0-9_-")))); + } + // Kept as JSON text, which the windows parse into their context. + let json = match value { + ui::ContextValue::Flag(b) => b.to_string(), + ui::ContextValue::Number(n) if n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(), + ui::ContextValue::Number(n) => match serde_json::Number::from_f64(n) { + Some(n) => n.to_string(), + None => return Ok(Err(wit::Error::Invalid("context value: not a finite number".into()))), + }, + ui::ContextValue::Text(t) if t.len() <= 1024 => serde_json::Value::String(t).to_string(), + ui::ContextValue::Text(_) => { + return Ok(Err(wit::Error::Invalid("context value: text longer than 1024 bytes".into()))); + } + }; + let client = self.invocation.client.clone(); + Ok(self.host.set_context(client.as_deref(), &key, Some(json)).map_err(to_wit)) + } + + fn remove_context(&mut self, key: String) -> wasmtime::Result<()> { + let client = self.invocation.client.clone(); + // Nothing to remove under an invalid key. + let _ = self.host.set_context(client.as_deref(), &key, None); + Ok(()) + } + fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> { if let Err(e) = self.require(Capability::Clipboard) { return Ok(Err(e)); @@ -261,6 +341,25 @@ impl ui::Host for StoreData { let client = self.invocation.client.clone(); Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit)) } + + fn pick(&mut self, title: String, items: Vec) -> Fallible> { + let answer = self.pick_prompt(title, items, false); + Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next()))) + } + + fn pick_many(&mut self, title: String, items: Vec) -> Fallible>> { + Ok(self.pick_prompt(title, items, true)) + } + + fn input(&mut self, prompt: String, value: Option) -> Fallible> { + let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) }; + Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next()))) + } + + fn confirm(&mut self, title: String, body: String) -> Fallible { + let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) }; + Ok(self.ask(prompt).map(|answer| answer.is_some())) + } } impl storage::Host for StoreData { @@ -419,6 +518,10 @@ impl invocation::HostInvocation for StoreData { } } + fn selected_paths(&mut self, _this: Resource) -> wasmtime::Result> { + Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect()) + } + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { // Owned by the host for the duration of the call. let _ = rep; @@ -649,6 +752,14 @@ impl views::Host for StoreData { Ok(()) } + fn reveal(&mut self, view: String) -> Fallible<()> { + if let Err(e) = self.require_interactive() { + return Ok(Err(e)); + } + let client = self.invocation.client.clone(); + Ok(self.host.reveal_view(client.as_deref(), &clip(view, 64)).map_err(to_wit)) + } + fn post_message(&mut self, view: String, message: String) -> Fallible<()> { if message.len() > MAX_VIEW_MESSAGE { return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes")))); @@ -696,3 +807,30 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {} /// Largest message between a plugin and its view's frame. const MAX_VIEW_MESSAGE: usize = 1 << 20; + +pub(crate) mod indexing_bindings { + wasmtime::component::bindgen!({ + world: "indexing", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} + +/// The optional `service-provider` export. +pub(crate) mod services_bindings { + wasmtime::component::bindgen!({ + world: "services", path: "../../wit", + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, + "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + }, + }); +} + +pub(crate) mod semantic_bindings { + wasmtime::component::bindgen!({ + world: "semantic-tokens", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 7ec2a34e2..1b33ccf35 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -23,9 +23,13 @@ fn repo() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } -/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture. +/// Official components plus fixtures for the base and optional indexing exports. fn components() -> Vec { - let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")]; + let mut out = vec![ + repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"), + ]; let official = repo().join("plugins/official"); let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect(); dirs.sort(); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 22be24d04..8baad7a88 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index a78a811bf..aaa350d53 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3 +670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450 diff --git a/crates/ide-plugin/tests/fixtures/hello/src/lib.rs b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs index 414c223e0..4f1253592 100644 --- a/crates/ide-plugin/tests/fixtures/hello/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/hello/src/lib.rs @@ -1,6 +1,7 @@ //! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK. //! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md). +use openidea_plugin::services::{Pane, ProcessLog, Services, Tab}; use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin}; use std::collections::BTreeSet; @@ -96,7 +97,15 @@ impl Views for Hello { } } -register_plugin!(Hello, views); +/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs. +impl Services for Hello { + fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result, Error> { + let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]); + Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))]) + } +} + +register_plugin!(Hello, views, services); /// Today's UTC date as `YYYY-MM-DD` (WASI wall clock). fn today() -> String { diff --git a/crates/ide-plugin/tests/fixtures/indexer/Cargo.lock b/crates/ide-plugin/tests/fixtures/indexer/Cargo.lock new file mode 100644 index 000000000..ce35c853c --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-indexer" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/indexer/Cargo.toml b/crates/ide-plugin/tests/fixtures/indexer/Cargo.toml new file mode 100644 index 000000000..03fb879fc --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-indexer" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/indexer/README.md b/crates/ide-plugin/tests/fixtures/indexer/README.md new file mode 100644 index 000000000..fa06eefa7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/README.md @@ -0,0 +1,10 @@ +# Document indexer test fixture + +A small line-oriented test language (`.idx`), unrelated to any framework: +`def key`, `ref key`, `alias prefix# target#` and `symbols prefix`. +References also produce gutter markers. `symbols` uses host-supplied UTF-16 +syntax ranges. `invalid key` deliberately returns an out-of-bounds range. + +Used by `cargo test -p ide-host --test plugin_indexing` to exercise the optional +WIT export, lifecycle invalidation, permissions, per-window isolation, candidate +selection and warm memory-only lookups. It is not a marketplace plugin. diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm new file mode 100644 index 000000000..473c8cfe1 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources new file mode 100644 index 000000000..14301c47f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -0,0 +1 @@ +c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae diff --git a/crates/ide-plugin/tests/fixtures/indexer/plugin.toml b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml new file mode 100644 index 000000000..a172e1422 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml @@ -0,0 +1,11 @@ +id = "test.indexer" +name = "Document indexer fixture" +version = "0.1.0" +engines.openidea = "^1.2" +wasm = "dist/plugin.wasm" + +[capabilities] +required = ["project.read"] + +[[contributes.documentIndexers]] +extensions = ["idx"] diff --git a/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs new file mode 100644 index 000000000..f5a1fdc9f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs @@ -0,0 +1,51 @@ +//! Deliberately tiny line-oriented fixture, not a language/framework plugin. +use openidea_plugin::indexing::{Alias, Anchor, Document, DocumentIndexer, Edges}; +use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin}; + +#[derive(Default)] +struct Indexer; +impl Plugin for Indexer { + fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result, Error> { + Err(Error::NotFound(id.into())) + } +} +impl DocumentIndexer for Indexer { + fn index(&mut self, doc: &Document) -> Edges { + let mut out = Edges::default(); + for (line, text) in doc.text.lines().enumerate() { + let Some((kind, value)) = text.split_once(' ') else { continue }; + let range = Range { + start: Position { line: line as u32, character: kind.len() as u32 + 1 }, + end: Position { line: line as u32, character: text.encode_utf16().count() as u32 }, + }; + let anchor = Anchor { key: value.into(), range }; + match kind { + "def" => out.definitions.push(anchor), + "ref" => { + out.references.push(anchor.clone()); + out.markers.push(anchor); + } + "alias" => { + if let Some((prefix, target)) = value.split_once(' ') { + out.aliases.push(Alias { prefix: prefix.into(), target: target.into() }); + } + } + "symbols" => out.definitions.extend( + doc.symbols + .iter() + .map(|symbol| Anchor { key: format!("{value}{}", symbol.name), range: symbol.range }), + ), + "invalid" => out.references.push(Anchor { + key: value.into(), + range: Range { + start: Position { line: 999, character: 0 }, + end: Position { line: 999, character: 1 }, + }, + }), + _ => {} + } + } + out + } +} +register_plugin!(Indexer, indexing); diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 6cc5e751a..7c3273700 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 30532e592..cb880be2a 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3 +911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 7453c9ec5..6da670a06 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml @@ -25,6 +25,16 @@ id = "follow" title = "Jobs: Follow Selected Job" category = "Jobs" +[[contributes.commands]] +id = "context" +title = "Jobs: Set Context" +category = "Jobs" + +[[contributes.commands]] +id = "selection" +title = "Jobs: Show Selected Paths" +category = "Jobs" + [contributes.settings] title = "Jobs" schema = "settings.schema.json" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index 131ef2cce..939ba0aee 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -4,7 +4,7 @@ use openidea_plugin::host; use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views}; -use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; +use openidea_plugin::{Error, Invocation, NotificationLevel, PickItem, Plugin, register_plugin}; pub const VIEW: &str = "jobs"; @@ -40,6 +40,19 @@ impl Plugin for Jobs { let remotes = project.vcs_remotes()?; Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::>().join("\n"))) } + "context" => { + // `key=` removes; `true` / `false`, numbers and other text set. + let (key, value) = arg.unwrap_or_default().split_once('=').ok_or("expected key=value")?; + match value { + "" => host::remove_context(key), + "true" | "false" => host::set_context(key, value == "true")?, + _ => match value.parse::() { + Ok(n) => host::set_context(key, n)?, + Err(_) => host::set_context(key, value)?, + }, + } + Ok(None) + } "copy" => { host::copy_to_clipboard(arg.unwrap_or_default())?; Ok(None) @@ -48,6 +61,27 @@ impl Plugin for Jobs { host::open_url(arg.unwrap_or_default())?; Ok(None) } + "pick" | "pick-many" => { + let long_key = "k".repeat(300); + let items = [ + PickItem::new(if arg == Some("long-key") { &long_key } else { "a" }, "Alpha").description("first"), + PickItem::new("b", "Beta"), + PickItem::new("c", "Gamma"), + ]; + let picked = match id { + "pick" => host::pick("Pick", &items)?.map(|key| vec![key]), + _ => host::pick_many("Pick", &items)?, + }; + Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(",")))) + } + "input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))), + "confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())), + "reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None), + "selection" => { + let paths = ctx.selected_paths().join("\n"); + host::notify(NotificationLevel::Info, "Selected paths", &paths); + Ok(Some(paths)) + } _ => Err(Error::NotFound(format!("command {id}"))), } } diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 70f536a98..f7e985dc9 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index d08b0b0e8..f968ef132 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f +a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 35be7a7a0..491816f4c 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -5,8 +5,8 @@ wit_bindgen::generate!({ inline: " package fixture:misbehave; world misbehave { - include openidea:plugin/plugin-syntax@1.1.0; - export openidea:plugin/providers@1.1.0; + include openidea:plugin/plugin-syntax@1.3.0; + export openidea:plugin/providers@1.3.0; } ", path: "../../../../../wit", diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.lock b/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.lock new file mode 100644 index 000000000..3a47de575 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-semantic-tokens" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.toml b/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.toml new file mode 100644 index 000000000..43251d9f8 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-semantic-tokens" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/README.md b/crates/ide-plugin/tests/fixtures/semantic-tokens/README.md new file mode 100644 index 000000000..d8e12d8f6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/README.md @@ -0,0 +1,7 @@ +# Semantic tokens test fixture + +Colours the name after every `->` in PHP documents that is not called: `method` +when the document declares `function (`, `property` otherwise. + +Used by `cargo test -p ide-host --test plugin_semantic_tokens` to exercise the +optional `semantic-tokens-provider` export. It is not a marketplace plugin. diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm new file mode 100644 index 000000000..cfedbe513 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources new file mode 100644 index 000000000..cadc9c206 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -0,0 +1 @@ +9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/plugin.toml b/crates/ide-plugin/tests/fixtures/semantic-tokens/plugin.toml new file mode 100644 index 000000000..a46e6d161 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/plugin.toml @@ -0,0 +1,9 @@ +id = "test.semantic-tokens" +name = "Semantic tokens fixture" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" + +[[contributes.providers]] +kinds = ["semantic-tokens"] +languages = ["php"] diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/src/lib.rs b/crates/ide-plugin/tests/fixtures/semantic-tokens/src/lib.rs new file mode 100644 index 000000000..daeeb6266 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/src/lib.rs @@ -0,0 +1,37 @@ +use openidea_plugin::semantic_tokens::{Document, SemanticToken, SemanticTokens, TokenKind}; +use openidea_plugin::{Error, Invocation, Plugin, Range, register_plugin, text}; + +#[derive(Default)] +struct Fixture; + +impl Plugin for Fixture { + fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result, Error> { + Err(Error::NotFound(id.into())) + } +} + +impl SemanticTokens for Fixture { + fn semantic_tokens(&mut self, doc: &Document) -> Vec { + let text = doc.text.as_str(); + let mut out = Vec::new(); + let mut rest = 0; + while let Some(i) = text[rest..].find("->") { + let start = rest + i + 2; + let end = text[start..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .map_or(text.len(), |n| start + n); + rest = end; + if end == start || text[end..].starts_with('(') { + continue; + } + let name = &text[start..end]; + let kind = + if text.contains(&format!("function {name}(")) { TokenKind::Method } else { TokenKind::Property }; + let range = Range { start: text::position(text, start), end: text::position(text, end) }; + out.push(SemanticToken::new(range, kind)); + } + out + } +} + +register_plugin!(Fixture, semantic_tokens); diff --git a/crates/ide-remote/Cargo.toml b/crates/ide-remote/Cargo.toml index 8442d6fe7..eb9828c7d 100644 --- a/crates/ide-remote/Cargo.toml +++ b/crates/ide-remote/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ide-remote" -description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets" +description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)" version.workspace = true edition.workspace = true license.workspace = true @@ -10,6 +10,7 @@ ide-core.workspace = true ide-ssh.workspace = true ide-run.workspace = true serde.workspace = true +serde_json.workspace = true specta.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/ide-remote/src/docker.rs b/crates/ide-remote/src/docker.rs new file mode 100644 index 000000000..f59dc8cd0 --- /dev/null +++ b/crates/ide-remote/src/docker.rs @@ -0,0 +1,350 @@ +//! Run configurations on Docker and Compose run targets: rewritten into a local +//! `docker run --rm` / `docker compose run --rm` of the configuration's program, +//! with the project bind-mounted and its paths and interpreters swapped for the +//! container's. The console, stdin, Stop and test parsers then work as for any +//! local run. + +use crate::target::remote_command; +use ide_core::remote_settings::{RunTarget, RunTargetKind}; +use ide_core::{IdeError, IdeResult}; +use ide_run::RunConfig; +use std::collections::BTreeMap; +use std::path::Path; + +/// What the rewrite needs besides the configuration and its target. +pub struct ContainerRun<'a> { + pub target: &'a RunTarget, + /// The project on this machine. + pub root: &'a Path, + /// Where the project is in the container. + pub container_root: &'a str, + /// The docker executable. + pub docker: &'a str, + /// The connection's variables for the docker CLI (`ide_docker::run_env`). + pub endpoint_env: &'a BTreeMap, + /// Variables of the configuration's `.env` files, already read. + pub file_env: &'a BTreeMap, + /// Container name, unique per run. + pub name: &'a str, + /// `--user uid:gid`; `None` keeps the image's user. + pub user: Option<(u32, u32)>, +} + +/// The container folder of the project when a Docker target sets none. +pub fn default_container_root(root: &Path) -> String { + let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name: String = + name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect(); + format!("/workspaces/{}", if name.is_empty() { "project" } else { &name }) +} + +/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`. +pub fn container_name(config_id: &str, unique: &str) -> String { + let id: String = config_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' }) + .take(40) + .collect(); + format!("openidea-{}-{unique}", id.trim_matches('-')) +} + +/// `config` as a local run of the docker CLI that runs it in a new container. +pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult { + let target = run.target; + let command = remote_command(config, target, run.root, run.container_root); + // Variables go by name (`-e NAME`) so their values stay out of the command line + // echoed in the console; the docker CLI reads them from its own environment. + let mut env: BTreeMap = run.file_env.clone(); + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") + && !command.env.contains_key("PYTHONUNBUFFERED") + { + env.insert("PYTHONUNBUFFERED".into(), "1".into()); + } + env.extend(command.env.clone()); + let pty = config.extras.emulate_terminal; + let mut args: Vec = Vec::new(); + match target.kind { + RunTargetKind::Docker => { + if target.image.trim().is_empty() { + return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name))); + } + args.extend(["run", "--rm", "--interactive", "--init"].map(String::from)); + if pty { + args.push("--tty".into()); + } + let root = run.root.to_string_lossy(); + args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]); + } + RunTargetKind::Compose => { + if target.service.trim().is_empty() { + return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name))); + } + args.push("compose".into()); + for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) { + args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]); + } + args.extend(["run", "--rm"].map(String::from)); + if !pty { + args.push("--no-TTY".into()); + } + } + RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")), + } + args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]); + args.extend(["--workdir".into(), command.cwd.clone()]); + let mut docker_env = run.endpoint_env.clone(); + for (key, value) in &env { + if run.endpoint_env.contains_key(key) { + // `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's. + args.extend(["--env".into(), format!("{key}={value}")]); + } else { + args.extend(["--env".into(), key.clone()]); + docker_env.insert(key.clone(), value.clone()); + } + } + for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) { + args.extend(["--publish".into(), port.to_owned()]); + } + if let Some((uid, gid)) = run.user { + args.extend(["--user".into(), format!("{uid}:{gid}")]); + } + let options = target.run_options.trim(); + if !options.is_empty() { + args.extend(ide_run::import::split_args(options)); + } + args.push(match target.kind { + RunTargetKind::Compose => target.service.trim().to_owned(), + _ => target.image.trim().to_owned(), + }); + match command.shell_line { + Some(line) => args.extend(["sh".into(), "-c".into(), line]), + None => { + args.push(command.program); + args.extend(command.args); + } + } + let mut out = config.clone(); + out.command = run.docker.to_owned(); + out.args = args; + out.cwd = Some(run.root.to_path_buf().into()); + out.env = docker_env; + let extras = &mut out.extras; + extras.target = None; + extras.env_files.clear(); + // The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`. + extras.include_system_env = true; + // `docker stop` signals the container's process (the CLI may not forward a signal). + extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name)); + Ok(out) +} + +/// `word` for this machine's shell (the stop command runs there): the docker CLI found +/// outside `PATH` (Docker.app's bundle) may contain spaces. +fn shell_word(word: &str) -> String { + if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() { + word.to_owned() + } else if cfg!(windows) { + format!("\"{word}\"") + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// The project's folder in the container of a Compose `service`, from the JSON of +/// `docker compose config --format json`: the target of a bind mount of the project +/// or a folder above it. +pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult { + let model: serde_json::Value = serde_json::from_str(config_json) + .map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?; + let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else { + return Err(IdeError::not_found(format!("the Compose files have no service '{service}'"))); + }; + let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten(); + let mut best: Option<(usize, String)> = None; + for m in mounts { + if m.get("type").and_then(|t| t.as_str()) != Some("bind") { + continue; + } + let (Some(source), Some(target)) = + (m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str())) + else { + continue; + }; + let Ok(rel) = root.strip_prefix(source) else { continue }; + let depth = Path::new(source).components().count(); + if best.as_ref().is_none_or(|(d, _)| depth > *d) { + let rel = crate::mapping::posix(rel); + let target = target.trim_end_matches('/'); + let folder = match (target.is_empty(), rel.is_empty()) { + (true, true) => "/".to_owned(), + (_, true) => target.to_owned(), + _ => format!("{target}/{rel}"), + }; + best = Some((depth, folder)); + } + } + best.map(|(_, folder)| folder).ok_or_else(|| { + IdeError::invalid(format!( + "service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::path::PathBuf; + + #[cfg(unix)] + fn config(command: &str, args: &[&str]) -> RunConfig { + RunConfig { + id: "app".into(), + name: "app".into(), + kind: "python".into(), + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: None, + env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + } + } + + #[cfg(unix)] + fn run<'a>( + target: &'a RunTarget, + endpoint: &'a BTreeMap, + files: &'a BTreeMap, + ) -> ContainerRun<'a> { + ContainerRun { + target, + root: Path::new("/work/proj"), + container_root: "/workspaces/proj", + docker: "/usr/local/bin/docker", + endpoint_env: endpoint, + file_env: files, + name: "openidea-app-1", + user: Some((501, 20)), + } + } + + // Unix paths are not absolute on Windows. + #[cfg(unix)] + #[test] + fn docker_target_runs_the_program_in_a_new_container() { + let target = RunTarget { + kind: RunTargetKind::Docker, + name: "py".into(), + image: "python:3.12".into(), + ports: vec!["8000:8000".into(), " ".into()], + run_options: r#"--network host --label "a b""#.into(), + ..Default::default() + }; + let endpoint = BTreeMap::from([ + ("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()), + ("PATH".to_owned(), "/usr/local/bin".to_owned()), + ]); + let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]); + let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]); + c.env.insert("PATH".into(), "/opt/bin".into()); + c.extras.target = Some("t".into()); + c.extras.env_files = vec![".env".into()]; + let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap(); + assert_eq!(out.command, "/usr/local/bin/docker"); + assert_eq!( + out.args.join(" "), + "run --rm --interactive --init --volume /work/proj:/workspaces/proj \ + --name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \ + --env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \ + --publish 8000:8000 --user 501:20 --network host --label a b \ + python:3.12 python3 -u /workspaces/proj/main.py" + ); + assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped"); + assert_eq!(out.env["SECRET"], "s3"); + assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH"); + assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack"); + assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj"))); + assert_eq!(out.extras.target, None); + assert!(out.extras.env_files.is_empty() && out.extras.include_system_env); + assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1")); + assert_eq!(out.kind, "python", "test and output parsers still apply"); + + let mut c = config("npm run dev", &[]); + c.kind = "npm".into(); + c.extras.emulate_terminal = true; + let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() }; + let mut r = run(&target, &endpoint, &files); + r.user = None; + let out = container_config(&c, &r).unwrap(); + assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from))); + assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string())); + assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from))); + + let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() }; + assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image")); + } + + #[cfg(unix)] + #[test] + fn compose_target_runs_in_a_new_container_of_the_service() { + let target = RunTarget { + kind: RunTargetKind::Compose, + compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()], + service: "web".into(), + ..Default::default() + }; + let none = BTreeMap::new(); + let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]); + c.kind = "django-test".into(); + c.cwd = Some(PathBuf::from("/work/proj/backend").into()); + let mut r = run(&target, &none, &none); + r.container_root = "/app"; + let out = container_config(&c, &r).unwrap(); + assert_eq!( + out.args.join(" "), + "compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \ + --name openidea-app-1 --label openidea.run=app --workdir /app/backend \ + --env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test" + ); + assert_eq!(out.env["DATA"], "/app/data"); + + let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() }; + assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service")); + } + + #[cfg(unix)] + #[test] + fn compose_container_root_follows_the_project_bind_mount() { + let json = r#"{"services": { + "web": {"volumes": [ + {"type": "volume", "source": "data", "target": "/data"}, + {"type": "bind", "source": "/work", "target": "/src"}, + {"type": "bind", "source": "/work/proj", "target": "/app/"} + ]}, + "worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]}, + "db": {} + }}"#; + let root = Path::new("/work/proj"); + assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins"); + assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj"); + assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount")); + assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound); + } + + #[test] + fn names_and_default_folder() { + assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x"); + assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj"); + } + + #[cfg(unix)] + #[test] + fn stop_quotes_the_docker_cli_for_the_shell() { + assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker"); + assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'"); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 378aa5ccd..e39e0b2f7 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -1,7 +1,8 @@ //! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run -//! configurations on run targets ([`target`]). +//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]). pub mod deploy; +pub mod docker; pub mod import; pub mod mapping; pub mod service; diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 9b383611a..7e5d9a4d0 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -338,6 +338,9 @@ impl RemoteService { ) -> IdeResult { let target_id = config.extras.target.as_deref().unwrap_or_default(); let target = run_target(settings, target_id)?; + if target.kind != ide_core::remote_settings::RunTargetKind::Ssh { + return Err(IdeError::internal("container run targets start as local runs of the docker CLI")); + } let session = self.ssh.session(&target.ssh_config).await?; let fs = RemoteFs::new(session.sftp().await?); let folder = if target.project_root.trim().is_empty() { diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index b7bc88027..101351ddd 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -27,18 +27,51 @@ pub(crate) fn detect_with_profiles( ) -> Vec { let mut out = Vec::new(); detect_node(root, &mut out); - detect_deno(root, &mut out); - detect_cargo(root, &mut out); + detect_projects(root, &["deno.json", "deno.jsonc"], detect_deno, &mut out); + detect_projects(root, &["Cargo.toml"], detect_cargo, &mut out); detect_gradle(root, &mut out); detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); - detect_python(root, &mut out); - detect_php(root, &mut out); - detect_ruby(root, &mut out); - cmake::detect(root, profiles, &mut out); + detect_projects_matching( + root, + |dir| { + ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"] + .iter() + .any(|name| dir.join(name).is_file()) + || [".venv", "venv"] + .iter() + .any(|name| dir.join(name).join(PyEnv::bin_dir()).join(PyEnv::python_exe()).is_file()) + }, + detect_python, + &mut out, + ); + detect_projects( + root, + &["composer.json", "phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"], + detect_php, + &mut out, + ); + detect_projects_matching(root, ruby_project, detect_ruby, &mut out); + for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) { + let start = out.len(); + cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out); + if dir != root { + let scope = Scope::new(root, &dir); + for config in &mut out[start..] { + config.id = scope.id(&config.id); + config.name = scope.name(&config.name); + for task in &mut config.extras.before_launch { + if task.kind == "run" { + task.target = scope.id(&task.target); + } + } + crate::scope::rebase(config, &dir, Some(root)); + } + } + } dotnet::detect(root, &mut out); - detect_make(root, &mut out); - detect_just(root, &mut out); + detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out); + detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out); detect_godot(root, &mut out); crate::external::detect(root, &mut out); @@ -47,6 +80,80 @@ pub(crate) fn detect_with_profiles( out } +fn detect_projects( + root: &Path, + markers: &[&str], + detect: impl Fn(&Path, &mut Vec), + out: &mut Vec, +) { + detect_projects_matching( + root, + |dir| { + markers.iter().any(|marker| dir.join(marker).is_file()) + && (markers != ["Cargo.toml"] || !cargo_workspace_owns(root, dir)) + }, + detect, + out, + ); +} + +fn detect_projects_matching( + root: &Path, + marked: impl Fn(&Path) -> bool, + detect: impl Fn(&Path, &mut Vec), + out: &mut Vec, +) { + let mut covered = HashSet::new(); + for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, marked) { + if covered.contains(&dir) { + continue; + } + let mut configs = Vec::new(); + detect(&dir, &mut configs); + for config in &configs { + if let Some(module) = &config.extras.module { + covered.insert(ide_core::project_roots::resolve(&dir, Path::new(module))); + } + } + if dir != root { + let scope = Scope::new(root, &dir); + for config in &mut configs { + config.id = scope.id(&config.id); + config.name = scope.name(&config.name); + for task in &mut config.extras.before_launch { + if task.kind == "run" { + task.target = scope.id(&task.target); + } + } + crate::scope::rebase(config, &dir, Some(root)); + } + } + out.extend(configs); + } +} + +fn cargo_workspace_owns(root: &Path, dir: &Path) -> bool { + let Some(manifest) = read_toml(&dir.join("Cargo.toml")) else { return false }; + if manifest.contains_key("workspace") { + return false; + } + dir.ancestors() + .skip(1) + .take_while(|parent| parent.starts_with(root)) + .find_map(|parent| { + let manifest = read_toml(&parent.join("Cargo.toml"))?; + let workspace = manifest.get("workspace")?; + let excluded = string_list(workspace.get("exclude")); + Some( + !excluded.iter().any(|excluded| dir.starts_with(parent.join(excluded))) + && !expand_members(parent, &excluded, "Cargo.toml") + .iter() + .any(|excluded| dir.starts_with(excluded)), + ) + }) + .unwrap_or(false) +} + fn config(id: impl Into, name: impl Into, kind: &str, command: &str, args: &[&str]) -> RunConfig { RunConfig { id: id.into(), @@ -103,19 +210,7 @@ fn first_existing(root: &Path, names: &[&str]) -> Option { /// Sorted immediate subdirectories, skipping hidden and vendored ones. fn subdirs(dir: &Path) -> Vec { - let Ok(entries) = fs::read_dir(dir) else { return Vec::new() }; - let mut dirs: Vec = entries - .filter_map(Result::ok) - .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter(|e| { - let name = e.file_name(); - let name = name.to_string_lossy(); - !name.starts_with('.') && !matches!(name.as_ref(), "node_modules" | "target" | "build" | "dist" | "vendor") - }) - .map(|e| e.path()) - .collect(); - dirs.sort(); - dirs + ide_core::project_roots::children(dir) } /// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`. @@ -151,25 +246,8 @@ fn relative(root: &Path, dir: &Path) -> PathBuf { // ---------------------------------------------------------------- node -pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str { - if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) { - match pm.split('@').next().unwrap_or_default() { - "pnpm" => return "pnpm", - "yarn" => return "yarn", - "bun" => return "bun", - "npm" => return "npm", - _ => {} - } - } - if root.join("pnpm-lock.yaml").is_file() { - "pnpm" - } else if root.join("yarn.lock").is_file() { - "yarn" - } else if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() { - "bun" - } else { - "npm" - } +pub(crate) fn package_manager(root: &Path, _pkg: &Json) -> &'static str { + ide_core::project_roots::package_manager(root) } pub(crate) fn is_test_script(name: &str) -> bool { @@ -186,6 +264,7 @@ fn push_scripts(pkg: &Json, pm: &str, prefix: Option<&str>, cwd: Option None => (format!("npm:{script}"), format!("{pm} run {script}")), }; let mut c = config(id, name, "npm", pm, &["run", script]).in_dir(cwd.clone()).debug("js-debug"); + c.extras.module = cwd.as_deref().map(ide_core::ipc_path::to_ipc); if is_test_script(script) { c = c.test(); } @@ -221,27 +300,34 @@ fn node_workspace_patterns(root: &Path, pkg: &Json) -> Vec { } fn detect_node(root: &Path, out: &mut Vec) { - let Some(pkg) = read_json(&root.join("package.json")) else { - // A polyglot repository: front-end packages one level down (`web/`, `frontend/`). - for dir in subdirs(root) { - let Some(member) = read_json(&dir.join("package.json")) else { continue }; - let rel = relative(root, &dir); - let label = rel.to_string_lossy().replace('\\', "/"); - push_scripts(&member, package_manager(&dir, &member), Some(&label), Some(rel), out); + let mut covered = HashSet::new(); + let projects = + ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| { + dir.join("package.json").is_file() + }); + for dir in projects { + if !covered.insert(dir.clone()) { + continue; } - return; - }; - let pm = package_manager(root, &pkg); - push_scripts(&pkg, pm, None, None, out); - for dir in expand_members(root, &node_workspace_patterns(root, &pkg), "package.json") { - let Some(member) = read_json(&dir.join("package.json")) else { continue }; + let Some(pkg) = read_json(&dir.join("package.json")) else { continue }; + let pm = package_manager(&dir, &pkg); let rel = relative(root, &dir); - let label = member - .get("name") - .and_then(Json::as_str) - .map(str::to_owned) - .unwrap_or_else(|| rel.to_string_lossy().replace('\\', "/")); - push_scripts(&member, pm, Some(&label), Some(rel), out); + let label = ide_core::ipc_path::to_ipc(&rel); + push_scripts(&pkg, pm, (dir != root).then_some(label.as_str()), (dir != root).then_some(rel), out); + for member_dir in expand_members(&dir, &node_workspace_patterns(&dir, &pkg), "package.json") { + if !covered.insert(member_dir.clone()) { + continue; + } + let Some(member) = read_json(&member_dir.join("package.json")) else { continue }; + let rel = relative(root, &member_dir); + let name = member + .get("name") + .and_then(Json::as_str) + .map(str::to_owned) + .unwrap_or_else(|| ide_core::ipc_path::to_ipc(&rel)); + let name = if dir == root { name } else { format!("{label}:{name}") }; + push_scripts(&member, pm, Some(&name), Some(rel), out); + } } } @@ -355,12 +441,31 @@ fn detect_cargo(root: &Path, out: &mut Vec) { } else { format!("cargo:run:{pkg}:{bin}") }; - out.push(config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb")); + let mut run = config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb"); + if dir != root { + run.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir))); + } + out.push(run); } } if packages.is_empty() && !is_workspace { return; } + if is_workspace { + for (pkg, dir, _) in &packages { + let mut test = config( + format!("cargo:test:{pkg}"), + format!("cargo test {pkg}"), + "cargo", + "cargo", + &["test", "-p", pkg], + ) + .test() + .debug("codelldb"); + test.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir))); + out.push(test); + } + } let ws_flag: &[&str] = if is_workspace { &["--workspace"] } else { &[] }; let test_args: Vec<&str> = ["test"].iter().chain(ws_flag).copied().collect(); let build_args: Vec<&str> = ["build"].iter().chain(ws_flag).copied().collect(); @@ -410,32 +515,8 @@ fn applies_application_plugin(text: &str) -> bool { }) } -/// How deep a build is looked for when the project root has none (a folder holding -/// several repositories: `backend/pom.xml`, `apps/server/settings.gradle`). -const NESTED_BUILD_DEPTH: usize = 3; - -/// The builds marked by any of `markers`: the root itself, else the shallowest marked -/// folder of each branch, up to [`NESTED_BUILD_DEPTH`] levels down. fn build_dirs(root: &Path, markers: &[&str]) -> Vec { - let marked = |d: &Path| markers.iter().any(|m| d.join(m).is_file()); - if marked(root) { - return vec![root.to_path_buf()]; - } - let mut out = Vec::new(); - let mut level = vec![root.to_path_buf()]; - for _ in 0..NESTED_BUILD_DEPTH { - let mut next = Vec::new(); - for sub in level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src") && !d.ends_with("out")) { - if marked(&sub) { - out.push(sub); - } else { - next.push(sub); - } - } - level = next; - } - out.sort(); - out + ide_core::project_roots::discover(root, markers) } /// Ids, names and working directory of the configurations of a build nested in @@ -546,7 +627,7 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec) { let mut budget = SPRING_SCAN_FILES; let mut modules = vec![build.to_path_buf()]; let mut level = vec![build.to_path_buf()]; - for _ in 0..NESTED_BUILD_DEPTH { + for _ in 0..ide_core::project_roots::SEARCH_DEPTH { level = level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src")).collect(); modules.extend(level.iter().cloned()); } @@ -708,16 +789,11 @@ fn detect_go(root: &Path, out: &mut Vec) { } return; } - if root.join("go.mod").is_file() { - detect_go_module(root, None, out); - return; - } - // A polyglot repository: Go modules one level down (`api/go.mod`). - for dir in subdirs(root) { - if dir.join("go.mod").is_file() { - let rel = relative(root, &dir); - detect_go_module(&dir, Some(rel), out); - } + for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| { + dir.join("go.mod").is_file() + }) { + let rel = relative(root, &dir); + detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out); } } @@ -759,6 +835,9 @@ enum PyEnv { Runner(&'static str), /// A project-local virtualenv directory (relative to root). Venv(String), + /// A pixi environment directory `.pixi/envs/` (relative to root); tools + /// also run as `pixi run …`, the interpreter directly for the debugger. + Pixi(String), System, } @@ -778,9 +857,25 @@ impl PyEnv { return Self::Venv(venv.to_owned()); } } + if let Some(env) = Self::pixi_env(root) { + return Self::Pixi(env); + } Self::System } + /// The first pixi environment `.pixi/envs/` holding a Python, relative + /// to the root, in name order. + fn pixi_env(root: &Path) -> Option { + let envs = root.join(".pixi/envs"); + let mut dirs: Vec<_> = + std::fs::read_dir(&envs).into_iter().flatten().flatten().map(|e| e.path()).filter(|d| d.is_dir()).collect(); + dirs.sort_by_key(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + dirs.iter() + .find(|d| d.join(Self::python_exe()).is_file() || d.join("bin").join(Self::python_exe()).is_file()) + .and_then(|d| d.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + } + fn bin_dir() -> &'static str { if cfg!(windows) { "Scripts" } else { "bin" } } @@ -804,6 +899,18 @@ impl PyEnv { } } Self::System => (tool.to_owned(), Vec::new()), + // A pixi env's executables sit in `bin` (Unix) or `Scripts` (Windows) + // like a venv's; else `pixi run` finds the tool. + Self::Pixi(v) => { + let exe = if cfg!(windows) { format!("{tool}.exe") } else { tool.to_owned() }; + for bin in ["Scripts", "bin"] { + let rel = format!("{v}/{bin}/{exe}"); + if root.join(&rel).is_file() { + return (rel, Vec::new()); + } + } + ("pixi".to_owned(), vec!["run".into(), tool.into()]) + } } } @@ -818,9 +925,19 @@ impl PyEnv { match self { Self::Runner(r) => ((*r).to_owned(), vec!["run".into(), "python".into()]), Self::Venv(v) => (format!("{v}/{}/{}", Self::bin_dir(), Self::python_exe()), Vec::new()), + Self::Pixi(v) => (Self::pixi_python(v), Vec::new()), Self::System => ((if cfg!(windows) { "python" } else { "python3" }).to_owned(), Vec::new()), } } + + /// `/python.exe` (Windows) or `/bin/python` of a pixi environment. + fn pixi_python(env: &str) -> String { + if cfg!(windows) { + format!("{env}/{}", Self::python_exe()) + } else { + format!("{env}/bin/{}", Self::python_exe()) + } + } } /// The interpreter an imported debugger configuration runs with, as `(command, leading args)`: @@ -833,6 +950,9 @@ pub(crate) fn project_python(root: &Path) -> (String, Vec) { return (rel, Vec::new()); } } + if let Some(env) = PyEnv::pixi_env(root) { + return (PyEnv::pixi_python(&env), Vec::new()); + } PyEnv::detect(root).python() } @@ -867,8 +987,9 @@ fn uses_pytest(root: &Path) -> bool { fn detect_python(root: &Path, out: &mut Vec) { let markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"]; let env = PyEnv::detect(root); - // A project virtualenv marks a Python project too (scripts + tests without packaging files). - let venv = matches!(env, PyEnv::Venv(_)); + // A project virtualenv or pixi environment marks a Python project too + // (scripts + tests without packaging files). + let venv = matches!(env, PyEnv::Venv(_) | PyEnv::Pixi(_)); if !venv && !markers.iter().any(|m| root.join(m).is_file()) { return; } @@ -1059,20 +1180,28 @@ fn ruby_config( c } +/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test` +/// does; options such as `-v` stay in `ARGV` for Minitest. +const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#; + fn uses_rspec(root: &Path) -> bool { root.join("spec").is_dir() || root.join(".rspec").is_file() || read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec")) } -fn detect_ruby(root: &Path, out: &mut Vec) { +fn ruby_project(root: &Path) -> bool { let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"]; let gemspec = fs::read_dir(root) .into_iter() .flatten() .flatten() .any(|e| e.path().extension().is_some_and(|x| x == "gemspec")); - if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) { + gemspec || markers.iter().any(|m| root.join(m).is_file()) +} + +fn detect_ruby(root: &Path, out: &mut Vec) { + if !ruby_project(root) { return; } if root.join("bin/rails").is_file() { @@ -1101,6 +1230,17 @@ fn detect_ruby(root: &Path, out: &mut Vec) { } if uses_rspec(root) { out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test()); + } else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") { + // Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`. + let mut args = Vec::new(); + for dir in ["lib", "test"] { + if root.join(dir).is_dir() { + args.push(format!("-I{dir}")); + } + } + args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]); + let extra: Vec<&str> = args.iter().map(String::as_str).collect(); + out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test()); } } @@ -1202,8 +1342,10 @@ pub(crate) fn just_recipes(src: &str) -> Vec { /// Godot projects (`project.godot`) at the root or one level down: "Godot Project" /// runs the main scene (`godot --path .`), debuggable through the editor's adapter. fn detect_godot(root: &Path, out: &mut Vec) { - let dirs = std::iter::once(root.to_path_buf()).chain(subdirs(root)); - for dir in dirs.filter(|d| d.join("project.godot").is_file()) { + let dirs = ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| { + dir.join("project.godot").is_file() + }); + for dir in dirs { let rel = relative(root, &dir); let (id, name, cwd) = if rel.as_os_str().is_empty() { ("godot:project".to_owned(), "Godot Project".to_owned(), None) diff --git a/crates/ide-run/src/import/mod.rs b/crates/ide-run/src/import/mod.rs index 074bb4057..d314f57e8 100644 --- a/crates/ide-run/src/import/mod.rs +++ b/crates/ide-run/src/import/mod.rs @@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec, /// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes /// (like `parseArgs` in `src/platform/commandLine.ts`). -pub(crate) fn split_args(line: &str) -> Vec { +pub fn split_args(line: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); let mut has = false; diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 7e85a1643..a352ee838 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -12,6 +12,9 @@ pub mod java_cmdline; pub mod junit; mod process; pub mod python_path; +pub mod ruby_preload; +pub mod ruby_reporter; +pub mod scope; pub mod tomcat; mod utf8; pub mod which; @@ -74,7 +77,8 @@ fn is_true(b: &bool) -> bool { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct RunExtras { - /// Module supplying a JVM application's classpath; empty means the project root. + /// Owning module, independent of the working directory; also supplies a JVM application's classpath. + /// Empty means the project root. #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(optional)] pub module: Option, diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 2899c026a..9deb76d46 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -80,6 +80,8 @@ pub struct StartOptions { pub encoding: Option, /// Project content and source roots added to Python launch environments. pub python_paths: Vec, + /// Where the Ruby test reporter is installed (see [`crate::ruby_reporter`]); Ruby test runs load it. + pub ruby_reporter: Option, } #[derive(Default)] @@ -355,6 +357,10 @@ impl ProcessManager { if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") { crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?; } + if let Some(dir) = options.ruby_reporter.as_deref().filter(|_| crate::ruby_reporter::applies(config)) { + crate::ruby_preload::add(&mut env.vars, dir, crate::ruby_reporter::FEATURE, !env.clear); + env.vars.insert("OPENIDEA_TEST_REPORT".into(), "1".into()); + } let on_output: OutputFn = Arc::from(on_output); let on_output = match extras.output_file.as_deref() { Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?, diff --git a/crates/ide-run/src/ruby_preload.rs b/crates/ide-run/src/ruby_preload.rs new file mode 100644 index 000000000..f42937633 --- /dev/null +++ b/crates/ide-run/src/ruby_preload.rs @@ -0,0 +1,61 @@ +//! Loads a Ruby file into a launched process without touching the project: the directory goes on +//! `RUBYLIB` and `RUBYOPT` gets the bare feature name (`-ropenidea_hook`). +//! +//! `RUBYOPT` is split at spaces with no quoting, so an absolute path in it (`-r/…/Application +//! Support/…/hook.rb`, where the IDE keeps its data) makes Ruby fail with `invalid switch in RUBYOPT`. +//! `RUBYLIB` is a path list and takes spaces. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Makes `feature` (a file `/.rb`) load first in every Ruby the process starts. +/// `inherit`: the process also sees the IDE's own environment, whose `RUBYLIB` / `RUBYOPT` stay. +pub fn add(env: &mut BTreeMap, dir: &Path, feature: &str, inherit: bool) { + let mut inherited = if inherit { crate::system_env() } else { BTreeMap::new() }; + let mut existing = |name: &str| env.get(name).cloned().or_else(|| inherited.remove(name)).filter(|v| !v.is_empty()); + + let mut dirs: Vec = vec![dir.to_path_buf()]; + if let Some(lib) = existing("RUBYLIB") { + for d in std::env::split_paths(&lib) { + if !dirs.contains(&d) { + dirs.push(d); + } + } + } + let rubyopt = existing("RUBYOPT"); + if let Ok(joined) = std::env::join_paths(dirs) { + env.insert("RUBYLIB".into(), joined.to_string_lossy().into_owned()); + } + let option = format!("-r{feature}"); + let value = match rubyopt { + Some(e) if e.split_whitespace().any(|o| o == option) => e, + Some(e) => format!("{e} {option}"), + None => option, + }; + env.insert("RUBYOPT".into(), value); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_directory_with_spaces_goes_on_rubylib_not_into_rubyopt() { + let dir = Path::new("/Users/me/Library/Application Support/ide/ruby"); + let mut env = BTreeMap::new(); + add(&mut env, dir, "openidea_hook", false); + assert_eq!(env["RUBYLIB"], dir.to_string_lossy()); + assert_eq!(env["RUBYOPT"], "-ropenidea_hook"); + assert!(!env["RUBYOPT"].contains(' ')); + } + + #[test] + fn keeps_existing_settings_and_adds_once() { + // Path lists use the platform's separator (`;` on Windows). + let list = |dirs: &[&str]| std::env::join_paths(dirs).unwrap().to_string_lossy().into_owned(); + let mut env = BTreeMap::from([("RUBYLIB".into(), list(&["/a", "/b"])), ("RUBYOPT".into(), "-w".into())]); + add(&mut env, Path::new("/hook"), "h", false); + add(&mut env, Path::new("/hook"), "h", false); + assert_eq!(env["RUBYLIB"], list(&["/hook", "/a", "/b"])); + assert_eq!(env["RUBYOPT"], "-w -rh"); + } +} diff --git a/crates/ide-run/src/ruby_reporter.rs b/crates/ide-run/src/ruby_reporter.rs new file mode 100644 index 000000000..c689d4ddd --- /dev/null +++ b/crates/ide-run/src/ruby_reporter.rs @@ -0,0 +1,52 @@ +//! A Minitest reporter the IDE loads into test runs (see `ruby_reporter/`): it prints one record per +//! test on stdout, which the IDE strips from the console and turns into the test tree. Tests then +//! report their real source file and line, and the output no longer needs `-v` to be parsed. +use crate::RunConfig; +use std::io; +use std::path::Path; + +/// The Ruby file `RUBYOPT` loads first (`/openidea_test_hook.rb`). +pub const FEATURE: &str = "openidea_test_hook"; + +const FILES: [(&str, &str); 2] = [ + ("openidea_test_hook.rb", include_str!("ruby_reporter/openidea_test_hook.rb")), + ("openidea/minitest_reporter.rb", include_str!("ruby_reporter/minitest_reporter.rb")), +]; + +/// Whether a run takes the reporter: Minitest-style test runs through `ruby` or `rake`, with their output +/// in the console (a terminal would show the records). +pub fn applies(config: &RunConfig) -> bool { + config.is_test && matches!(config.kind.as_str(), "ruby" | "rake") && !config.extras.emulate_terminal +} + +/// Writes the reporter files under `dir` (unchanged files stay). +pub fn install(dir: &Path) -> io::Result<()> { + for (name, text) in FILES { + let path = dir.join(name); + if std::fs::read_to_string(&path).is_ok_and(|current| current == text) { + continue; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, text)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installs_the_files_once_and_only_for_ruby_test_runs() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("ruby reporter"); + install(&dir).unwrap(); + install(&dir).unwrap(); + for (name, text) in FILES { + assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), text); + } + assert!(FILES[0].1.contains("OPENIDEA_TEST_REPORT") && FILES[1].1.contains("@@OPENIDEA@@")); + } +} diff --git a/crates/ide-run/src/ruby_reporter/minitest_reporter.rb b/crates/ide-run/src/ruby_reporter/minitest_reporter.rb new file mode 100644 index 000000000..a657ad4e9 --- /dev/null +++ b/crates/ide-run/src/ruby_reporter/minitest_reporter.rb @@ -0,0 +1,78 @@ +# Written by OpenIDEA for test runs; registered by `openidea_test_hook.rb` once Minitest is loaded. +# Nothing in the project is changed. +# +# One record per line on stdout, which the IDE strips from the console again: +# @@OPENIDEA@@{"t":"start","c":"MyTest","n":"test_x","f":"/abs/test.rb","l":12} +# @@OPENIDEA@@{"t":"result","c":"MyTest","n":"test_x","s":"passed","d":0.001,"f":...,"l":...,"m":"failure text"} +# @@OPENIDEA@@{"t":"summary","runs":7,"failures":1,"errors":1,"skips":1,"d":0.4} +require "json" + +module Minitest + class OpenIdeaReporter < AbstractReporter + MARK = "@@OPENIDEA@@" + + def initialize(io) + super() + @io = io + @cwd = Dir.pwd + @lock = Mutex.new + @runs = @failures = @errors = @skips = 0 + @started = nil + end + + def start + @started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def prerecord(klass, name) + emit(t: "start", c: klass.to_s, n: name.to_s, **where(klass, name)) + end + + def record(result) + @runs += 1 + status = result.skipped? ? "skipped" : result.passed? ? "passed" : "failed" + @skips += 1 if status == "skipped" + if status == "failed" + result.failures.any? { |f| f.is_a?(UnexpectedError) } ? @errors += 1 : @failures += 1 + end + loc = result.source_location + emit(t: "result", c: result.klass.to_s, n: result.name.to_s, s: status, d: result.time, + f: loc && absolute(loc[0]), l: loc && loc[1], m: result.failures.map(&:message).join("\n")) + end + + def report + d = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : nil + emit(t: "summary", runs: @runs, failures: @failures, errors: @errors, skips: @skips, d: d) + end + + private + + # Where the test is defined, known before it runs (so the tree can show it while running). + def where(klass, name) + f, l = klass.instance_method(name).source_location + { f: absolute(f), l: l } + rescue StandardError + {} + end + + # Test files are often loaded by a path relative to the working directory. + def absolute(file) + File.expand_path(file, @cwd) + end + + def emit(record) + line = "#{MARK}#{JSON.generate(record)}\n" + @lock.synchronize { @io.write(line) } + rescue StandardError + nil # reporting must never fail a test run + end + end + + def self.plugin_openidea_init(options) + return unless ENV["OPENIDEA_TEST_REPORT"] + + io = options[:io] || $stdout + io.sync = true if io.respond_to?(:sync=) + reporter << OpenIdeaReporter.new(io) + end +end diff --git a/crates/ide-run/src/ruby_reporter/openidea_test_hook.rb b/crates/ide-run/src/ruby_reporter/openidea_test_hook.rb new file mode 100644 index 000000000..57a72084e --- /dev/null +++ b/crates/ide-run/src/ruby_reporter/openidea_test_hook.rb @@ -0,0 +1,32 @@ +# Written by OpenIDEA for test runs. Loaded first in every Ruby the run starts +# (RUBYLIB=, RUBYOPT=-ropenidea_test_hook). +# +# Registers the test reporter once a test framework is loaded. Minitest 5 would find a plugin on +# its own, Minitest 6 does not load plugins any more, so both are registered the same way. +if ENV["OPENIDEA_TEST_REPORT"] + module OpenIdeaTestHook + @registered = false + + def self.register + return if @registered + return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions) && defined?(::Minitest::AbstractReporter) + + @registered = true + require "openidea/minitest_reporter" + ::Minitest.extensions << "openidea" unless ::Minitest.extensions.include?("openidea") + rescue StandardError, LoadError + nil # reporting must never break a test run + end + + # `require "minitest/autorun"` loads Minitest through require_relative, so look after every require. + module Watch + def require(path) + result = super + OpenIdeaTestHook.register + result + end + end + end + + Kernel.prepend(OpenIdeaTestHook::Watch) +end diff --git a/crates/ide-run/src/scope.rs b/crates/ide-run/src/scope.rs new file mode 100644 index 000000000..a99447787 --- /dev/null +++ b/crates/ide-run/src/scope.rs @@ -0,0 +1,125 @@ +//! A configuration's module and relative paths move together when projects are nested. + +use crate::RunConfig; +use ide_core::{ipc_path::to_ipc, project_roots}; +use std::path::{Path, PathBuf}; + +pub fn owner(root: &Path, config: &RunConfig) -> PathBuf { + if matches!(config.kind.as_str(), "dotnet" | "dotnet-test") + && let Some(project) = config.extras.params.get("project").filter(|p| !p.is_empty()) + { + return project_roots::resolve(root, Path::new(project)).parent().unwrap_or(root).to_path_buf(); + } + let module = (config.kind == "go") + .then(|| config.extras.params.get("module")) + .flatten() + .map(String::as_str) + .or(config.extras.module.as_deref()) + .map(Path::new) + .or(config.cwd.as_deref()) + .unwrap_or(Path::new("")); + project_roots::resolve(root, module) +} + +pub fn rebase(config: &mut RunConfig, from: &Path, relative_to: Option<&Path>) { + let path = |value: &str| { + let absolute = project_roots::resolve(from, Path::new(value)); + to_ipc(&relative_to.map_or(absolute.clone(), |root| project_roots::relative(root, &absolute))) + }; + let cwd = config.cwd.as_deref().unwrap_or(Path::new("")).to_path_buf(); + config.cwd = Some(path(&to_ipc(&cwd)).into()); + if config.extras.module.is_some() || !matches!(config.kind.as_str(), "application" | "spring-boot") { + config.extras.module = Some(path(config.extras.module.as_deref().unwrap_or(""))); + } + if config.kind == "go" && config.extras.params.contains_key("goKind") { + config.extras.params.entry("module".into()).or_default(); + } + for file in &mut config.extras.env_files { + *file = path(file); + } + for file in [&mut config.extras.stdin_file, &mut config.extras.output_file].into_iter().flatten() { + *file = path(file); + } + let keys: &[&str] = match config.kind.as_str() { + "go" => &["module", "outputDir"], + "dotnet" | "dotnet-test" => &["project", "targetPath"], + _ => &[], + }; + for key in keys { + if let Some(value) = config.extras.params.get_mut(*key) + && (*key == "module" || !value.is_empty()) + { + *value = path(value); + } + } + if !matches!(config.kind.as_str(), "shell" | "compound") && Path::new(&config.command).components().count() > 1 { + // Executables are relative to the old cwd, unlike env/input/output files. + let cwd = project_roots::resolve(from, &cwd); + config.command = to_ipc(&project_roots::resolve(&cwd, Path::new(&config.command))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn default_go_module_moves_with_member_but_unknown_jvm_module_stays_inferred() { + let temp = tempfile::tempdir().unwrap(); + for (kind, params) in + [("go", serde_json::json!({"goKind":"build", "target":"."})), ("application", serde_json::json!({}))] + { + let mut config: RunConfig = serde_json::from_value(serde_json::json!({ + "id":"run", "name":"run", "kind":kind, "command":"tool", "args":[], "cwd":null, + "env":{}, "detected":false, "isTest":false, "debugAdapter":null, "params":params + })) + .unwrap(); + rebase(&mut config, temp.path(), None); + if kind == "go" { + assert_eq!(config.extras.params["module"], to_ipc(temp.path())); + } else { + assert_eq!(config.extras.module, None); + } + } + } + + #[test] + fn workspace_members_rebase_every_typed_root_without_changing_targets() { + let temp = tempfile::tempdir().unwrap(); + let member = temp.path().join("member with spaces"); + for (kind, params) in [ + ("application", BTreeMap::::new()), + ( + "go", + BTreeMap::from([ + ("module".into(), "api".into()), + ("target".into(), "./cmd/server".into()), + ("outputDir".into(), "bin".into()), + ]), + ), + ("dotnet", BTreeMap::from([("project".into(), "src/App.csproj".into())])), + ] { + let mut config: RunConfig = serde_json::from_value(serde_json::json!({ + "id":"run", "name":"run", "kind":kind, "command":"tool", "args":["unchanged"], "cwd":null, + "env":{}, "detected":true, "isTest":false, "debugAdapter":null, + "module":"app", "params":params, "envFiles":[".env"], "stdinFile":"input.txt", "outputFile":"out/result.txt" + })).unwrap(); + rebase(&mut config, &member, None); + assert_eq!(config.cwd.as_deref(), Some(member.as_path())); + assert_eq!(config.extras.module.as_deref(), Some(to_ipc(&member.join("app")).as_str())); + assert_eq!(config.extras.env_files, [to_ipc(&member.join(".env"))]); + assert_eq!(config.extras.stdin_file, Some(to_ipc(&member.join("input.txt")))); + assert_eq!(config.extras.output_file, Some(to_ipc(&member.join("out/result.txt")))); + assert_eq!(config.args, ["unchanged"]); + if kind == "go" { + assert_eq!(config.extras.params["module"], to_ipc(&member.join("api"))); + assert_eq!(config.extras.params["outputDir"], to_ipc(&member.join("bin"))); + assert_eq!(config.extras.params["target"], "./cmd/server"); + } + if kind == "dotnet" { + assert_eq!(config.extras.params["project"], to_ipc(&member.join("src/App.csproj"))); + } + } + } +} diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index ab49e617d..bcccc221b 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String { format!("\n", escape(&doc_base.to_string_lossy())) } +/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR. +fn app_context_xml(doc_base: &Path) -> Option { + if doc_base.is_dir() { + return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok(); + } + let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?; + let mut text = String::new(); + zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?; + Some(text) +} + +/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's +/// `META-INF/context.xml` once a descriptor exists there, so its content (``, +/// ``, ``…) is carried over, with `docBase` and `reloadable` set by the IDE. +pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String { + let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) }; + merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| { + tracing::warn!("ignoring the web app's META-INF/context.xml: {e}"); + context_xml(doc_base, reload) + }) +} + +fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult { + let mut reader = Reader::from_str(app); + let mut writer = Writer::new(Vec::new()); + let mut root = false; + loop { + let event = reader.read_event().map_err(invalid)?; + let event = match event { + Event::Eof => break, + Event::Start(ref tag) | Event::Empty(ref tag) if !root => { + if tag.name().as_ref() != "Context" { + return Err(invalid("the root element is not ")); + } + root = true; + let mut new = BytesStart::new("Context"); + for a in tag.attributes() { + let a = a.map_err(invalid)?; + if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") { + new.push_attribute(a); + } + } + new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref())); + new.push_attribute(("reloadable", if reload { "true" } else { "false" })); + if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) } + } + e => e, + }; + writer.write_event(event).map_err(invalid)?; + } + if !root { + return Err(invalid("no element")); + } + String::from_utf8(writer.into_inner()).map_err(invalid) +} + pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult { let mut reader = Reader::from_str(xml); let mut writer = Writer::new(Vec::new()); @@ -442,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<( let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?; fs::write(base.join("conf/server.xml"), server)?; let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true"); - fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?; + fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?; config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned()); config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned()); if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) { @@ -501,6 +557,48 @@ mod tests { } zip.finish().unwrap(); } + #[test] + fn keeps_the_apps_own_context_descriptor() { + let tmp = tempfile::tempdir().unwrap(); + let app = "\n\n \n \n \n\n"; + let dir = tmp.path().join("web app"); + fs::create_dir_all(dir.join("META-INF")).unwrap(); + fs::write(dir.join("META-INF/context.xml"), app).unwrap(); + let war = tmp.path().join("app.war"); + archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]); + for doc in [&dir, &war] { + let xml = deployed_context_xml(doc, true); + let doc_base = escape(&doc.to_string_lossy()); + assert!( + xml.starts_with("\n")), "{xml}"); + assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}"); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("")); + } + + fs::write(dir.join("META-INF/context.xml"), "").unwrap(); + let doc_base = escape(&dir.to_string_lossy()); + assert_eq!( + deployed_context_xml(&dir, false), + format!("") + ); + // Unreadable or not a context: the IDE's own descriptor, as before. + for broken in ["", "", ""] { + fs::write(dir.join("META-INF/context.xml"), broken).unwrap(); + assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}"); + } + let plain = tmp.path().join("plain"); + fs::create_dir_all(&plain).unwrap(); + assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true)); + let bare = tmp.path().join("bare.war"); + archive(&bare, &[("index.jsp", "")]); + assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true)); + } fn config() -> RunConfig { RunConfig { id: "local:demo".into(), diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index aebe520c2..0c35cc037 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -131,9 +131,25 @@ fn cargo_workspace() { ("cli/src/main.rs", ""), ]); let c = detect(dir.path()); - assert_eq!(ids(&c), ["cargo:run:openidea-cli", "cargo:test", "cargo:build"]); + assert_eq!( + ids(&c), + [ + "cargo:run:openidea-cli", + "cargo:test:core", + "cargo:test:openidea-cli", + "cargo:test", + "cargo:build", + "cargo:run:skip:crates/skip", + "cargo:test:crates/skip", + "cargo:build:crates/skip" + ] + ); assert_eq!(cmdline(&c[0]), "cargo run -p openidea-cli --bin openidea-cli"); assert_eq!(cmdline(find(&c, "cargo:test")), "cargo test --workspace"); + let test = find(&c, "cargo:test:core"); + assert_eq!(test.extras.module.as_deref(), Some("crates/core")); + assert_eq!(cmdline(test), "cargo test -p core"); + assert_eq!(find(&c, "cargo:run:skip:crates/skip").cwd.as_deref(), Some(Path::new("crates/skip"))); } #[test] @@ -300,7 +316,22 @@ fn nested_builds_of_every_kind_and_depth() { assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing"))); let builds: Vec<&str> = ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect(); - assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]); + assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing", "maven:package:tool/src"]); + let model = ide_build::load(dir.path(), &dir.path().join("cache"), None); + for config in c.iter().filter(|c| c.kind == "gradle") { + let cwd = config.cwd.as_deref().unwrap(); + assert!(model.gradle.iter().any(|g| Path::new(&g.dir) == cwd), "{} has no build model", config.id); + } + let plan = ide_build::compile::plan( + dir.path(), + &model, + ide_build::compile::BuildKind::Build, + Some(&dir.path().join("services/api/app/src/main/kotlin/com/acme/Api.kt")), + None, + ) + .unwrap(); + assert_eq!(plan.steps[0].cwd, dir.path().join("services/api")); + assert_eq!(plan.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]); } #[test] @@ -362,6 +393,49 @@ fn go_projects() { assert_eq!(ids(&detect(dir.path())), ["go:test"]); } +#[test] +fn nested_go_modules_keep_their_working_directories() { + let dir = project(&[ + ("Cargo.toml", "[package]\nname = 'workspace'\n"), + ("fixtures/go-app/go.mod", "module example.com/openidea/notes\n"), + ("fixtures/go-app/cmd/server/main.go", "package main\nfunc main() {}\n"), + ("services/backend/api/go.mod", "module example.com/api\n"), + ("services/backend/api/main.go", "package main\nfunc main() {}\n"), + ("fixtures/go-app/nested/go.mod", "module ignored\n"), + ("vendor/ignored/go.mod", "module ignored\n"), + ("node_modules/ignored/go.mod", "module ignored\n"), + ("target/ignored/go.mod", "module ignored\n"), + ("build/ignored/go.mod", "module ignored\n"), + ("dist/ignored/go.mod", "module ignored\n"), + (".hidden/ignored/go.mod", "module ignored\n"), + ]); + let configs = detect(dir.path()); + let go: Vec<_> = configs.iter().filter(|c| c.kind == "go").map(|c| c.id.as_str()).collect(); + assert_eq!( + go, + [ + "go:run:server:fixtures/go-app", + "go:test:fixtures/go-app", + "go:test:fixtures/go-app/nested", + "go:run:services/backend/api", + "go:test:services/backend/api", + ] + ); + let run = find(&configs, "go:run:server:fixtures/go-app"); + assert_eq!(cmdline(run), "go run ./cmd/server"); + assert_eq!(run.cwd.as_deref(), Some(Path::new("fixtures/go-app"))); + let test = find(&configs, "go:test:services/backend/api"); + assert_eq!(test.cwd.as_deref(), Some(Path::new("services/backend/api"))); +} + +#[cfg(unix)] +#[test] +fn nested_go_discovery_does_not_follow_symlinks() { + let dir = project(&[("real/go.mod", "module real\n")]); + std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap(); + assert_eq!(ids(&detect(dir.path())), ["go:test:real"]); +} + #[test] fn python_projects() { let dir = project(&[ @@ -545,7 +619,7 @@ fn nested_packages_in_polyglot_repo() { } #[test] -fn root_package_disables_nested_scan() { +fn independent_packages_remain_visible_under_a_root_package() { let dir = project(&[ ("package.json", r#"{"scripts":{"build":"tsc"}}"#), ("tools/package.json", r#"{"scripts":{"gen":"x"}}"#), @@ -553,9 +627,9 @@ fn root_package_disables_nested_scan() { ("svc/go.mod", "module y\n"), ]); let c = detect(dir.path()); - assert!(!ids(&c).contains(&"npm:tools:gen")); + assert!(ids(&c).contains(&"npm:tools:gen")); assert!(ids(&c).contains(&"go:test")); - assert!(!ids(&c).contains(&"go:test:svc")); + assert!(ids(&c).contains(&"go:test:svc")); } #[test] @@ -820,6 +894,25 @@ task default: :build assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake")); } +#[test] +fn ruby_minitest_projects_get_a_test_runner() { + let dir = project(&[("Gemfile", "source 'https://rubygems.org'\n"), ("lib/a.rb", ""), ("test/a_test.rb", "")]); + let c = detect(dir.path()); + let m = find(&c, "minitest"); + assert!(m.is_test); + assert_eq!(m.kind, "ruby"); + assert_eq!( + (m.command.as_str(), &m.args[..4]), + ("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..]) + ); + assert_eq!(m.args.last().map(String::as_str), Some("test")); + // A `test` Rake task or RSpec already runs the tests. + let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]); + assert!(!ids(&detect(dir.path())).contains(&"minitest")); + let dir = project(&[("Gemfile", "gem 'rspec'\n"), ("test/a_test.rb", "")]); + assert!(!ids(&detect(dir.path())).contains(&"minitest")); +} + #[test] fn ruby_without_bundler_runs_bare_tools() { let dir = project(&[("Rakefile", "task :test\n")]); @@ -828,3 +921,49 @@ fn ruby_without_bundler_runs_bare_tools() { assert_eq!(cmdline(&c[0]), "rake test"); assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty()); } + +#[test] +fn all_builtin_families_find_independent_nested_projects_and_keep_owners() { + let cases = [ + ("package.json", r#"{"scripts":{"test":"vitest"}}"#, "npm"), + ("deno.json", r#"{"tasks":{"test":"deno test"}}"#, "deno"), + ("Cargo.toml", "[package]\nname='demo'\nversion='0.1.0'\n[workspace]", "cargo"), + ("go.mod", "module demo", "go"), + ("pom.xml", "", "maven"), + ("build.gradle.kts", "plugins { application }", "gradle"), + ("pyproject.toml", "[tool.pytest.ini_options]", "pytest"), + ("phpunit.xml", "", "phpunit"), + ("Rakefile.rb", "task :test do\nend", "rake"), + ("CMakeLists.txt", "project(demo)", "cmake"), + ("Makefile", "test:\n\techo ok", "make"), + ("justfile", "test:\n echo ok", "just"), + ("project.godot", "[application]\nrun/main_scene=\"res://main.tscn\"", "godot"), + ]; + for (marker, text, kind) in cases { + let dir = project(&[ + ("package.json", r#"{"scripts":{"build":"echo root"}}"#), + (&format!("packages/apps/demo/{marker}"), text), + ]); + let configs = detect(dir.path()); + let found: Vec<_> = configs + .iter() + .filter(|c| c.kind == kind && c.cwd.as_deref() == Some(Path::new("packages/apps/demo"))) + .collect(); + assert!(!found.is_empty(), "{kind}: {configs:?}"); + for c in found { + // JVM and Go configs already identify ownership through cwd / their typed model. + if !matches!(kind, "gradle" | "maven" | "go" | "godot") { + assert_eq!(c.extras.module.as_deref(), Some("packages/apps/demo")); + } + } + } +} + +#[test] +fn cmake_subdirectories_are_not_independent_builds() { + let dir = project(&[ + ("CMakeLists.txt", "project(demo)\nadd_subdirectory(lib)"), + ("lib/CMakeLists.txt", "add_library(lib lib.cpp)"), + ]); + assert!(detect(dir.path()).iter().filter(|c| c.kind == "cmake").all(|c| c.cwd.is_none())); +} diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 176be79c9..21524e4f4 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -479,6 +479,36 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { } } +#[test] +fn ruby_test_runs_load_the_reporter_and_other_runs_do_not() { + let reporter = std::env::temp_dir().join("ruby reporter"); + let run = |kind: &str, is_test: bool| { + let h = harness(); + let mut config = + cfg("ruby-env", "/bin/sh", &["-c", r#"echo "lib=$RUBYLIB opt=$RUBYOPT report=$OPENIDEA_TEST_REPORT""#]); + config.kind = kind.into(); + config.is_test = is_test; + let options = StartOptions { ruby_reporter: Some(reporter.clone()), ..Default::default() }; + let chunks: Chunks = Arc::default(); + let sink = chunks.clone(); + let info = + h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))) + .unwrap(); + assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0)); + text(&chunks, stdout) + }; + for kind in ["ruby", "rake"] { + let out = run(kind, true); + // The directory has a space: it is on RUBYLIB and only the bare feature name is in RUBYOPT. + assert!(out.contains(&format!("lib={}", reporter.display())), "{out}"); + assert!(out.contains("-ropenidea_test_hook") && !out.contains(&format!("-r{}", reporter.display())), "{out}"); + assert!(out.contains("report=1"), "{out}"); + } + for (kind, is_test) in [("ruby", false), ("rspec", true), ("pytest", true)] { + assert!(run(kind, is_test).contains("report=\n"), "{kind} {is_test}"); + } +} + #[test] fn revoking_one_project_stops_only_its_runs() { let h = harness(); @@ -499,6 +529,30 @@ fn revoking_one_project_stops_only_its_runs() { h.pm.stop(theirs.id).unwrap(); } +/// Quitting the app kills what every project's runs started, forked children included (#1691). +#[test] +fn kill_all_stops_every_projects_process_trees() { + let h = harness(); + let other = tempfile::tempdir().unwrap(); + let (mine, chunks) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30 & echo $!; wait"])); + let grandchild: i32 = wait_for(&chunks, "\n").trim().parse().expect("pid"); + let theirs = + h.pm.start( + other.path(), + &cfg("theirs", "/bin/sh", &["-c", "sleep 30"]), + &StartOptions::default(), + h.sink.clone(), + Box::new(|_| {}), + ) + .unwrap(); + h.pm.kill_all(); + let deadline = Instant::now() + Duration::from_secs(8); + while alive(grandchild) || h.pm.list().iter().any(|r| (r.id == mine || r.id == theirs.id) && r.running) { + assert!(Instant::now() < deadline, "a run survived kill_all"); + std::thread::sleep(Duration::from_millis(50)); + } +} + #[test] #[ignore = "requires CMake, CTest and a C++ compiler"] fn cmake_configure_discovers_builds_and_runs_a_multifile_target() { diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 11888554c..7d098d949 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -2,14 +2,16 @@ //! language servers, run configurations and terminals. use crate::discover::{project_venv_python, workspace_tsdk}; -use crate::exec::{path_with, search_path}; +use crate::exec::{path_with, search_path, which}; use crate::{angular, godot, pinned, vue}; use crate::{bin_dir, exe, venv_of}; use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings}; use ide_core::version_managers::Tool; +use ide_core::{IdeError, IdeResult}; use ide_language::{LanguageConfig, LanguageServerConfig}; use ide_run::RunConfig; use serde_json::{Value, json}; +use std::collections::HashMap; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -28,14 +30,60 @@ pub fn setting_path(root: &Path, s: &str) -> Option { Some(if p.is_absolute() { p } else { root.join(p) }) } +fn toml_has_table(file: &Path, table: &str) -> bool { + let Ok(text) = std::fs::read_to_string(file) else { return false }; + let Ok(value) = toml::from_str::(&text) else { return false }; + table.split('.').try_fold(&value, |value, key| value.get(key)).is_some_and(toml::Value::is_table) +} + +/// The Ruff configuration file selected by `languages.ruff.configurationDirectory`. +/// A file can be chosen directly; a directory resolves `ruff.toml`, `.ruff.toml`, +/// then a `pyproject.toml` containing `[tool.ruff]`. +pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult> { + let Some(path) = setting_path(root, configured) else { return Ok(None) }; + if path.is_file() { + return Ok(Some(path)); + } + if !path.is_dir() { + return Err(IdeError::invalid(format!("Ruff configuration path does not exist: {}", path.display()))); + } + for name in ["ruff.toml", ".ruff.toml"] { + let file = path.join(name); + if file.is_file() { + return Ok(Some(file)); + } + } + let pyproject = path.join("pyproject.toml"); + if toml_has_table(&pyproject, "tool.ruff") { + return Ok(Some(pyproject)); + } + Err(IdeError::invalid(format!( + "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {}", + path.display() + ))) +} + /// The project interpreter: the configured one, else a project virtualenv -/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf. +/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/`) in the project +/// root, else the Python the project pins for mise / asdf. pub fn python_interpreter(root: &Path, s: &Settings) -> Option { setting_path(root, &s.languages.python.interpreter) .or_else(|| project_venv_python(root)) .or_else(|| pinned::pinned_python(root)) } +/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file. +pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option { + if let Some(python) = setting_path(root, &s.languages.python.interpreter) { + return Some(python); + } + let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root); + near.ancestors() + .take_while(|dir| dir.starts_with(root)) + .find_map(project_venv_python) + .or_else(|| pinned::pinned_python(near)) +} + /// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf /// install, else `ruby` on PATH. pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option { @@ -211,6 +259,103 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp .unwrap_or_else(|| name.into()) } +fn gradle_run_jdk<'a>( + release: Option, + configured: Option<&'a crate::java::Jdk>, + installed: &'a [crate::java::Jdk], + gradle: Option<(u32, u32)>, +) -> Option<&'a crate::java::Jdk> { + configured.or_else(|| { + let release = release?; + // An unknown Gradle version cannot narrow the choice: the release alone decides. + let Some(gradle) = gradle else { return crate::java::pick_jdk(installed, Some(release)) }; + let compatible: Vec<_> = installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).collect(); + compatible + .iter() + .copied() + .find(|jdk| jdk.major == release) + .or_else(|| compatible.iter().copied().filter(|jdk| jdk.major > release).min_by_key(|jdk| jdk.major)) + .or_else(|| compatible.iter().copied().max_by_key(|jdk| jdk.major)) + }) +} + +/// Default environment shared by Gradle model builds and Run configurations. +pub fn gradle_build_env( + root: &Path, + build_dir: &Path, + command: &str, + settings: &Settings, + release: Option, +) -> std::collections::BTreeMap { + let configured = project_jdk(root, settings); + let installed = crate::java::installed_jdks(); + let jdk = gradle_run_jdk( + release.or_else(|| crate::java::project_java_version(build_dir)), + configured.as_ref(), + &installed, + gradle_distribution_version(build_dir, command), + ); + let env = jdk + .map(|jdk| std::collections::BTreeMap::from([("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned())])) + .unwrap_or_default(); + gradle_env(root, settings, env) +} + +fn gradle_supports_java(gradle: (u32, u32), java: u32) -> bool { + // https://docs.gradle.org/current/userguide/compatibility.html#java_runtime + let minimum = match java { + 8 => (2, 0), + 9 => (4, 3), + 10 => (4, 7), + 11 => (5, 0), + 12 => (5, 4), + 13 => (6, 0), + 14 => (6, 3), + 15 => (6, 7), + 16 => (7, 0), + 17 => (7, 3), + 18 => (7, 5), + 19 => (7, 6), + 20 => (8, 3), + 21 => (8, 5), + 22 => (8, 8), + 23 => (8, 10), + 24 => (8, 14), + 25 => (9, 1), + 26 => (9, 4), + 27 => (9, 8), + _ => return false, + }; + gradle >= minimum && (java >= 17 || gradle < (9, 0)) +} + +fn gradle_distribution_version(build_dir: &Path, command: &str) -> Option<(u32, u32)> { + let version = |text: &str| { + let mut parts = text.split('.'); + Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) + }; + if Path::new(command).file_stem().is_some_and(|name| name == "gradlew") { + let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?; + let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?; + return version(url.rsplit_once("gradle-")?.1.split('-').next()?); + } + let path = Path::new(command); + let path = if path.is_absolute() { path.to_path_buf() } else { crate::exec::which(command)? }; + let path = dunce::canonicalize(path).ok()?; + let home = path.parent()?.parent()?; + for lib in [home.join("lib"), home.join("libexec/lib")] { + let Ok(entries) = std::fs::read_dir(lib) else { continue }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(v) = name.strip_prefix("gradle-launcher-").and_then(|name| name.strip_suffix(".jar")) { + return version(v); + } + } + } + None +} + /// Gradle JVM overrides Project JDK; model callers supply the module's fallback environment. pub fn gradle_env( root: &Path, @@ -226,8 +371,8 @@ pub fn gradle_env( env } -/// Maven options from settings, placed before the goals: `-s ` -/// and `-Dmaven.repo.local=`. +/// Maven options from settings, placed before the goals: `-s `, +/// `-Dmaven.repo.local=` and `-T ` (the thread count). pub fn maven_options(root: &Path, s: &Settings) -> Vec { let m = &s.languages.java.maven; let mut out = Vec::new(); @@ -237,6 +382,9 @@ pub fn maven_options(root: &Path, s: &Settings) -> Vec { if let Some(dir) = setting_path(root, &m.local_repository) { out.push(format!("-Dmaven.repo.local={}", dir.to_string_lossy())); } + if let Some(threads) = m.thread_count() { + out.extend(["-T".to_owned(), threads.to_owned()]); + } out } @@ -244,7 +392,7 @@ fn maven_settings_prefixes(args: &[String]) -> Vec { let mut prefixes = vec![0]; let mut i = 0; while let Some(arg) = args.get(i) { - if arg == "-s" && args.get(i + 1).is_some() { + if (arg == "-s" || arg == "-T") && args.get(i + 1).is_some() { i += 2; } else if arg.starts_with("-Dmaven.repo.local=") { i += 1; @@ -575,6 +723,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } + if name == "ruff" { + match ruff_configuration(root, &s.languages.ruff.configuration_directory) { + Ok(Some(path)) => { + let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({})); + settings["configuration"] = json!(path.to_string_lossy()); + cfg.settings = Some(settings); + } + Ok(None) => {} + Err(e) => cfg.launch_error = Some(e.message), + } + } + if name == "rust-analyzer" { let r = &s.languages.rust; let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect(); @@ -642,6 +802,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root json!({ "settings": { "java": { "configuration": { "maven": { "userSettings": file } } } } }), ); } + // m2e resolves and downloads into `maven.repo.local`; without it the + // embedded Maven falls back to ~/.m2 even when the IDE's own `mvn` + // runs honor the configured local repository. A `-Dmaven.repo.local` + // the user put in their own jdtls VM options wins. + if let Some(repo) = maven_local_repository(root, s) { + let arg = format!("-Dmaven.repo.local={}", repo.to_string_lossy()); + let taken = cfg.args.iter().enumerate().any(|(i, a)| { + let value = a + .strip_prefix("--jvm-arg=") + .or_else(|| (i > 0 && cfg.args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some("-Dmaven.repo.local")) + }); + if !taken { + cfg.args.push(format!("--jvm-arg={arg}")); + } + } } if let Some(lib) = tsdk(server_root, s).or_else(|| tsdk(root, s)) { @@ -661,6 +837,14 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root { let plugin = vue::tsserver_plugin(&vue); merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } })); + cfg.tsserver_project_extras.extensions = vec![vue::EXTENSION.into()]; + } + if name == "vtsls" + && cfg.tsserver_project + && !server_root.ancestors().any(|d| d.join("tsconfig.json").is_file() || d.join("jsconfig.json").is_file()) + { + let node = node_dir(root, s).map(|d| d.join(exe("node"))).filter(|p| p.is_file()).or_else(|| which("node")); + cfg.tsserver_project_extras.paths = vue::cli_paths(server_root, node.as_deref()); } if name == vue::SERVER { configure_vue_typescript(cfg, root, server_root, s); @@ -691,6 +875,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root path_dirs.push(dir); } + // Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew). + if name == "ruby-lsp" + && let Some(ruby) = ruby_interpreter(root, s) + { + path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root)); + } + if !path_dirs.is_empty() { let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect(); cfg.env.insert("PATH".into(), path_with(&dirs)); @@ -712,6 +903,11 @@ pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Se out.push(custom_server(name, ls)); } } + if non_empty(&s.languages.ruff.configuration_directory).is_some() { + for cfg in out.iter_mut().filter(|cfg| cfg.name == "ruff") { + cfg.require_root_marker = false; + } + } out } @@ -748,6 +944,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi max_java: None, launch_error: None, tsserver_project: false, + tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, } @@ -850,22 +1047,28 @@ fn is_python_command(cmd: &str) -> bool { const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"]; -/// [`maven_options`] as the groups they are passed in (`-s `, `-Dmaven.repo.local=…`). +/// [`maven_options`] as the groups they are passed in (`-s `, `-Dmaven.repo.local=…`, +/// `-T `). fn maven_option_groups(options: &[String]) -> Vec<&[String]> { let mut groups = Vec::new(); let mut i = 0; while i < options.len() { - let n = if options[i] == "-s" { 2.min(options.len() - i) } else { 1 }; + let n = if options[i] == "-s" || options[i] == "-T" { 2.min(options.len() - i) } else { 1 }; groups.push(&options[i..i + n]); i += n; } groups } -/// Whether `args` set what `group` sets: a user settings file, or the local repository. +/// Whether `args` set what `group` sets: a user settings file, the local +/// repository, or the thread count. fn sets_maven_option(args: &[String], group: &[String]) -> bool { match group.first().map(String::as_str) { Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")), + // `-T 4`, `--threads 4`, `-T4` and `--threads=4` all set the thread count. + Some("-T") => args.iter().any(|a| { + a == "-T" || a == "--threads" || a.starts_with("--threads=") || (a.starts_with("-T") && a.len() > 2) + }), Some(flag) => { let key = flag.split_once('=').map_or(flag, |(k, _)| k); args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key) @@ -890,19 +1093,38 @@ fn add_maven_options(args: &mut Vec, options: &[String]) { /// (and Maven the configured home and options). pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) { let explicit_python = setting_path(root, &s.languages.python.interpreter); - let python = python_interpreter(root, s); let pm = non_empty(&s.languages.node.package_manager); - let node = node_dir(root, s); let php = setting_path(root, &s.languages.php.interpreter); let rust = rust_bin(root, s); - let go = goroot(root, s); let gopath = setting_path(root, &s.languages.go.gopath); let jdk = project_jdk(root, s); let mvn = maven_command(root, s); let mvn_options = maven_options(root, s); - let ruby = ruby_interpreter(root, s); + + // Module-local toolchains, looked up once per owning module and only for the kinds using them. + let mut toolchains: HashMap<(&'static str, PathBuf), Option> = HashMap::new(); + let mut toolchain = |tool: &'static str, owner: &Path| { + toolchains + .entry((tool, owner.to_path_buf())) + .or_insert_with(|| { + // A module outside the project (an external linked build) keeps the project's choices. + let near = if owner.starts_with(root) { owner } else { root }; + match tool { + "python" => python_interpreter_near(root, Some(near), s), + "node" if non_empty(&s.languages.node.interpreter).is_none() => { + node_dir(near, s).or_else(|| node_dir(root, s)) + } + "node" => node_dir(root, s), + "go" if non_empty(&s.languages.go.goroot).is_none() => goroot(near, s).or_else(|| goroot(root, s)), + "go" => goroot(root, s), + _ => setting_path(root, &s.languages.ruby.interpreter).or_else(|| ruby_interpreter(near, s)), + } + }) + .clone() + }; for c in configs.iter_mut() { + let owner = ide_run::scope::owner(root, c); resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { @@ -926,6 +1148,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect(); } } + let python = toolchain("python", &owner); if let Some(venv) = python.as_deref().and_then(venv_of) && !c.env.contains_key("VIRTUAL_ENV") { @@ -933,8 +1156,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings path_dirs.push(venv.join(bin_dir())); } } - "npm" => { + "npm" | "node" | "deno" | "bun" => { if let Some(pm) = pm + && c.kind == "npm" && c.detected && matches!(c.command.as_str(), "npm" | "pnpm" | "yarn" | "bun") { @@ -942,7 +1166,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings } // The configuration's own Node interpreter, else the project's. let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p)); - if let Some(dir) = own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| node.clone()) { + if let Some(dir) = + own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| toolchain("node", &owner)) + { path_dirs.push(dir); } } @@ -969,7 +1195,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) { c.env.entry(key).or_insert(value); } - if let Some(goroot) = &go { + if let Some(goroot) = &toolchain("go", &owner) { let bin = goroot.join("bin"); if c.command == "go" && bin.join(exe("go")).is_file() { c.command = bin.join(exe("go")).to_string_lossy().into_owned(); @@ -986,18 +1212,12 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings if matches!(c.command.as_str(), "gradle" | "gradle.bat") { c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned(); } - let defaults = jdk - .as_ref() - .map(|j| { - std::collections::BTreeMap::from([("JAVA_HOME".into(), j.home.to_string_lossy().into_owned())]) - }) - .unwrap_or_default(); - for (key, value) in gradle_env(root, s, defaults) { + for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) { c.env.entry(key).or_insert(value); } } "ruby" | "rake" | "rspec" => { - if let Some(ruby) = &ruby + if let Some(ruby) = &toolchain("ruby", &owner) && let Some(bin) = crate::ruby::bin_dir_of(ruby) { // `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed @@ -1244,6 +1464,69 @@ mod java_compiler_tests { } } +#[cfg(test)] +mod gradle_runtime_tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn gradle_runs_use_the_builds_toolchain_without_overriding_user_choices() { + let temp = tempfile::tempdir().unwrap(); + let installed = + [25, 21, 17, 11, 8].map(|major| crate::java::Jdk { major, home: temp.path().join(format!("jdk{major}")) }); + for (name, script, expected) in [ + ("kotlin", "kotlin { jvmToolchain(21) }", Some(21)), + ("java", "java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }", Some(17)), + ("unspecified", "plugins { id 'java' }", None), + ] { + let dir = temp.path().join(name); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("build.gradle.kts"), script).unwrap(); + let picked = gradle_run_jdk(crate::java::project_java_version(&dir), None, &installed, Some((9, 0))); + assert_eq!(picked.map(|jdk| jdk.major), expected); + assert_eq!(gradle_run_jdk(expected, Some(&installed[0]), &installed, Some((9, 0))), Some(&installed[0])); + let mut settings = Settings::default(); + settings.languages.java.gradle.jvm = "custom-gradle-jdk".into(); + let env = gradle_env(temp.path(), &settings, BTreeMap::from([("JAVA_HOME".into(), "/fallback".into())])); + assert_eq!(env["JAVA_HOME"], temp.path().join("custom-gradle-jdk").to_string_lossy()); + } + let dir = temp.path().join("kotlin"); + for (gradle, target, expected) in + [((9, 0), 8, 17), ((8, 4), 21, 17), ((8, 5), 21, 21), ((7, 2), 21, 11), ((9, 0), 25, 21), ((9, 1), 25, 25)] + { + std::fs::write(dir.join("build.gradle.kts"), format!("kotlin {{ jvmToolchain({target}) }}")).unwrap(); + assert_eq!(gradle_run_jdk(Some(target), None, &installed, Some(gradle)).unwrap().major, expected); + } + assert_eq!(gradle_run_jdk(Some(21), None, &installed, None).unwrap().major, 21); + assert_eq!(gradle_run_jdk(Some(19), None, &installed, None).unwrap().major, 21); + assert!(gradle_run_jdk(None, None, &installed, None).is_none()); + } + + #[test] + fn reads_wrapper_and_local_gradle_versions_without_running_them() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap(); + std::fs::write( + root.join("gradle/wrapper/gradle-wrapper.properties"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.4.0-bin.zip\n", + ) + .unwrap(); + for command in ["./gradlew", "gradlew.bat"] { + assert_eq!(gradle_distribution_version(root, command), Some((8, 4))); + } + for layout in ["lib", "libexec/lib"] { + let home = root.join(layout.replace('/', "-")); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::create_dir_all(home.join(layout)).unwrap(); + let command = home.join("bin/gradle"); + std::fs::write(&command, "").unwrap(); + std::fs::write(home.join(layout).join("gradle-launcher-9.0.0.jar"), "").unwrap(); + assert_eq!(gradle_distribution_version(root, &command.to_string_lossy()), Some((9, 0))); + } + } +} + #[cfg(test)] mod maven_linkage_tests { use super::*; @@ -1301,3 +1584,29 @@ mod maven_linkage_tests { assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none()); } } + +#[cfg(all(test, unix))] +mod ruby_lsp_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() { + let tmp = tempfile::tempdir().unwrap(); + let bindir = tmp.path().join("gems/bin"); + std::fs::create_dir_all(&bindir).unwrap(); + // Homebrew's Ruby: `Gem.bindir` is not on `PATH`. + let ruby = tmp.path().join("ruby"); + std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display())) + .unwrap(); + std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut s = Settings::default(); + s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned(); + let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + let path = cfg.env.get("PATH").expect("PATH set"); + let dirs: Vec = std::env::split_paths(path).collect(); + assert!(dirs.contains(&bindir), "{dirs:?}"); + assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}"); + } +} diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index d5141e270..5469d6301 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -73,7 +73,7 @@ fn dedupe_by_real_path(paths: Vec<(PathBuf, &'static str)>) -> Vec<(PathBuf, &'s /// Project-local virtualenv directories checked by auto-detection, in order. pub(crate) const PROJECT_VENVS: [&str; 4] = [".venv", "venv", "env", ".env"]; -/// `//bin/python` of the first project virtualenv. +/// `//bin/python` of the first project virtualenv or pixi environment. pub(crate) fn project_venv_python(root: &Path) -> Option { PROJECT_VENVS .iter() @@ -81,6 +81,11 @@ pub(crate) fn project_venv_python(root: &Path) -> Option { .filter(|v| v.join("pyvenv.cfg").is_file()) .map(|v| v.join(bin_dir()).join(exe("python"))) .find(|p| p.is_file()) + .or_else(|| { + subdirs(&root.join(".pixi/envs")).iter().find_map(|env| { + [env.join(exe("python")), env.join("bin").join(exe("python"))].into_iter().find(|p| p.is_file()) + }) + }) } /// `/usr/bin/python3` on macOS is a stub that opens an installer dialog when @@ -110,6 +115,14 @@ fn python(root: Option<&Path>) -> Vec { found.push((dir.join(bin_dir()).join(exe("python")), "venv")); } } + // pixi environments (`.pixi/envs/`), which are no virtualenvs. + for env in subdirs(&root.join(".pixi/envs")) { + let python = + [env.join(exe("python")), env.join("bin").join(exe("python"))].into_iter().find(|p| p.is_file()); + if let Some(python) = python { + found.push((python, "pixi")); + } + } } if let Ok(venv) = std::env::var("VIRTUAL_ENV") { found.push((Path::new(&venv).join(bin_dir()).join(exe("python")), "venv")); @@ -215,6 +228,10 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti .map(|n| n.to_string_lossy().into_owned()); format!("Python {short} (conda: {})", env.unwrap_or_default()) } + (None, "pixi") => { + let env = pixi_env_name(path).unwrap_or_default(); + format!("Python {short} (pixi: {env})") + } (None, "pyenv") => format!("Python {short} (pyenv)"), (None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"), _ => format!("Python {short}"), @@ -224,11 +241,26 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti name: label, version: Some(version), source: source.to_owned(), - home: venv.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)).map(Into::into), + home: venv + .or_else(|| (source == "pixi").then(|| pixi_env_home(path)).flatten()) + .or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)) + .map(Into::into), extra: None, }) } +/// `.pixi/envs/` of a pixi interpreter (`/python`, `/bin/python`). +fn pixi_env_home(python: &Path) -> Option { + let dir = python.parent()?; + let env = if dir.file_name().is_some_and(|n| n == "bin") { dir.parent()? } else { dir }; + (env.parent()?.file_name()? == "envs" && env.parent()?.parent()?.file_name()? == ".pixi").then(|| env.to_path_buf()) +} + +/// The name of a pixi interpreter's environment (`` of `.pixi/envs/`). +fn pixi_env_name(python: &Path) -> Option { + pixi_env_home(python)?.file_name().map(|n| n.to_string_lossy().into_owned()) +} + fn pyvenv_version(python: &Path) -> Option { let cfg = std::fs::read_to_string(venv_of(python)?.join("pyvenv.cfg")).ok()?; cfg.lines().find_map(|l| { @@ -282,8 +314,38 @@ pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str { } } -/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers, -/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon). +/// A PHP CLI named with its version, as Debian / Ubuntu (`ppa:ondrej/php`) install them: +/// `php8.1`, `php81`. Not `php-cgi8.1`, `phpize8.1` or `phpdbg8.1`. +pub(crate) fn is_versioned_php_name(name: &str) -> bool { + let name = name.strip_suffix(".exe").unwrap_or(name); + let Some(version) = name.strip_prefix("php") else { return false }; + let mut parts = version.splitn(2, '.'); + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + parts.next().is_some_and(digits) && parts.next().is_none_or(digits) +} + +/// The versioned PHP CLIs in `dirs`, in `PATH` order (sorted by name within a directory). +pub(crate) fn versioned_php_in(dirs: impl IntoIterator) -> Vec { + let mut found = Vec::new(); + for dir in dirs { + if version_managers::is_shims_dir(&dir) { + continue; + } + let mut names: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| is_versioned_php_name(n)) + .collect(); + names.sort(); + found.extend(names.into_iter().map(|n| dir.join(n)).filter(|p| p.is_file())); + } + found +} + +/// `php` CLIs: every one on `PATH` (also versioned ones, `php8.1`), Homebrew's versioned +/// formulae, PHP version managers, Laravel Herd, and the usual Windows distributions (XAMPP, Laragon). fn php() -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); let home = home_dir(); @@ -294,6 +356,11 @@ fn php() -> Vec { let source = php_source(&p, home.as_deref()); found.push((p, source)); } + // After the plain `php`: deduplication keeps `/usr/bin/php` over the `php8.3` it links to. + for p in versioned_php_in(std::env::split_paths(&search_path())) { + let source = php_source(&p, home.as_deref()); + found.push((p, source)); + } let named = |dir: &Path, prefix: &str| -> Vec { subdirs(dir) .into_iter() @@ -332,7 +399,7 @@ fn php() -> Vec { } } let found = dedupe_by_real_path(found); - par_map(&found, |(path, source)| { + let mut sdks = par_map(&found, |(path, source)| { let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?; let version = version.trim().to_owned(); if version.is_empty() { @@ -346,7 +413,16 @@ fn php() -> Vec { home: path.parent().and_then(Path::parent).map(Into::into), extra: None, }) - }) + }); + newest_first(&mut sdks); + sdks +} + +/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal +/// versions the `PATH` install stays first. +pub(crate) fn newest_first(sdks: &mut [Sdk]) { + let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or("")); + sdks.sort_by_key(|s| std::cmp::Reverse(key(s))); } fn node() -> Vec { @@ -662,12 +738,20 @@ mod maven_path_tests { struct Subst { name: String, root: PathBuf, + _lock: std::fs::File, } impl Subst { fn new(target: &Path) -> Self { - // Tests run in parallel and may race for the same free letter; only a drive that - // shows this target's marker is ours. + // Drive letters are shared across nextest's test processes; hold the lock until unmapping. + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(std::env::temp_dir().join("openidea-sdk-subst.lock")) + .unwrap(); + lock.lock().unwrap(); let marker = format!("{}.subst", target.file_name().unwrap().to_string_lossy()); std::fs::write(target.join(&marker), "").unwrap(); for letter in (b'D'..=b'Z').rev() { @@ -678,7 +762,7 @@ mod maven_path_tests { } let output = Command::new("subst").arg(&name).arg(target).output().unwrap(); if output.status.success() && root.join(&marker).exists() { - return Self { name, root }; + return Self { name, root, _lock: lock }; } } panic!("no free drive for Maven path verification"); @@ -737,3 +821,34 @@ mod maven_path_tests { assert_eq!(home, dunce::canonicalize(real.path()).unwrap()); } } + +#[cfg(test)] +mod pixi_tests { + use super::*; + + #[test] + fn a_pixi_env_is_a_project_python_and_an_sdk_home() { + let root = tempfile::tempdir().unwrap(); + let env = root.path().join(".pixi/envs/default"); + let bin = if cfg!(windows) { env.clone() } else { env.join("bin") }; + std::fs::create_dir_all(&bin).unwrap(); + let python = bin.join(exe("python")); + std::fs::write(&python, "").unwrap(); + + assert_eq!(project_venv_python(root.path()), Some(python.clone())); + assert_eq!(pixi_env_home(&python), Some(env.clone())); + assert_eq!(pixi_env_name(&python).as_deref(), Some("default")); + + // A second env sorts after; the first with a Python wins. + let other = root.path().join(".pixi/envs/aaa-empty"); + std::fs::create_dir_all(&other).unwrap(); + assert_eq!(project_venv_python(root.path()), Some(python)); + } + + #[test] + fn non_pixi_paths_have_no_pixi_home() { + assert_eq!(pixi_env_home(Path::new("/usr/bin/python3")), None); + assert_eq!(pixi_env_home(Path::new("/p/.venv/bin/python")), None); + assert_eq!(pixi_env_home(Path::new("/p/envs/x/bin/python")), None); + } +} diff --git a/crates/ide-sdk/src/exec.rs b/crates/ide-sdk/src/exec.rs index c7d036b71..26a791f91 100644 --- a/crates/ide-sdk/src/exec.rs +++ b/crates/ide-sdk/src/exec.rs @@ -55,12 +55,16 @@ pub(crate) fn query_in( if let Some(cwd) = cwd { cmd.current_dir(cwd); } - if !env.iter().any(|(k, _)| *k == "PATH") { + cmd.args(args).envs(env.iter().copied()); + query_command(cmd, timeout) +} + +/// [`query`] of a command built by the caller; `PATH` is the search path unless it sets one. +pub(crate) fn query_command(mut cmd: Command, timeout: Duration) -> Option { + if !cmd.get_envs().any(|(k, _)| k == "PATH") { cmd.env("PATH", search_path()); } let mut child = ide_core::process::prepare(&mut cmd) - .args(args) - .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 6de7d6673..93b15c7f4 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option { } for name in ["build.gradle.kts", "build.gradle"] { let Some(text) = read(name) else { continue }; - if let Some(i) = text.find("JavaLanguageVersion.of(") { - let rest = &text[i + "JavaLanguageVersion.of(".len()..]; - if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) { - return Some(v); + for key in ["JavaLanguageVersion.of(", "jvmToolchain("] { + if let Some(i) = text.find(key) { + let rest = &text[i + key.len()..]; + if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) { + return Some(v); + } } } for key in ["sourceCompatibility", "targetCompatibility"] { @@ -880,7 +882,7 @@ mod tests { configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None); let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone(); assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent"); - let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar"); + let patched = Path::new("/managed/lombok-1.18.48-openidea.3.jar"); let mut fixed = jdtls(); configure_jdtls( &mut fixed, @@ -894,6 +896,23 @@ mod tests { Some(patched), ); assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent"); + let mut previous = jdtls(); + configure_jdtls( + &mut previous, + Path::new("/p"), + Path::new("/data"), + &[], + None, + None, + JDTLS_MIN_JAVA, + &[], + Some(Path::new("/managed/lombok-1.18.48-openidea.2.jar")), + ); + assert_ne!( + data(&previous), + data(&fixed), + "the new patch must rebuild classes compiled with the previous bundle" + ); let before = cfg.clone(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar)); assert_eq!(cfg, before, "idempotent"); @@ -1121,6 +1140,14 @@ mod tests { assert_eq!(project_java_version(dir), Some(11)); std::fs::write(dir.join("build.gradle.kts"), "sourceCompatibility = '1.8'").unwrap(); assert_eq!(project_java_version(dir), Some(8)); + for (script, version) in [ + ("kotlin { jvmToolchain(21) }", Some(21)), + ("kotlin { jvmToolchain(17) }", Some(17)), + ("kotlin { jvmToolchain(projectVersion) }", None), + ] { + std::fs::write(dir.join("build.gradle.kts"), script).unwrap(); + assert_eq!(project_java_version(dir), version); + } } } @@ -1169,7 +1196,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.3.0.jar"); + let extension = data_dir.join("java-usages-1.7.0.jar"); let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 40a61920b..7640ed6f7 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -82,7 +82,7 @@ pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[Build // ------------------------------------------------------------------ cache /// Size and modification time of each manifest (`None`: missing). -type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>; +pub(crate) type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>; struct Cached { /// Settings the result depends on. @@ -97,7 +97,7 @@ pub fn invalidate_go(root: &Path) { CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go")); } -fn stamp(files: &[PathBuf]) -> Stamp { +pub(crate) fn stamp(files: &[PathBuf]) -> Stamp { files .iter() .map(|f| { diff --git a/crates/ide-sdk/src/lombok.rs b/crates/ide-sdk/src/lombok.rs index 3a4b41927..3544e7031 100644 --- a/crates/ide-sdk/src/lombok.rs +++ b/crates/ide-sdk/src/lombok.rs @@ -507,7 +507,7 @@ mod tests { let cache = repo.join("org/projectlombok/lombok/1.18.48"); fs::create_dir_all(&cache).unwrap(); fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap(); - let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.2.jar"); + let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.3.jar"); let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone())); assert_eq!(selected.jar(), Some(patched.as_path())); assert_eq!(selected.problem(), None); diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index 4e1bc6c42..e0b26119c 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -1,12 +1,16 @@ //! The PHP language level: the one chosen in Settings › PHP, else the one the root //! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows); -//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes. +//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes; +//! the PHPStan run that checks saved files; and whether an interpreter loads Xdebug. use crate::apply::{merge, merge_init_options, setting_path}; use ide_core::settings::Settings; +use ide_core::{IdeError, IdeResult}; use ide_language::LanguageServerConfig; use serde_json::{Value, json}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; /// A PHP version and where it comes from. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] @@ -50,6 +54,43 @@ pub fn composer_level(root: &Path) -> Option { .map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require)) } +/// Why `composer install` is due in a Composer project. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum ComposerInstall { + /// No `vendor/autoload.php`. + Missing, + /// A package of `composer.lock` is not installed, or at another version. + Outdated, +} + +/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and +/// whether its dependencies need `composer install`. +pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> { + let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?; + let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?; + let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor")); + if !vendor.join("autoload.php").is_file() { + return Some((dir.to_path_buf(), ComposerInstall::Missing)); + } + let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::(&t).ok()); + let lock = read(dir.join("composer.lock"))?; + let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null); + // Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself. + let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default(); + let versions: std::collections::HashMap<&str, &str> = + packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect(); + let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true); + let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] }; + let outdated = sections + .iter() + .filter_map(|section| lock.get(*section).and_then(Value::as_array)) + .flatten() + .filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))) + .any(|(name, version)| versions.get(name) != Some(&version)); + outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated)) +} + /// Gives each PHP version its own Intelephense workspace storage (below `data_dir`). /// Intelephense keeps the built-in functions of the version a workspace was first indexed /// with in that storage, and reuses them after the version changes. Without a version, or @@ -139,6 +180,87 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec { .collect() } +/// `vendor/bin/phpstan analyse` of `files` with a JSON report and the configuration file of +/// Settings › PHP, run from `root` with Xdebug off: in debug mode it would try to reach a +/// debugger on every run. The selected PHP runs the script itself (and is first on `PATH`, as for +/// PHPUnit), so a `php8.1` is not replaced by the `php` its `#!/usr/bin/env php` finds, and +/// Windows needs no `phpstan.bat`, whose `cmd` limits the command line. `None` when the project +/// has no PHPStan. +pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option { + let bin = root.join("vendor/bin/phpstan"); + if !bin.is_file() { + return None; + } + let php = setting_path(root, &s.languages.php.interpreter) + .or_else(|| crate::exec::which("php")) + .unwrap_or_else(|| PathBuf::from("php")); + let mut cmd = Command::new(php); + cmd.current_dir(root).arg(bin).args(["analyse", "--error-format=json", "--no-progress"]); + if let Some(config) = setting_path(root, &s.languages.php.phpstan_config) { + cmd.arg("--configuration").arg(config); + } + cmd.args(files); + cmd.env("XDEBUG_MODE", "off"); + if let Some(dir) = crate::apply::php_dir(root, s) { + cmd.env("PATH", crate::exec::path_with(&[&dir])); + } + Some(cmd) +} + +/// A PHP interpreter's version and the Xdebug it loads. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PhpXdebug { + pub php: ide_core::IpcPath, + /// `PHP_VERSION`. + pub php_version: String, + /// The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets. + pub xdebug: Option, +} + +const XDEBUG_TIMEOUT: Duration = Duration::from_secs(4); + +/// `php` printing its version and the loaded Xdebug's (nothing without one), with Xdebug off: a +/// php.ini with `xdebug.start_with_request=yes` would connect to a listening debugger. Xdebug stays +/// loaded in mode `off`, so it still reports its version. Run in `cwd`, where a version manager's +/// `php` shim (asdf, mise, phpenv) picks the PHP it starts. +fn xdebug_command(php: &Path, cwd: Option<&Path>) -> Command { + let mut cmd = Command::new(php); + cmd.args(["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#]).env("XDEBUG_MODE", "off"); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + cmd +} + +/// The version of `php` and of the Xdebug it loads, run in `cwd`. An error when `php` doesn't run, +/// times out or prints something else. +pub fn xdebug(php: &Path, cwd: Option<&Path>) -> IdeResult { + let out = crate::exec::query_command(xdebug_command(php, cwd), XDEBUG_TIMEOUT) + .ok_or_else(|| IdeError::unavailable(format!("cannot run {}", php.display())))?; + let (php_version, xdebug) = parse_xdebug(&out) + .ok_or_else(|| IdeError::invalid(format!("{} printed no PHP version: {out}", php.display())))?; + Ok(PhpXdebug { php: php.to_path_buf().into(), php_version, xdebug }) +} + +/// The PHP version, then the Xdebug version when one is loaded: the last lines of the output, after +/// the startup warnings PHP prints first with `display_startup_errors` (as a broken Xdebug build does). +fn parse_xdebug(out: &str) -> Option<(String, Option)> { + let lines: Vec<&str> = out.lines().map(str::trim).collect(); + match lines[..] { + [.., php, xdebug] if is_version(php) && is_version(xdebug) => Some((php.into(), Some(xdebug.into()))), + [.., php] if is_version(php) => Some((php.into(), None)), + _ => None, + } +} + +/// `8.3.14`, `8.1.2-1ubuntu2.14`, `3.5.0alpha3`. +fn is_version(s: &str) -> bool { + s.starts_with(|c: char| c.is_ascii_digit()) + && s.contains('.') + && s.chars().all(|c| c.is_ascii_alphanumeric() || ".-+~_".contains(c)) +} + type Version = (u32, u32, u32); impl PhpLanguageLevel { @@ -197,6 +319,39 @@ fn lower_bound(alternative: &str) -> Option { mod tests { use super::*; + #[test] + fn composer_install_is_due_without_vendor_or_when_the_lock_differs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let app = root.join("app"); + std::fs::create_dir_all(app.join("src")).unwrap(); + let file = app.join("src/Kernel.php"); + assert_eq!(composer_install(&file, root), None, "not a Composer project"); + std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap(); + assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing))); + std::fs::create_dir_all(app.join("lib/composer")).unwrap(); + std::fs::write(app.join("lib/autoload.php"), ", explicit: &str) -> Option { levels(composer, explicit).0.map(|l| l.version) } @@ -350,6 +505,115 @@ mod tests { ); } + fn phpstan(root: &Path, s: &Settings) -> Option { + let cmd = phpstan_command(root, s, &[root.join("src").join("A.php"), root.join("src").join("B.php")])?; + assert_eq!(cmd.get_current_dir(), Some(root)); + Some(cmd) + } + + fn text(s: &std::ffi::OsStr) -> String { + s.to_string_lossy().into_owned() + } + + fn env(cmd: &Command) -> Vec<(String, Option)> { + cmd.get_envs().map(|(k, v)| (text(k), v.map(text))).collect() + } + + #[test] + fn phpstan_checks_the_files_on_the_selected_php_without_xdebug() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut s = Settings::default(); + assert!(phpstan(root, &s).is_none(), "no vendor/bin/phpstan, no run"); + + let bin = root.join("vendor/bin/phpstan"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::write(&bin, "").unwrap(); + let cmd = phpstan(root, &s).unwrap(); + // The PHP on PATH unless one is selected. + assert_eq!(Path::new(cmd.get_program()).file_stem(), Some("php".as_ref())); + let file = |name: &str| root.join("src").join(name).to_string_lossy().into_owned(); + assert_eq!( + cmd.get_args().map(text).collect::>(), + [ + &*bin.to_string_lossy(), + "analyse", + "--error-format=json", + "--no-progress", + &file("A.php"), + &file("B.php") + ] + ); + assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]); + + // A selected PHP runs PHPStan whatever its name, and is first on PATH for what PHPStan starts. + let php = root.join("php85/bin").join(crate::exe("php8.5")); + s.languages.php.interpreter = php.to_string_lossy().into_owned(); + let cmd = phpstan(root, &s).unwrap(); + assert_eq!(cmd.get_program(), php.as_os_str()); + let env = env(&cmd); + let path = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.clone()).expect("PATH"); + assert_eq!(std::env::split_paths(&path).next(), Some(root.join("php85/bin"))); + assert!(env.contains(&("XDEBUG_MODE".to_string(), Some("off".to_string())))); + } + + #[test] + fn phpstan_uses_the_configuration_file_of_the_settings() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let bin = root.join("vendor/bin/phpstan"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::write(&bin, "").unwrap(); + let args = |config: &str| { + let mut s = Settings::default(); + s.languages.php.phpstan_config = config.into(); + phpstan(root, &s).unwrap().get_args().map(text).collect::>() + }; + let resolved = root.join("build/phpstan.ci.neon").to_string_lossy().into_owned(); + assert_eq!(args(" build/phpstan.ci.neon ")[4..6], ["--configuration".to_string(), resolved]); + let absolute = if cfg!(windows) { "C:/etc/phpstan.neon" } else { "/etc/phpstan.neon" }; + assert_eq!(args(absolute)[4..6], ["--configuration", absolute]); + // Empty: PHPStan finds phpstan.neon, phpstan.neon.dist or phpstan.dist.neon itself. + assert!(!args(" ").iter().any(|a| a.starts_with("--configuration"))); + } + + #[test] + fn the_xdebug_probe_runs_with_xdebug_off() { + let php = Path::new("/opt/homebrew/opt/php@8.1/bin/php"); + let project = Path::new("/work/app"); + let cmd = xdebug_command(php, Some(project)); + assert_eq!(cmd.get_program(), php.as_os_str()); + assert_eq!(cmd.get_current_dir(), Some(project), "where a php shim picks the version"); + assert_eq!( + cmd.get_args().map(text).collect::>(), + ["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#] + ); + assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]); + assert_eq!(xdebug_command(php, None).get_current_dir(), None); + } + + #[test] + fn the_xdebug_probe_reads_both_versions() { + let found = |php: &str, xdebug: Option<&str>| Some((php.to_string(), xdebug.map(str::to_string))); + assert_eq!(parse_xdebug("8.3.14\n3.4.7"), found("8.3.14", Some("3.4.7"))); + assert_eq!(parse_xdebug("8.1.33"), found("8.1.33", None)); + assert_eq!(parse_xdebug("8.1.2-1ubuntu2.14\r\n3.5.0alpha3"), found("8.1.2-1ubuntu2.14", Some("3.5.0alpha3"))); + assert_eq!(parse_xdebug("5.6.40\n2.5.5"), found("5.6.40", Some("2.5.5"))); + // display_startup_errors prints startup warnings before the script's output. + let warning = "\nWarning: PHP Startup: Unable to load dynamic library 'xdebug.so' in Unknown on line 0"; + assert_eq!(parse_xdebug(&format!("{warning}\n8.1.33")), found("8.1.33", None)); + assert_eq!(parse_xdebug(&format!("{warning}\n8.3.14\n3.4.7")), found("8.3.14", Some("3.4.7"))); + } + + #[test] + fn the_xdebug_probe_fails_on_other_output() { + for out in ["", " ", "Could not open input file: -r", "8.3.14\nnot a version", "PHP", "8"] { + assert_eq!(parse_xdebug(out), None, "{out:?}"); + } + let missing = Path::new("/nonexistent/bin/php"); + assert!(xdebug(missing, None).is_err(), "no PHP is an error, not a PHP without Xdebug"); + } + #[test] fn each_level_names_its_source() { let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source }); diff --git a/crates/ide-sdk/src/python.rs b/crates/ide-sdk/src/python.rs index dcaecebe3..ed12cf32b 100644 --- a/crates/ide-sdk/src/python.rs +++ b/crates/ide-sdk/src/python.rs @@ -16,25 +16,144 @@ pub struct PythonPackage { pub version: String, } -/// Creates a virtualenv at `location` with `base -m venv` and returns its interpreter. +/// The tool that creates a new Python environment in "Add Python Interpreter". +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "lowercase")] +pub enum VenvTool { + /// `base -m venv `. + Venv, + /// `uv venv --python ` (uv must be on PATH). + Uv, + /// `poetry env use ` in the project, then `poetry env info --path`. + Poetry, + /// `conda create -p -y` (conda must be on PATH). + Conda, +} + +/// Creates a virtualenv at `location` with `tool` and returns its interpreter. +/// `base` is the interpreter uv / venv build on, or the one poetry's env uses. pub fn create_venv( + tool: VenvTool, base: &Path, location: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String), ) -> IdeResult { - if location.join("pyvenv.cfg").is_file() { + create_venv_with(tool, base, location, which, cancel, on_line) +} + +/// [`create_venv`] with the tool lookup injectable (tests fake `uv` / `poetry`). +pub(crate) fn create_venv_with( + tool: VenvTool, + base: &Path, + location: &Path, + which: impl Fn(&str) -> Option, + cancel: &CancellationToken, + on_line: &dyn Fn(String), +) -> IdeResult { + match tool { + VenvTool::Venv | VenvTool::Uv => { + if location.join("pyvenv.cfg").is_file() { + return Err(IdeError::new( + ide_core::error::ErrorKind::AlreadyExists, + format!("{} already contains a virtual environment", location.display()), + )); + } + if let Some(parent) = location.parent() { + std::fs::create_dir_all(parent)?; + } + let (program, args) = match tool { + VenvTool::Uv => { + let uv = which("uv").ok_or_else(|| { + IdeError::unavailable("uv is not installed (install it from https://docs.astral.sh/uv)") + })?; + ( + uv, + vec![ + "venv".to_owned(), + location.to_string_lossy().into_owned(), + "--python".to_owned(), + base.to_string_lossy().into_owned(), + ], + ) + } + _ => (base.to_path_buf(), venv_args(location)), + }; + stream(&program, &args, None, &[], cancel, on_line)?; + let python = location.join(bin_dir()).join(exe("python")); + crate::discover::python_sdk(&python, "venv", location.parent().and_then(Path::parent)) + .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) + } + VenvTool::Poetry => create_poetry_env(base, location, &which, cancel, on_line), + VenvTool::Conda => create_conda_env(base, location, &which, cancel, on_line), + } +} + +/// `poetry env use ` in the project directory (the parent of `location`, which the +/// dialog fills with `/.venv` only as a hint — poetry keeps its envs under its own +/// cache), then `poetry env info --path` for the interpreter it made or reused. +fn create_poetry_env( + base: &Path, + location: &Path, + which: &dyn Fn(&str) -> Option, + cancel: &CancellationToken, + on_line: &dyn Fn(String), +) -> IdeResult { + let poetry = which("poetry") + .ok_or_else(|| IdeError::unavailable("Poetry is not installed (install it from https://python-poetry.org)"))?; + let project = location + .parent() + .filter(|p| p.is_dir()) + .ok_or_else(|| IdeError::invalid("Poetry environments need an open project directory"))?; + stream( + &poetry, + &["env".to_owned(), "use".to_owned(), base.to_string_lossy().into_owned()], + Some(project), + &[], + cancel, + on_line, + )?; + let home = query_in(&poetry, &["env", "info", "--path"], Some(project), &[], Duration::from_secs(30)) + .map(PathBuf::from) + .ok_or_else(|| IdeError::internal("poetry created no environment".to_owned()))?; + let python = home.join(bin_dir()).join(exe("python")); + crate::discover::python_sdk(&python, "venv", Some(project)) + .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) +} + +/// `conda create -p -y` builds an environment at the requested prefix. +/// `base` pins the Python version (`python=`) when it reports one, so the +/// created env matches the interpreter the user picked rather than conda's default. +fn create_conda_env( + base: &Path, + location: &Path, + which: &dyn Fn(&str) -> Option, + cancel: &CancellationToken, + on_line: &dyn Fn(String), +) -> IdeResult { + if location.join("conda-meta").is_dir() { return Err(IdeError::new( ide_core::error::ErrorKind::AlreadyExists, - format!("{} already contains a virtual environment", location.display()), + format!("{} already contains a conda environment", location.display()), )); } + let conda = which("conda") + .ok_or_else(|| IdeError::unavailable("conda is not installed (install it from https://docs.conda.io)"))?; if let Some(parent) = location.parent() { std::fs::create_dir_all(parent)?; } - stream(base, &venv_args(location), None, &[], cancel, on_line)?; + let mut args = vec!["create".to_owned(), "-p".to_owned(), location.to_string_lossy().into_owned(), "-y".to_owned()]; + if let Some(version) = query_in(base, &["--version"], None, &[], Duration::from_secs(15)) + .and_then(|v| v.strip_prefix("Python ").map(str::to_owned)) + { + let short = version.split('.').take(2).collect::>().join("."); + if !short.is_empty() { + args.push(format!("python={short}")); + } + } + stream(&conda, &args, None, &[], cancel, on_line)?; let python = location.join(bin_dir()).join(exe("python")); - crate::discover::python_sdk(&python, "venv", location.parent().and_then(Path::parent)) + crate::discover::python_sdk(&python, "conda", location.parent().and_then(Path::parent)) .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) } diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs index b7ce8df4b..c0bbed7ca 100644 --- a/crates/ide-sdk/src/ruby.rs +++ b/crates/ide-sdk/src/ruby.rs @@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option { ruby.parent().map(Path::to_path_buf) } +/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked +/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`. +pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec { + query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT) + .map(|out| ide_core::tool_path::gem_bin_dirs(&out)) + .unwrap_or_default() +} + type NamedRoots = Vec<(String, PathBuf)>; /// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 84a45bb8d..1442b61fc 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1,6 +1,6 @@ use crate::apply::{ apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter, - terminal_activation, tsdk, without_derived, + ruff_configuration, terminal_activation, tsdk, without_derived, }; use crate::python::parse_pip_list; use crate::{bin_dir, exe, venv_of}; @@ -38,6 +38,7 @@ fn server(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, } @@ -115,6 +116,26 @@ fn jdtls_vm_args() { assert_eq!(cfg.args, ["--jvm-arg=-Xmx2g", "--jvm-arg=-javaagent:/opt/lombok.jar"]); } +#[test] +fn jdtls_honors_the_configured_maven_local_repository() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("m2-repo"); + let mut s = Settings::default(); + s.languages.java.maven.local_repository = repo.to_string_lossy().into_owned(); + let mut cfg = server("jdtls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(cfg.args, [format!("--jvm-arg=-Dmaven.repo.local={}", repo.to_string_lossy())]); + // A `-Dmaven.repo.local` the user put in their own jdtls VM options wins. + s.languages.java.jdtls_vm_args = "-Xmx2g -Dmaven.repo.local=/user/repo".into(); + let mut cfg = server("jdtls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(cfg.args, ["--jvm-arg=-Xmx2g", "--jvm-arg=-Dmaven.repo.local=/user/repo"]); + // Unset: jdtls keeps m2e's own default (no arg at all). + let mut cfg = server("jdtls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &Settings::default()); + assert!(cfg.args.iter().all(|a| !a.contains("maven.repo.local"))); +} + #[test] fn rust_analyzer_check_command_and_toolchain() { if cfg!(windows) { @@ -271,6 +292,41 @@ fn project_jdk_and_maven_settings() { assert_eq!(project_jdk(tmp.path(), &s), None); } +#[test] +fn maven_options_carry_the_thread_count_into_the_command_line() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + assert!(maven_options(tmp.path(), &s).is_empty(), "empty settings add nothing"); + + s.languages.java.maven.user_settings = "m2/settings.xml".into(); + s.languages.java.maven.local_repository = "m2/repo".into(); + s.languages.java.maven.threads = "4".into(); + let settings_file = tmp.path().join("m2/settings.xml").to_string_lossy().into_owned(); + let repo = tmp.path().join("m2/repo").to_string_lossy().into_owned(); + assert_eq!( + maven_options(tmp.path(), &s), + ["-s", settings_file.as_str(), &format!("-Dmaven.repo.local={repo}"), "-T", "4"] + ); + + // A core multiplier passes through; anything Maven would reject is dropped + // rather than failing every build. + s.languages.java.maven.threads = "2.5C".into(); + assert!(maven_options(tmp.path(), &s).ends_with(&["-T".to_owned(), "2.5C".to_owned()])); + s.languages.java.maven.threads = "abc".into(); + assert!(!maven_options(tmp.path(), &s).iter().any(|a| a == "-T")); + + // Run configurations get the options ahead of the goals, once per launch. + s.languages.java.maven.threads = "2C".into(); + let mut configs = vec![run("maven", "./mvnw", &["package"]), run("maven", "./mvnw", &["-T", "8", "package"])]; + apply_to_run_configs(&mut configs, tmp.path(), &s); + let args: Vec = configs[0].args.clone(); + assert_eq!(args[args.len() - 3..].join(" "), "-T 2C package", "the settings' thread count leads the goals"); + let args: Vec = configs[1].args.clone(); + assert_eq!(args[args.len() - 3..].join(" "), "-T 8 package", "the configuration's own -T wins"); + apply_to_run_configs(&mut configs, tmp.path(), &s); + assert_eq!(configs[0].args.iter().filter(|a| a.as_str() == "-T").count(), 1, "applied once"); +} + #[test] fn saved_maven_goal_configs_take_the_configured_launcher() { if cfg!(windows) { @@ -481,6 +537,9 @@ fn language_server_settings_disable_add_and_override() { (ruff.command.as_str(), ruff.args.as_slice()), ("/opt/ruff", &["server".to_string(), "--preview".to_string()][..]) ); + s.languages.ruff.configuration_directory = "ruff-config".into(); + let defined = [server("ruff")]; + assert!(!language_servers("python", &defined, &s)[0].require_root_marker); s.language_servers.insert( "rust-analyzer".into(), @@ -523,6 +582,38 @@ fn language_server_settings_disable_add_and_override() { assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()])); } +#[test] +fn ruff_configuration_directory_resolves_files_and_validates_pyproject() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let dir = root.join("shared ruff"); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(ruff_configuration(root, "").unwrap(), None); + assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no")); + + let ruff = dir.join("ruff.toml"); + std::fs::write(&ruff, "line-length = 100\n").unwrap(); + assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(ruff.clone())); + assert_eq!(ruff_configuration(root, &ruff.to_string_lossy()).unwrap(), Some(ruff)); + + std::fs::remove_file(dir.join("ruff.toml")).unwrap(); + let pyproject = dir.join("pyproject.toml"); + std::fs::write(&pyproject, "[project]\nname = \"x\"\n").unwrap(); + assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no")); + std::fs::write(&pyproject, "[tool.ruff]\nline-length = 100\n").unwrap(); + assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(pyproject.clone())); + + let mut settings = Settings::default(); + settings.languages.ruff.configuration_directory = dir.to_string_lossy().into_owned(); + let mut cfg = server("ruff"); + configure_server(&mut cfg, root, root, &settings); + let configured = dir.join("pyproject.toml").to_string_lossy().into_owned(); + assert_eq!( + cfg.settings.as_ref().and_then(|s| s.get("configuration")).and_then(|v| v.as_str()), + Some(configured.as_str()) + ); +} + #[test] fn jdtls_jdk_setting() { if cfg!(windows) { @@ -629,6 +720,82 @@ fn venv_creation_ignores_modules_in_the_working_directory() { assert!(location.join("pyvenv.cfg").is_file()); } +#[test] +fn env_tools_are_reported_missing_before_they_run() { + // Without uv / Poetry on PATH the error names the install, not a spawn failure. + let dir = tempfile::tempdir().unwrap(); + let location = dir.path().join(".venv"); + let base = dir.path().join("python"); + fn none(_: &str) -> Option { + None + } + let uv = crate::python::create_venv_with( + crate::python::VenvTool::Uv, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(uv.message.contains("uv is not installed"), "{}", uv.message); + let poetry = crate::python::create_venv_with( + crate::python::VenvTool::Poetry, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(poetry.message.contains("Poetry is not installed"), "{}", poetry.message); + let conda = crate::python::create_venv_with( + crate::python::VenvTool::Conda, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(conda.message.contains("conda is not installed"), "{}", conda.message); + // An existing environment is refused before any tool runs. + std::fs::create_dir_all(&location).unwrap(); + std::fs::write(location.join("pyvenv.cfg"), "home = /python").unwrap(); + let again = crate::python::create_venv_with( + crate::python::VenvTool::Uv, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(again.message.contains("already contains a virtual environment"), "{}", again.message); +} + +#[test] +fn an_existing_conda_environment_is_not_recreated() { + // `conda-meta/` marks a conda prefix: refuse before `conda create` would fail obscurely. + let dir = tempfile::tempdir().unwrap(); + let location = dir.path().join("envs").join("ds"); + let base = dir.path().join("python"); + std::fs::create_dir_all(location.join("conda-meta")).unwrap(); + fn none(_: &str) -> Option { + None + } + let err = crate::python::create_venv_with( + crate::python::VenvTool::Conda, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(err.message.contains("already contains a conda environment"), "{}", err.message); +} + #[test] fn neutral_dir_is_fresh_and_empty() { let a = crate::python::neutral_dir().unwrap(); @@ -1287,9 +1454,50 @@ fn php_sources_and_selected_interpreter() { assert_eq!(source("/opt/homebrew/opt/php@8.2/bin/php"), "homebrew"); assert_eq!(source("/home/me/bin/php"), "path"); + use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in}; + let sdk = |path: &str, version: &str| crate::Sdk { + path: std::path::PathBuf::from(path).into(), + name: format!("PHP {version}"), + version: Some(version.into()), + source: "path".into(), + home: None, + extra: None, + }; + let mut sdks = vec![ + sdk("/opt/homebrew/bin/php", "8.4.1"), + sdk("/opt/homebrew/opt/php@8.1/bin/php", "8.1.31"), + sdk("/opt/homebrew/opt/php@8.2/bin/php", "8.2.20"), + sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"), + sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"), + sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"), + sdk("/usr/bin/php", "8.4.1"), + ]; + newest_first(&mut sdks); + let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect(); + assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]); + // Stable: on equal versions the PATH install stays first. + assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php")); + for name in ["php8.1", "php81", "php8", "php8.3.exe"] { + assert!(is_versioned_php_name(name), "{name}"); + } + for name in ["php", "php-cgi8.1", "phpize8.1", "phpdbg8.1", "php-fpm8.1", "php8.", "php.8", "php8.1.2x", "phpunit"] + { + assert!(!is_versioned_php_name(name), "{name}"); + } + if cfg!(windows) { return; // Unix paths } + let bins = tempfile::tempdir().unwrap(); + for name in ["php", "php8.3", "php8.1", "php-cgi8.1", "phpize8.1", "phpdbg8.1"] { + std::fs::write(bins.path().join(name), "").unwrap(); + } + std::fs::create_dir(bins.path().join("php9.0")).unwrap(); + assert_eq!( + versioned_php_in([bins.path().to_path_buf()]), + vec![bins.path().join("php8.1"), bins.path().join("php8.3")] + ); + let tmp = tempfile::tempdir().unwrap(); let mut s = Settings::default(); assert_eq!(crate::apply::php_dir(tmp.path(), &s), None); @@ -1539,3 +1747,40 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() { assert_eq!(next[1].args, ["-Dmaven.repo.local=/m3/repo", "-s", "/mine.xml", "test"]); } } + +#[test] +fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_project_relative() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let outer = fake_venv(root, ".venv"); + let nested = fake_venv(&root.join("apps").join("python"), ".venv"); + let mut config = run("python", "python3", &["main.py"]); + config.extras.module = Some("apps/python".into()); + apply_to_run_configs(std::slice::from_mut(&mut config), root, &Settings::default()); + assert_eq!(config.env["VIRTUAL_ENV"], nested.parent().unwrap().parent().unwrap().to_string_lossy()); + // A workspace member without its own virtualenv uses the one at the project root (uv workspaces). + let mut member = run("pytest", "python3", &["-m", "pytest"]); + member.extras.module = Some("packages/lib".into()); + apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default()); + assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy()); + let mut settings = Settings::default(); + settings.languages.python.interpreter = "custom/bin/python".into(); + let mut config = run("python", "python3", &["main.py"]); + config.extras.module = Some("apps/python".into()); + apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings); + assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy()); +} + +#[test] +fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let outer = fake_venv(root, ".venv"); + let inner = fake_venv(&root.join("apps/api"), ".venv"); + let near = root.join("apps/api/src/package"); + let mut settings = Settings::default(); + assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner)); + assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer)); + settings.languages.python.interpreter = "selected/python".into(); + assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python"))); +} diff --git a/crates/ide-sdk/src/vue.rs b/crates/ide-sdk/src/vue.rs index 4c32cf997..1a460d28c 100644 --- a/crates/ide-sdk/src/vue.rs +++ b/crates/ide-sdk/src/vue.rs @@ -1,13 +1,30 @@ //! Vue Language Server (Volar 3): the TypeScript it loads as a library, and the //! `@vue/typescript-plugin` that vtsls' tsserver hosts for it (hybrid mode). +use crate::libraries::{Stamp, package_dependencies, stamp}; use crate::npm::{find_command, package_dir}; +use std::collections::{BTreeMap, HashMap}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::Duration; pub(crate) const SERVER: &str = "vue-language-server"; const PACKAGE: &str = "@vue/language-server"; const PLUGIN: &str = "@vue/typescript-plugin"; +/// The file extension the plugin adds to tsserver. +pub(crate) const EXTENSION: &str = "vue"; +const CLI_SERVICE: &str = "@vue/cli-service"; +const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30); +const ALIASES_MARKER: &str = "openidea-webpack-aliases:"; +/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever +/// the configuration itself prints. +const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \ + const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \ + process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));"; + +type Paths = BTreeMap>; +static CLI_PATHS: LazyLock>> = LazyLock::new(Default::default); /// The TypeScript `lib` directory has the language-service API (`typescript.js`). /// TypeScript 7, the native port, ships only `tsc`. @@ -36,6 +53,81 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value { }) } +/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` → +/// `/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't +/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported +/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the +/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project +/// is trusted: language servers only run then); else the alias Vue CLI always adds. +/// Empty for other projects. +pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths { + let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() }; + if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) { + return Paths::new(); + } + let config = root + .ancestors() + .map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js")) + .find(|f| f.is_file()); + let mut inputs: Vec = + ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect(); + inputs.extend(config.iter().cloned()); + inputs.extend(node.map(Path::to_path_buf)); + let now = stamp(&inputs); + if let Some((then, paths)) = CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).get(root) + && *then == now + { + return paths.clone(); + } + let aliases = config + .zip(node) + .and_then(|(config, node)| webpack_aliases(root, &config, node)) + .unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]); + let paths = alias_paths(&aliases); + CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone())); + paths +} + +/// `[alias, target]` pairs of the webpack configuration module `config`. +fn webpack_aliases(root: &Path, config: &Path, node: &Path) -> Option> { + let root_s = root.to_string_lossy(); + let out = crate::exec::query_in( + node, + &["-e", PRINT_ALIASES, &config.to_string_lossy(), ALIASES_MARKER], + Some(root), + &[("VUE_CLI_CONTEXT", &root_s), ("NODE_ENV", "development")], + CLI_CONFIG_TIMEOUT, + ); + let json = out.as_deref().and_then(|o| o.rsplit_once(ALIASES_MARKER)).map(|(_, json)| json); + let Some(pairs) = json.and_then(|j| serde_json::from_str::>(j).ok()) + else { + tracing::debug!("no webpack aliases from {}", config.display()); + return None; + }; + Some( + pairs + .into_iter() + .filter_map(|(name, target)| Some((name.as_str()?.to_owned(), target.as_str()?.to_owned()))) + .collect(), + ) +} + +/// Directory aliases as `paths`; `name$` (exact module) and file aliases have no equivalent. +fn alias_paths(aliases: &[(String, String)]) -> Paths { + let mut paths = Paths::new(); + for (name, target) in aliases { + let dir = Path::new(target); + if name.is_empty() || name.ends_with('$') || name.contains('*') || !dir.is_absolute() || !dir.is_dir() { + continue; + } + let dir = target.replace('\\', "/"); + let dir = dir.trim_end_matches('/'); + paths.insert(format!("{name}/*"), vec![format!("{dir}/*")]); + paths.insert(name.clone(), vec![dir.to_owned()]); + } + paths +} + pub(crate) fn missing_typescript_message() -> String { "Vue Language Server needs TypeScript 5 or 6 (TypeScript 7 has no language service API). \ Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@6 \ @@ -80,6 +172,59 @@ mod tests { assert_eq!(server_package("not-installed", &path), None); } + fn cli_project(t: &Path) { + std::fs::create_dir_all(t.join("src/components")).unwrap(); + std::fs::write(t.join("package.json"), r#"{"devDependencies":{"@vue/cli-service":"~5.0.0"}}"#).unwrap(); + } + + #[test] + fn vue_cli_projects_get_the_at_alias_without_their_webpack_configuration() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap(); + assert!(cli_paths(t, None).is_empty()); + cli_project(t); + let src = t.join("src").to_string_lossy().replace('\\', "/"); + assert_eq!( + cli_paths(t, None), + Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]) + ); + } + + #[test] + fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() { + let Some(node) = crate::exec::which("node") else { return }; + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + cli_project(t); + let service = t.join("node_modules/@vue/cli-service"); + std::fs::create_dir_all(&service).unwrap(); + // What `vue.config.js` can add, and what the configuration may print while loading. + std::fs::write( + service.join("webpack.config.js"), + r#"const path = require('path'); +console.log('loading plugins'); +module.exports = { resolve: { alias: { + '@': path.join(process.env.VUE_CLI_CONTEXT, 'src'), + comps: path.join(process.env.VUE_CLI_CONTEXT, 'src/components'), + vue$: 'vue/dist/vue.runtime.esm.js', + missing: path.join(process.env.VUE_CLI_CONTEXT, 'nowhere'), +} } };"#, + ) + .unwrap(); + let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/"); + let (src, comps) = (slash(t.join("src")), slash(t.join("src/components"))); + assert_eq!( + cli_paths(t, Some(&node)), + Paths::from([ + ("@".into(), vec![src.clone()]), + ("@/*".into(), vec![format!("{src}/*")]), + ("comps".into(), vec![comps.clone()]), + ("comps/*".into(), vec![format!("{comps}/*")]), + ]) + ); + } + #[test] fn own_typescript_skips_typescript_7() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-terminal/src/integration.rs b/crates/ide-terminal/src/integration.rs index 7e55db422..46f946134 100644 --- a/crates/ide-terminal/src/integration.rs +++ b/crates/ide-terminal/src/integration.rs @@ -6,7 +6,10 @@ //! //! zsh starts with `ZDOTDIR` pointing at generated startup files that source //! the user's own ones first; bash gets an `--init-file` that replays the -//! login sequence; fish gets an `--init-command`. +//! login sequence; fish gets an `--init-command`. The generated `.zshrc` hands +//! `ZDOTDIR` (and, if a startup file resolved it against the generated +//! directory, `HISTFILE`) back to the user for login shells too, so history +//! lands in `~/.zsh_history` despite the `zsh -l` start. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -36,14 +39,20 @@ _openidea_preexec() { printf '\e]133;C\a' } # First, so the command counts as finished before other hooks set the prompt's title. precmd_functions=(_openidea_osc7 ${precmd_functions:#_openidea_osc7}) preexec_functions+=(_openidea_preexec) -if [[ ! -o login ]]; then - ZDOTDIR=$OPENIDEA_USER_ZDOTDIR - unset OPENIDEA_USER_ZDOTDIR _openidea_dir +# Startup files that ran before this one (e.g. /etc/zshrc's +# HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history) resolved HISTFILE against this +# generated directory, since OpenIDEA starts zsh as a login shell (-l) and the +# restore below used to be gated on non-login. Hand both back to the user: a +# HISTFILE they configured themselves is never under $_openidea_dir and is kept. +if [[ $HISTFILE == "$_openidea_dir"/* ]]; then + HISTFILE=${OPENIDEA_USER_ZDOTDIR:-$HOME}/.zsh_history fi +ZDOTDIR=${OPENIDEA_USER_ZDOTDIR:-$HOME} +unset OPENIDEA_USER_ZDOTDIR _openidea_dir "#; const ZLOGIN: &str = r#"# OpenIDEA shell integration (generated) -ZDOTDIR=$OPENIDEA_USER_ZDOTDIR +ZDOTDIR=${OPENIDEA_USER_ZDOTDIR:-$HOME} [[ -f $ZDOTDIR/.zlogin ]] && . $ZDOTDIR/.zlogin unset OPENIDEA_USER_ZDOTDIR _openidea_dir "#; @@ -183,4 +192,28 @@ mod tests { assert_eq!(env["OPENIDEA_USER_ZDOTDIR"], "/home/me/zsh"); assert_eq!(args, vec!["-l"]); } + + #[test] + fn zsh_restores_zdotdir_and_histfile_for_login_shells() { + // OpenIDEA starts zsh as a login shell (`zsh -l`), so the restore at the + // end of the generated .zshrc must not be gated on non-login shells, + // and HISTFILE resolved against the generated directory (e.g. by + // /etc/zshrc's `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history`) must be + // re-pointed at the user's directory. + assert!(!ZSHRC.contains("[[ ! -o login ]]")); + assert!(ZSHRC.contains("HISTFILE=${OPENIDEA_USER_ZDOTDIR:-$HOME}/.zsh_history")); + assert!(ZSHRC.contains("ZDOTDIR=${OPENIDEA_USER_ZDOTDIR:-$HOME}")); + assert!(ZSHRC.contains("unset OPENIDEA_USER_ZDOTDIR _openidea_dir")); + // Login shells that never read .zshrc (non-interactive ones) restore via .zlogin. + assert!(ZLOGIN.contains("ZDOTDIR=${OPENIDEA_USER_ZDOTDIR:-$HOME}")); + assert!(ZLOGIN.contains("unset OPENIDEA_USER_ZDOTDIR _openidea_dir")); + if cfg!(windows) { + return; // apply() only integrates on Unix + } + let mut args = vec!["-l".to_owned()]; + let mut env = BTreeMap::from([("ZDOTDIR".to_owned(), "/home/me/zsh".to_owned())]); + assert!(apply("zsh", &mut args, &mut env)); + let zshrc = std::fs::read_to_string(PathBuf::from(&env["ZDOTDIR"]).join(".zshrc")).unwrap(); + assert!(zshrc.contains("HISTFILE=${OPENIDEA_USER_ZDOTDIR:-$HOME}/.zsh_history")); + } } diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index ebe53e311..c53426a54 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -120,6 +120,7 @@ impl TerminalManager { for (k, v) in env { cmd.env(k, v); } + apply_powershell_env(&shell, &mut cmd); let child = pair .slave @@ -633,6 +634,18 @@ fn terminal_env(user: &BTreeMap) -> BTreeMap { env } +/// Older pwsh versions can crash while formatting a localized update banner +/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment. +fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) { + let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell); + if cfg!(windows) + && (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe")) + && cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty()) + { + cmd.env("POWERSHELL_UPDATECHECK", "Off"); + } +} + /// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare /// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and /// the mingw64 tools out unless `\cmd` happens to be inherited — the Git @@ -938,6 +951,56 @@ mod tests { mod shell_start_on_every_platform { use super::*; + #[test] + fn powershell_update_notifications_default_to_off_on_windows() { + for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"] + { + for value in [None, Some("")] { + let mut cmd = CommandBuilder::new(shell); + cmd.env_clear(); + if let Some(value) = value { + cmd.env("POWERSHELL_UPDATECHECK", value); + } + apply_powershell_env(shell, &mut cmd); + let want = if cfg!(windows) { Some("Off") } else { value }; + assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}"); + } + } + } + + #[test] + fn powershell_update_notification_preferences_are_preserved() { + for value in ["Default", "LTS", "Off"] { + let mut cmd = CommandBuilder::new("pwsh.exe"); + cmd.env_clear(); + cmd.env("POWERSHELL_UPDATECHECK", value); + apply_powershell_env("pwsh.exe", &mut cmd); + assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value))); + } + } + + #[cfg(windows)] + #[test] + fn powershell_update_notification_preferences_ignore_key_case() { + let mut cmd = CommandBuilder::new("pwsh.exe"); + cmd.env_clear(); + cmd.env("POWERSHELL_UPDATECHECK", "Off"); + cmd.env("powershell_updatecheck", "Default"); + apply_powershell_env("pwsh.exe", &mut cmd); + assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default"))); + assert_eq!(cmd.iter_full_env_as_str().count(), 1); + } + + #[test] + fn other_shells_keep_their_update_notification_environment() { + for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] { + let mut cmd = CommandBuilder::new(shell); + cmd.env_clear(); + apply_powershell_env(shell, &mut cmd); + assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}"); + } + } + #[test] fn posix_shells_start_as_login_shells_everywhere() { let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index b6a7a692b..538d48331 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -8,6 +8,18 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +/// A selection to format. Offsets are UTF-16 code units for Prettier; the +/// one-based line and column values are Unicode code points for Ruff. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FormatRange { + pub start_offset: u32, + pub end_offset: u32, + pub start_line: u32, + pub start_col: u32, + pub end_line: u32, + pub end_col: u32, +} + /// Walk from the file's directory upward, including workspace parents (monorepos). pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) @@ -17,6 +29,24 @@ pub fn display_name(tool: &str) -> &str { crate::registry::find(tool).map_or(tool, |t| t.display) } +/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhpCsFixer { + pub config: PathBuf, + pub bin: PathBuf, +} + +/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as +/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`. +/// `None` without both: Reformat Code keeps the language server. +pub fn php_cs_fixer(file: &Path) -> Option { + let config = file.parent()?.ancestors().find_map(|dir| { + [".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file()) + })?; + let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?; + Some(PhpCsFixer { config, bin }) +} + fn package_bin(package: &Path, tool: &str) -> IdeResult { let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) @@ -32,9 +62,13 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult { Ok(path) } -/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only. pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { - tool == "prettier" && config.range + matches!(tool, "prettier" | "ruff") && config.range +} + +fn is_formatter(tool: &str) -> bool { + crate::registry::find(tool).is_some_and(|t| t.kind == crate::ToolKind::Formatter || t.name == "ruff") } impl Tools { @@ -44,7 +78,7 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { + if !is_formatter(tool) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } let package = Path::new("node_modules").join(tool); @@ -73,18 +107,36 @@ impl Tools { } } -/// Both tools resolve their configuration and plugins themselves. Prettier's -/// project configuration overrides the editor indentation passed here; oxfmt has -/// no such flags and reads its configuration and `.editorconfig` only. +/// Every tool resolves its configuration itself. Prettier's project configuration +/// overrides the editor indentation passed here; oxfmt and Ruff read their own +/// configuration files. Ruff also discovers the nearest `pyproject.toml`, +/// `ruff.toml`, or `.ruff.toml` from the stdin filename. pub fn arguments( tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, - range: Option<(u32, u32)>, + range: Option, + config_file: Option<&Path>, ) -> Vec { let mut args: Vec = Vec::new(); + if tool == "ruff" { + args.push("format".into()); + if let Some(config_file) = config_file { + args.extend(["--config".into(), config_file.as_os_str().to_owned()]); + } + args.extend(["--stdin-filename".into(), file.as_os_str().to_owned(), "-".into()]); + if supports_range(tool, config) + && let Some(range) = range + { + args.extend([ + "--range".into(), + format!("{}:{}-{}:{}", range.start_line, range.start_col, range.end_line, range.end_col).into(), + ]); + } + return args; + } if tool == "prettier" { let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { "mdx" @@ -107,9 +159,14 @@ pub fn arguments( } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); if supports_range(tool, config) - && let Some((start, end)) = range + && let Some(range) = range { - args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); + args.extend([ + "--range-start".into(), + range.start_offset.to_string().into(), + "--range-end".into(), + range.end_offset.to_string().into(), + ]); } // CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files. for name in [".prettierignore", ".gitignore"] { @@ -120,6 +177,33 @@ pub fn arguments( args } +impl Tools { + /// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the + /// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`. + pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult { + let php = match php { + Some(php) => php.to_path_buf(), + None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?, + }; + let mut cmd = self.command(php); + cmd.arg(&fixer.bin) + .args(["fix", "--config"]) + .arg(&fixer.config) + .args(["--using-cache=no", "--no-interaction", "--show-progress=none"]) + .arg(target); + if let Some(dir) = fixer.config.parent() { + cmd.current_dir(dir); + } + Ok(cmd) + } +} + +/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place. +pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel) + .map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim()))) +} + pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } @@ -181,8 +265,15 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = - arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let range = Some(FormatRange { + start_offset: 3, + end_offset: 9, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 7, + }); + let args = arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, range, None); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -217,7 +308,9 @@ mod tests { ); let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; - let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let range = + Some(FormatRange { start_offset: 3, end_offset: 9, start_line: 1, start_col: 1, end_line: 1, end_col: 7 }); + let args = arguments("oxfmt", &config, &file, 4, false, range, None); let ignore = root.join(".prettierignore").into_os_string(); assert_eq!( args[..4], @@ -229,4 +322,38 @@ mod tests { let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); } + + #[test] + fn ruff_formats_stdin_and_uses_its_config_and_range_flags() { + let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true }; + let file = Path::new("/project with spaces/src/example.py"); + let range = Some(FormatRange { + start_offset: 10, + end_offset: 20, + start_line: 2, + start_col: 5, + end_line: 4, + end_col: 1, + }); + let config_file = Path::new("/shared/config/ruff.toml"); + let args: Vec<_> = arguments("ruff", &config, file, 4, true, range, Some(config_file)) + .into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + args, + [ + "format", + "--config", + "/shared/config/ruff.toml", + "--stdin-filename", + "/project with spaces/src/example.py", + "-", + "--range", + "2:5-4:1", + ] + ); + assert!(supports_range("ruff", &config)); + assert!(!args.iter().any(|arg| arg == "--stdin-filepath")); + } } diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 881393d6c..dbdddfa5b 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

{ Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" })) } - Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None, + Method::Bundled { .. } + | Method::Rustup { .. } + | Method::PipUser { .. } + | Method::PipTarget { .. } + | Method::GemActive { .. } => None, } } @@ -429,6 +433,9 @@ pub struct Tools { /// Executables found outside the search path (app bundles), by command. found: BTreeMap, version_timeout: Duration, + /// The project the tools are used for: tools that install into "the active runtime" (gems) run here, + /// so a version manager picks the project's Ruby. + work_dir: Option, } impl Tools { @@ -450,6 +457,7 @@ impl Tools { custom: BTreeMap::new(), found: BTreeMap::new(), version_timeout: Duration::from_secs(4), + work_dir: None, } } @@ -465,10 +473,25 @@ impl Tools { self } + /// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`. + pub fn with_work_dir(mut self, dir: Option) -> Self { + self.work_dir = dir.filter(|d| d.is_dir()); + self + } + pub fn root(&self) -> &Path { &self.root } + /// [`Tools::command`] in the project directory. + fn command_in_project(&self, program: impl AsRef) -> Command { + let mut cmd = self.command(program); + if let Some(dir) = &self.work_dir { + cmd.current_dir(dir); + } + cmd + } + fn which(&self, cmd: &str) -> Option { ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } @@ -614,7 +637,9 @@ impl Tools { "npm or pnpm is required: install Node.js (https://nodejs.org)", ), Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"), - Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"), + Method::Gem { .. } | Method::GemActive { .. } => { + need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)") + } Method::DotnetTool { .. } => { need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)") } @@ -772,6 +797,22 @@ impl Tools { _ => (ToolStatus::Missing, None, None), } } + Method::GemActive { gem } => { + // Installed for the Ruby active in the project, not merely a shim of some other version's. + let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) }; + let mut c = self.command_in_project(&gem_cmd); + c.args(["list", "--exact", gem]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) if out.contains(&format!("{gem} (")) => { + // Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is. + match self.gem_executable(main) { + Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)), + None => (ToolStatus::Missing, None, None), + } + } + _ => (ToolStatus::Missing, None, None), + } + } _ => match self.which(main) { Some(path) => { let version = self.query_version(spec, &path); @@ -868,6 +909,7 @@ impl Tools { Method::Npm { packages } => self.install_npm(packages, cancel, emit)?, Method::Go { module } => self.install_go(spec, module, cancel, emit)?, Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?, + Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?, Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?, Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?, Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?, @@ -953,6 +995,41 @@ impl Tools { self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) }) } + /// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`). + fn gem_bin_dirs(&self) -> Vec { + let Some(ruby) = self.which("ruby") else { return Vec::new() }; + let mut c = self.command_in_project(ruby); + c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out), + _ => Vec::new(), + } + } + + /// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories. + fn gem_executable(&self, cmd: &str) -> Option { + self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd))) + } + + /// `gem install` into the active Ruby, then makes version-manager shims see the new executable. + fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let mut c = self.command_in_project(self.which("gem").expect("checked")); + c.args(["install", "--no-document", gem]); + run_streamed(c, cancel, emit)?; + // mise / asdf / rbenv list executables in shims written at install time; a gem installed + // afterwards has none until they are regenerated. Best effort: the tool may not be installed. + for (manager, args) in + [("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])] + { + if let Some(path) = self.which(manager) { + let mut c = self.command_in_project(path); + c.args(args); + let _ = run_quiet(c, Duration::from_secs(30)); + } + } + Ok(()) + } + fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { let dir = self.root.join(spec.name); let _ = std::fs::remove_dir_all(&dir); @@ -1144,10 +1221,26 @@ impl Tools { cancel: &CancellationToken, ) -> IdeResult { match source { - ReleaseSource::GitHub { repo } => { - let json = http.get_text(&sources::github_latest_url(repo), cancel)?; - sources::github_pick(&json, assets, self.platform) - } + ReleaseSource::GitHub { repo } => match http.get_text(&sources::github_latest_url(repo), cancel) { + Ok(json) => sources::github_pick(&json, assets, self.platform), + Err(e) if e.kind == ErrorKind::Cancelled => Err(e), + // Without a token the API allows 60 requests an hour per IP address, often used + // up behind a shared one (403/429); the release's web pages have no such limit. + Err(api) => self.resolve_github_web(http, repo, assets, cancel).map_err(|web| { + if web.kind == ErrorKind::Cancelled { + return web; + } + tracing::warn!("GitHub release lookup for {repo} failed: {api}; web pages: {web}"); + IdeError::new( + api.kind, + format!( + "{}\nDownload it from {} and set its Path in Settings › Languages & Frameworks › Language Servers.", + api.message, + sources::github_latest_page(repo) + ), + ) + }), + }, ReleaseSource::HashiCorp { product } => { let json = http.get_text(&sources::hashicorp_latest_url(product), cancel)?; sources::hashicorp_pick(&json, assets, self.platform) @@ -1174,6 +1267,27 @@ impl Tools { } } + /// [`resolve_release`](Self::resolve_release) for a GitHub release, from its web pages. + fn resolve_github_web( + &self, + http: &Http, + repo: &str, + assets: &[platform::AssetRule], + cancel: &CancellationToken, + ) -> IdeResult { + let page = http.effective_url(&sources::github_latest_page(repo), cancel)?; + let tag = sources::github_tag_from_url(&page) + .ok_or_else(|| IdeError::unavailable(format!("{repo} has no published release")))?; + let list = http.get_text(&sources::github_assets_page(repo, &tag), cancel)?; + match sources::github_pick_web(repo, &tag, &list, None, assets, self.platform) { + Err(e) if e.kind == ErrorKind::Unavailable => { + let notes = http.get_text(&sources::github_tag_page(repo, &tag), cancel)?; + sources::github_pick_web(repo, &tag, &list, Some(¬es), assets, self.platform) + } + result => result, + } + } + fn install_release( &self, spec: &ToolSpec, @@ -1310,6 +1424,18 @@ impl Tools { run_streamed(c, &cancel, &quiet)?; } } + Method::GemActive { gem } => { + if let Some(gem_cmd) = self.which("gem") { + let mut c = self.command_in_project(&gem_cmd); + c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]); + run_streamed(c, &cancel, &quiet)?; + } + // A copy an older version installed under the tools directory. + let dir = self.root.join(spec.name); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + } Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } diff --git a/crates/ide-tools/src/platform.rs b/crates/ide-tools/src/platform.rs index d32b07ac7..d27e89cec 100644 --- a/crates/ide-tools/src/platform.rs +++ b/crates/ide-tools/src/platform.rs @@ -151,6 +151,36 @@ mod tests { assert_eq!(select_asset(&names, rules, WIN), Some(names[4])); } + #[test] + fn kmp_lsp_assets_include_the_native_jar_indexer() { + let names = [ + "kmp-lsp-darwin-aarch64.gz", + "kmp-lsp-darwin-aarch64.tar.gz", + "kmp-lsp-darwin-x86_64.tar.gz", + "kmp-lsp-linux-aarch64.tar.gz", + "kmp-lsp-linux-x86_64.tar.gz", + "kmp-lsp-windows-x86_64.zip", + "kmp-lsp-windows-aarch64.zip", + "kmp-lsp-v0.27.0.vsix", + "kmp-lsp-win32-x64-v0.27.0.vsix", + "kmp-lsp-win32-arm64-v0.27.0.vsix", + ]; + let rules = crate::registry::find("kmp-lsp").unwrap().asset_rules(); + for (os, arch, expected) in [ + (Os::Mac, Arch::Arm64, names[1]), + (Os::Mac, Arch::X64, names[2]), + (Os::Linux, Arch::Arm64, names[3]), + (Os::Linux, Arch::X64, names[4]), + (Os::Windows, Arch::X64, names[8]), + (Os::Windows, Arch::Arm64, names[9]), + ] { + assert_eq!(select_asset(&names, rules, Platform { os, arch }), Some(expected)); + } + // The plain gzip, Windows zip and universal VSIX omit the sidecar or server. + assert_eq!(select_asset(&[names[0]], rules, MAC_ARM), None); + assert_eq!(select_asset(&names[5..8], rules, WIN), None); + } + #[test] fn rosetta_fallback_and_missing() { let names = ["zls-x86_64-macos.tar.xz", "zls-x86_64-linux.tar.xz", "zls-x86_64-windows.zip"]; diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index ac8a48b26..7d34423e4 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -263,6 +263,15 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool { (major, minor) >= (7, 70) && first.split_whitespace().any(|w| w.eq_ignore_ascii_case("schannel")) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Request { + Get, + /// Adoptium uses 404 as well as an empty array for absent platform builds. + GetOrEmptyOn404, + /// Headers, then the URL redirects ended at. + Head, +} + /// HTTP via the system `curl` (shipped with macOS, Windows 10+ and every /// mainstream Linux), so the IDE carries no TLS stack of its own. pub struct Http { @@ -336,26 +345,39 @@ impl Http { self.get_text_limited(url, None, cancel) } + /// Where `url` ends up after redirects (a HEAD request). + pub fn effective_url(&self, url: &str, cancel: &CancellationToken) -> IdeResult { + let out = self.get_text_response(url, None, Request::Head, cancel)?; + Ok(out.trim_end().rsplit('\n').next().unwrap_or_default().trim().to_owned()) + } + /// GETs `url` as text, failing when the response is larger than `max_bytes`. pub fn get_text_limited(&self, url: &str, max_bytes: Option, cancel: &CancellationToken) -> IdeResult { - self.get_text_response(url, max_bytes, false, cancel) + self.get_text_response(url, max_bytes, Request::Get, cancel) } /// Adoptium uses 404 as well as an empty array for absent platform builds. pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult { - self.get_text_response(url, None, true, cancel) + self.get_text_response(url, None, Request::GetOrEmptyOn404, cancel) } fn get_text_response( &self, url: &str, max_bytes: Option, - empty_on_404: bool, + request: Request, cancel: &CancellationToken, ) -> IdeResult { let (mut cmd, token) = self.base(url, cancel)?; - if empty_on_404 { - cmd.args(["--write-out", "\n%{http_code}"]); + let empty_on_404 = request == Request::GetOrEmptyOn404; + match request { + Request::Get => {} + Request::GetOrEmptyOn404 => { + cmd.args(["--write-out", "\n%{http_code}"]); + } + Request::Head => { + cmd.args(["--head", "--write-out", "\n%{url_effective}"]); + } } if let Some(max) = max_bytes { cmd.arg("--max-filesize").arg(max.to_string()); diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 494482f76..bbb9b5a36 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -49,6 +49,11 @@ pub enum Method { PipTarget { package: &'static str, module: &'static str }, /// `gem install` into `//gems`, launched through `GEM_HOME` wrappers in `bin/`. Gem { gem: &'static str }, + /// `gem install ` into the active Ruby (the project's, installed from a project window), + /// launched through that Ruby's own executables. For tools that re-launch themselves under + /// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces + /// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it). + GemActive { gem: &'static str }, /// `dotnet tool install --tool-path //tool `, launched /// through `DOTNET_ROOT` wrappers in `bin/`. DotnetTool { package: &'static str }, @@ -64,6 +69,7 @@ impl Method { Method::Npm { .. } => "npm", Method::Go { .. } => "go install", Method::Gem { .. } => "gem", + Method::GemActive { .. } => "gem (project Ruby)", Method::DotnetTool { .. } => "dotnet tool", Method::PipVenv { .. } => "pip (virtualenv)", Method::PipUser { .. } => "pip --user", @@ -612,6 +618,30 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://github.com/eclipse-jdtls/eclipse.jdt.ls", needs: &[], }, + ToolSpec { + name: "kmp-lsp", + display: "kmp-lsp", + kind: LanguageServer, + commands: &["kmp-lsp"], + method: Method::Release { + source: ReleaseSource::GitHub { repo: "Hessesian/kmp-lsp" }, + // These distributions include the native JAR indexer next to the server. + // The single-binary .gz and Windows .zip assets do not. + assets: &[ + rule(Mac, Arm64, "kmp-lsp-darwin-aarch64.tar.gz"), + rule(Mac, X64, "kmp-lsp-darwin-x86_64.tar.gz"), + rule(Linux, Arm64, "kmp-lsp-linux-aarch64.tar.gz"), + rule(Linux, X64, "kmp-lsp-linux-x86_64.tar.gz"), + rule(Windows, Arm64, "kmp-lsp-win32-arm64-*.vsix"), + rule(Windows, X64, "kmp-lsp-win32-x64-*.vsix"), + ], + locate: &["kmp-lsp"], + }, + fallback: None, + version: ver("kmp-lsp"), + homepage: "https://github.com/Hessesian/kmp-lsp", + needs: &[], + }, ToolSpec { name: "java-debug", display: "Java Debugger (Microsoft java-debug)", @@ -640,12 +670,13 @@ pub static TOOLS: &[ToolSpec] = &[ // Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup (lombok#4059); // ECJ 3.46.200 keeps the constructor call in a constructor's statements, which // @SneakyThrows / @NonNull must not wrap or precede (lombok#4089). - // Return to Maven Central once an official release includes both. + // Builders on compact record constructors need the patch in java/lombok/patches. + // Return to Maven Central once an official release includes all of these fixes. // Source commit, build instructions and checksum: java/lombok/README.md. method: Method::Bundled { - filename: "lombok-1.18.48-openidea.2.jar", - version: "1.18.48-openidea.2", - bytes: include_bytes!("../../../java/lombok/lombok-1.18.48-openidea.2.jar"), + filename: "lombok-1.18.48-openidea.3.jar", + version: "1.18.48-openidea.3", + bytes: include_bytes!("../../../java/lombok/lombok-1.18.48-openidea.3.jar"), }, fallback: None, version: V, @@ -940,7 +971,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Ruby LSP (Shopify)", kind: LanguageServer, commands: &["ruby-lsp"], - method: Method::Gem { gem: "ruby-lsp" }, + method: Method::GemActive { gem: "ruby-lsp" }, fallback: None, version: V, homepage: "https://shopify.github.io/ruby-lsp", diff --git a/crates/ide-tools/src/sources.rs b/crates/ide-tools/src/sources.rs index 2337bc75c..0e5da7481 100644 --- a/crates/ide-tools/src/sources.rs +++ b/crates/ide-tools/src/sources.rs @@ -47,38 +47,146 @@ pub fn github_latest_url(repo: &str) -> String { format!("https://api.github.com/repos/{repo}/releases/latest") } +/// The latest release's web page; it redirects to `…/releases/tag/`. Web pages don't +/// count against the API's rate limit for unauthenticated clients (60 requests an hour). +pub fn github_latest_page(repo: &str) -> String { + format!("https://github.com/{repo}/releases/latest") +} + +/// The tag `github_latest_page` redirected to. +pub fn github_tag_from_url(url: &str) -> Option { + let (_, tag) = url.split_once("/releases/tag/")?; + let tag = tag.split(['?', '#']).next().unwrap_or(tag).trim_end_matches('/'); + (!tag.is_empty()).then(|| percent_decode(tag)) +} + +pub fn github_tag_page(repo: &str, tag: &str) -> String { + format!("https://github.com/{repo}/releases/tag/{tag}") +} + +/// The asset list the release page loads (HTML). +pub fn github_assets_page(repo: &str, tag: &str) -> String { + format!("https://github.com/{repo}/releases/expanded_assets/{tag}") +} + +/// One file of a release, from the API or its web page. +struct ReleaseAsset { + name: String, + url: String, + size: Option, + sha256: Option, +} + /// Picks the platform asset from a GitHub `releases/latest` response. Links /// in the release notes count as assets too (some projects host binaries on /// a CDN and only link them). pub fn github_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult { let rel: GhRelease = serde_json::from_str(json).map_err(|e| proto(format!("unexpected GitHub response: {e}")))?; - let version = rel.tag_name.trim_start_matches('v').to_string(); - let names: Vec<&str> = rel.assets.iter().map(|a| a.name.as_str()).collect(); + let assets: Vec = rel + .assets + .into_iter() + .map(|a| ReleaseAsset { + sha256: a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase), + name: a.name, + url: a.browser_download_url, + size: a.size, + }) + .collect(); + pick_release(&rel.tag_name, &assets, rel.body.as_deref().unwrap_or(""), rules, platform) +} + +/// [`github_pick`] from the release's web pages: `assets_html` from [`github_assets_page`] and, +/// for projects that only link their builds, `notes_html` from [`github_tag_page`]. +pub fn github_pick_web( + repo: &str, + tag: &str, + assets_html: &str, + notes_html: Option<&str>, + rules: &[AssetRule], + platform: Platform, +) -> IdeResult { + let prefix = format!("href=\"/{repo}/releases/download/"); + let mut assets: Vec = Vec::new(); + let mut rest = assets_html; + while let Some(i) = rest.find(&prefix) { + let tail = &rest[i + "href=\"".len()..]; + let Some(end) = tail.find('"') else { break }; + let path = html_unescape(&tail[..end]); + rest = &tail[end..]; + let name = percent_decode(file_name(&path)); + if name.is_empty() || assets.iter().any(|a| a.name == name) { + continue; + } + // `` + let sha256 = assets_html.find(&format!("digest for {}\"", html_escape(&name))).and_then(|at| { + let tag_end = assets_html[at..].find('>').map_or(assets_html.len(), |e| at + e); + let value = assets_html[at..tag_end].split_once("value=\"sha256:")?.1; + let hex: String = value.chars().take_while(char::is_ascii_hexdigit).collect(); + (hex.len() == 64).then(|| hex.to_ascii_lowercase()) + }); + assets.push(ReleaseAsset { url: format!("https://github.com{path}"), name, size: None, sha256 }); + } + pick_release(tag, &assets, &html_unescape(notes_html.unwrap_or("")), rules, platform) +} + +fn pick_release( + tag: &str, + assets: &[ReleaseAsset], + notes: &str, + rules: &[AssetRule], + platform: Platform, +) -> IdeResult { + let version = tag.trim_start_matches('v').to_string(); + let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect(); if let Some(name) = select_asset(&names, rules, platform) { - let a = rel.assets.iter().find(|a| a.name == name).expect("selected from list"); - let sha256 = a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase); - let checksum_url = rel - .assets + let a = assets.iter().find(|a| a.name == name).expect("selected from list"); + let checksum_url = assets .iter() .find(|c| c.name == format!("{name}.sha256") || c.name == format!("{name}.sha256sum")) - .map(|c| c.browser_download_url.clone()); + .map(|c| c.url.clone()); return Ok(Download { version, name: a.name.clone(), - url: a.browser_download_url.clone(), + url: a.url.clone(), size: a.size, - sha256, + sha256: a.sha256.clone(), checksum_url, }); } - let links = extract_links(rel.body.as_deref().unwrap_or("")); + let links = extract_links(notes); let link_names: Vec<&str> = links.iter().map(|u| file_name(u)).collect(); if let Some(name) = select_asset(&link_names, rules, platform) { let url = links.iter().find(|u| file_name(u) == name).expect("selected from list").clone(); let checksum_url = links.iter().find(|u| *u == &format!("{url}.sha256")).cloned(); return Ok(Download { version, name: name.to_string(), url, size: None, sha256: None, checksum_url }); } - Err(IdeError::unavailable(format!("release {} has no build for this platform", rel.tag_name))) + Err(IdeError::unavailable(format!("release {tag} has no build for this platform"))) +} + +fn html_unescape(s: &str) -> String { + s.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">").replace("&", "&") +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">") +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let Some(b) = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_owned()) } fn file_name(url: &str) -> &str { @@ -336,6 +444,71 @@ mod tests { assert_eq!(d.name, "kotlin-lsp-0.253.10629-linux-x64.zip"); } + #[test] + fn github_tag_from_redirect() { + let url = "https://github.com/Kotlin/kotlin-lsp/releases/tag/kotlin-lsp/v263.7084.0"; + assert_eq!(github_tag_from_url(url).as_deref(), Some("kotlin-lsp/v263.7084.0")); + let url = "https://github.com/o/r/releases/tag/v1.0%2Bbuild"; + assert_eq!(github_tag_from_url(url).as_deref(), Some("v1.0+build")); + // A repository without releases stays on the list. + assert_eq!(github_tag_from_url("https://github.com/o/r/releases"), None); + } + + /// Trimmed from `github.com//releases/expanded_assets/`. + const ASSETS_HTML: &str = r#"

  • + sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789 +
  • +
  • lua-language-server-3.13.5-linux-x64.tar.gz
  • +
  • Source code (zip)
  • "#; + + #[test] + fn github_web_assets_with_digest() { + let rules = find("lua-language-server").unwrap().asset_rules(); + let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, MAC_ARM).unwrap(); + assert_eq!(d.version, "3.13.5"); + assert_eq!(d.name, "lua-language-server-3.13.5-darwin-arm64.tar.gz"); + assert_eq!( + d.url, + "https://github.com/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz" + ); + assert_eq!(d.sha256.as_deref(), Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")); + let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, LINUX).unwrap(); + assert_eq!(d.sha256, None); + let win = Platform { os: Os::Windows, arch: Arch::X64 }; + let err = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, win).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Unavailable); + } + + #[test] + fn github_web_sibling_checksum_and_notes_links() { + let html = r#" + "#; + let d = github_pick_web( + "artempyanykh/marksman", + "2024-12-18", + html, + None, + find("marksman").unwrap().asset_rules(), + MAC_ARM, + ) + .unwrap(); + assert_eq!( + d.checksum_url.as_deref(), + Some("https://github.com/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256") + ); + + let notes = r#"
  • macOS arm
  • "#; + let rules = find("kotlin-lsp").unwrap().asset_rules(); + let d = + github_pick_web("Kotlin/kotlin-lsp", "kotlin-lsp/v0.253.10629", "", Some(notes), rules, MAC_ARM).unwrap(); + assert_eq!(d.name, "kotlin-lsp-0.253.10629-mac-aarch64.zip"); + assert_eq!( + d.url, + "https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2" + ); + } + #[test] fn hashicorp() { let json = r#"{"version":"0.36.4","url_shasums":"https://r/terraform-ls_0.36.4_SHA256SUMS","builds":[ diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index de4ff670d..1d606fe21 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -58,16 +58,20 @@ fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() fs::create_dir_all(legacy.parent().unwrap()).unwrap(); fs::write(&legacy, b"old official agent").unwrap(); fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap(); + let previous = env.root.join("lombok/bin/lombok-1.18.48-openidea.2.jar"); + fs::write(&previous, b"previous bundled agent").unwrap(); let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch }); let info = tools.info("lombok", &[]).unwrap(); let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap(); assert_eq!(info.status, ToolStatus::Installed); - assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.2")); + assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.3")); assert_eq!(info.path, Some(path.clone().into())); - assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.2.jar"); + assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.3.jar"); assert!(!info.can_install && !info.can_uninstall); assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None); assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it"); + assert_ne!(path, previous, "new agent path must invalidate the JDT LS workspace"); + assert_eq!(fs::read(&previous).unwrap(), b"previous bundled agent", "other windows may still use it"); } } } @@ -166,6 +170,10 @@ fn interchangeable_servers_list_their_alternatives() { assert_eq!(find(&list, name).alternatives, python); } assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]); + for name in ["jdtls", "kmp-lsp"] { + assert_eq!(find(&list, name).alternatives, ["jdtls", "kmp-lsp"]); + assert_eq!(find(&list, name).languages, ["Java"]); + } // A regular server after a fallback starts no group with it. assert!(find(&list, "angular-language-server").alternatives.is_empty()); assert!(find(&list, "ruff").alternatives.is_empty()); @@ -590,6 +598,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy()); } +#[cfg(unix)] +#[test] +fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() { + let env = Env::new(); + let project = env._tmp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let marker = env._tmp.path().join("installed"); + // Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it. + let bindir = env._tmp.path().join("gems/bin"); + fs::create_dir_all(&bindir).unwrap(); + env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display())); + // `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given. + env.script( + &env.sys, + "gem", + &format!( + r#"case "$1" in +install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;; +list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;; +uninstall) /bin/rm -f '{m}' ;; +esac"#, + m = marker.display(), + b = bindir.display() + ), + ); + let tools = env.tools().with_work_dir(Some(project.clone())); + assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing); + let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(info.status, ToolStatus::OnPath); + assert_eq!(info.version.as_deref(), Some("0.26.11")); + // Found in the Ruby's own bindir, not reported as the `gem` command. + assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path())); + // Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home. + let log = fs::read_to_string(&marker).unwrap(); + assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}"); + assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}"); + // No private gem home, no wrapper directory on the search path. + assert!(!env.root.join("ruby-lsp").exists()); + // Uninstalling removes the gem, and an older managed copy. + fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap(); + let info = tools.uninstall("ruby-lsp", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Missing); + assert!(!env.root.join("ruby-lsp").exists()); +} + #[cfg(unix)] #[test] fn dotnet_tool_install_into_tool_path() { diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index c09f6d59f..59ecdb506 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,7 +19,15 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + let range = range.map(|(start_offset, end_offset)| formatter::FormatRange { + start_offset, + end_offset, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 1, + }); + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range, None)); formatter::run("prettier", cmd, text, &CancellationToken::new()) } diff --git a/crates/ide-tools/tests/ruff.rs b/crates/ide-tools/tests/ruff.rs new file mode 100644 index 000000000..afe51f764 --- /dev/null +++ b/crates/ide-tools/tests/ruff.rs @@ -0,0 +1,67 @@ +//! `cargo test -p ide-tools --test ruff -- --ignored --nocapture` + +use ide_core::CancellationToken; +use ide_language::FormatterConfig; +use ide_tools::{Tools, formatter}; +use std::path::Path; + +fn format( + tools: &Tools, + path: &Path, + text: &str, + range: Option, + config_file: Option<&Path>, +) -> ide_core::IdeResult { + let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true }; + let mut cmd = tools.formatter_command("ruff", path, None)?; + cmd.current_dir(path.parent().unwrap()); + cmd.args(formatter::arguments("ruff", &config, path, 4, true, range, config_file)); + formatter::run("ruff", cmd, text, &CancellationToken::new()) +} + +#[test] +#[ignore = "requires ruff on PATH"] +fn real_ruff_reads_project_config_formats_stdin_and_supports_ranges() { + let temp = tempfile::tempdir().unwrap(); + let tools = Tools::new(&temp.path().join("app-data")); + if tools.info("ruff", &[]).unwrap().status == ide_tools::ToolStatus::Missing { + eprintln!("SKIP real Ruff: not on PATH"); + return; + } + let project = temp.path().join("project with spaces"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("pyproject.toml"), "[tool.ruff.format]\nquote-style = \"single\"\n").unwrap(); + let path = project.join("example.py"); + let input = "def greet(name):\n return {\"message\": name}\n"; + let output = format(&tools, &path, input, None, None).unwrap(); + assert_eq!(output, "def greet(name):\n return {'message': name}\n"); + assert!(!path.exists(), "formatting must never write the file"); + + let range = formatter::FormatRange { + start_offset: 0, + end_offset: 0, + start_line: 2, + start_col: 1, + end_line: 2, + end_col: 100, + }; + let mixed = "def untouched( a,b ):\n return {\"message\": name}\n"; + let output = format(&tools, &path, mixed, Some(range), None).unwrap(); + assert!(output.starts_with("def untouched( a,b ):"), "{output}"); + assert!(output.contains("return {'message': name}"), "{output}"); + + let shared = temp.path().join("shared config"); + std::fs::create_dir_all(&shared).unwrap(); + let shared_config = shared.join("ruff.toml"); + std::fs::write(&shared_config, "[format]\nquote-style = \"double\"\n").unwrap(); + std::fs::remove_file(project.join("pyproject.toml")).unwrap(); + let output = format(&tools, &path, input, None, Some(&shared_config)).unwrap(); + assert_eq!(output, "def greet(name):\n return {\"message\": name}\n"); + + let invalid = "def broken(:\n pass\n"; + let error = format(&tools, &path, invalid, None, Some(&shared_config)).unwrap_err(); + assert!(error.message.contains("Failed to parse"), "{}", error.message); + eprintln!( + "PASS: real Ruff config discovery, custom config file, stdin formatting, quote style, range, invalid syntax diagnostics, no disk writes" + ); +} diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 0f865cea7..3a73f2704 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -361,14 +361,48 @@ pub(crate) fn run_cancellable(root: &Path, args: &[&str], cancel: &CancellationT /// Cancellable capture preserving exit codes for commands with documented non-error states. pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult { + run_cancellable_raw_stdin(root, args, None, cancel) +} + +/// Like `run_cancellable`, feeding `stdin` to git. Revisions and paths passed through +/// `--stdin` are not bound by the command-line length limit (Windows: ~32K characters). +pub(crate) fn run_cancellable_stdin( + root: &Path, + args: &[&str], + stdin: &[u8], + cancel: &CancellationToken, +) -> IdeResult { + let out = run_cancellable_raw_stdin(root, args, Some(stdin), cancel)?; + if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) } +} + +fn run_cancellable_raw_stdin( + root: &Path, + args: &[&str], + stdin: Option<&[u8]>, + cancel: &CancellationToken, +) -> IdeResult { if cancel.is_cancelled() { return Err(IdeError::cancelled()); } let mut cmd = command(root)?; - cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.args(args) + .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); own_group(&mut cmd); let mut session = CommandSession::start(&mut cmd); let mut child = cmd.spawn().map_err(spawn_error)?; + let writer = match (stdin, child.stdin.take()) { + (Some(data), Some(mut pipe)) => { + let data = data.to_vec(); + Some(std::thread::spawn(move || { + // A broken pipe just means git stopped reading early; its exit status tells the story. + let _ = pipe.write_all(&data); + })) + } + _ => None, + }; let capture = |mut pipe: Box| { std::thread::spawn(move || { let mut bytes = Vec::new(); @@ -382,6 +416,9 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat terminate_tree(&mut child, KILL_GRACE); let _ = stdout.join(); let _ = stderr.join(); + if let Some(w) = writer { + let _ = w.join(); + } return Err(IdeError::cancelled()); } if let Some(status) = child.try_wait()? { @@ -389,6 +426,9 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat } std::thread::sleep(Duration::from_millis(20)); }; + if let Some(w) = writer { + let _ = w.join(); + } let join = |thread: std::thread::JoinHandle>>| { thread.join().map_err(|_| IdeError::internal("git output reader panicked"))?.map_err(IdeError::from) }; diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 14873f7b3..73bc78085 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -80,12 +80,18 @@ fn git_select( args.push("--first-parent".into()); } args.extend(filter.iter().cloned()); + // Past this many bytes of paths the command line risks Windows' ~32K limit. + let stdin = (paths.iter().map(|p| p.len() + 12).sum::() > 8 * 1024).then(|| paths_stdin(paths)); + if stdin.is_some() { + args.push("--stdin".into()); + } args.push("--end-of-options".into()); args.extend(scope.split_whitespace().map(str::to_string)); - args.push("--".into()); - args.extend(paths.iter().map(|p| format!(":(literal){p}"))); - let output = - crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; + if stdin.is_none() { + args.push("--".into()); + args.extend(paths.iter().map(|p| format!(":(literal){p}"))); + } + let output = run_log(r, args, stdin.as_deref(), cancel)?; for line in output.stdout_str().lines() { ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?); } @@ -93,6 +99,159 @@ fn git_select( Ok(ids) } +/// Stdin for `git log --stdin` that limits the walk to `paths`, however many there are. +fn paths_stdin(paths: &[String]) -> String { + let mut text = String::from("--\n"); + for p in paths { + text.push_str(&format!(":(literal){p}\n")); + } + text +} + +fn run_log( + r: &Repo, + args: Vec, + stdin: Option<&str>, + cancel: &CancellationToken, +) -> IdeResult { + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); + match stdin { + Some(text) => crate::cli::run_cancellable_stdin(r.root(), &argv, text.as_bytes(), cancel), + None => crate::cli::run_cancellable(r.root(), &argv, cancel), + } +} + +/// The commits of a file's history and the path it had in each. The commits that changed +/// the path, merges included (`--full-history --simplify-merges`); where it appears, a +/// rename from any parent continues the history under the old path from that parent. +/// Unlike `--follow`, this sees renames done in merges and history merged in from other +/// branches. +fn file_history( + r: &Repo, + scopes: &[String], + rel: &str, + first_parent: bool, + cancel: &CancellationToken, +) -> IdeResult<(HashSet, HashMap)> { + let mut ids = HashSet::new(); + let mut paths = HashMap::new(); + let mut starts: std::collections::VecDeque<(Vec, String)> = + scopes.iter().map(|s| (s.split_whitespace().map(str::to_string).collect(), rel.to_string())).collect(); + let mut started = HashSet::new(); + while let Some((revs, path)) = starts.pop_front() { + if !started.insert((revs.clone(), path.clone())) { + continue; + } + let mut opts: Vec = + ["log", "--format=%x00%H", "--name-status", "-z", "--full-history", "--simplify-merges", "--no-follow"] + .map(String::from) + .into(); + if first_parent { + opts.push("--first-parent".into()); + } + let pathspec = ["--".to_string(), format!(":(literal){path}")]; + // `excluded` can name a revision per created file, so it goes through stdin. + let run = |excluded: &[String]| -> IdeResult)>> { + let mut args = opts.clone(); + if !excluded.is_empty() { + args.push("--stdin".into()); + } + args.push("--end-of-options".into()); + args.extend(revs.iter().cloned()); + args.extend(pathspec.iter().cloned()); + let stdin = (!excluded.is_empty()).then(|| excluded.join("\n") + "\n"); + Ok(parse_name_status_log(&run_log(r, args, stdin.as_deref(), cancel)?.stdout_str())) + }; + let records = run(&[])?; + // Where the file appears its history ends: what is behind that is another file of + // the same name (a rename continues the history below). The commits of the history + // are those not behind a creation; the statuses come from the full listing (with + // its parents excluded a merge shows no changes). + let created: Vec = records.iter().filter(|(_, s)| *s == Some('A')).map(|(id, _)| *id).collect(); + let kept: Option> = if created.is_empty() { + None + } else { + let excluded: Vec = created.iter().map(|id| format!("^{id}^@")).collect(); + Some(run(&excluded)?.into_iter().map(|(id, _)| id).collect()) + }; + let mut created = Vec::new(); + for (id, first_status) in records { + if kept.as_ref().is_some_and(|k| !k.contains(&id)) || !ids.insert(id) { + continue; + } + paths.insert(id, path.clone()); + if first_status == Some('A') { + created.push(id); + } + } + for commit in created { + let line = crate::cli::run_cancellable( + r.root(), + &["rev-list", "--parents", "-n", "1", "--end-of-options", &commit.to_string()], + cancel, + )? + .stdout_str(); + let parents: Vec<&str> = + line.split_whitespace().skip(1).take(if first_parent { 1 } else { usize::MAX }).collect(); + for parent in parents { + if let Some(old) = renamed_from(r, parent, &commit.to_string(), &path, cancel)? { + starts.push_back((vec![parent.to_string()], old)); + } + } + } + } + Ok((ids, paths)) +} + +/// `git log --format=%x00%H --name-status -z`: each commit with the status letter of its +/// first change (none for a merge listed without changes). +fn parse_name_status_log(text: &str) -> Vec<(ObjectId, Option)> { + let mut out: Vec<(ObjectId, Option)> = Vec::new(); + let mut tokens = text.split('\0'); + while let Some(token) = tokens.next() { + let token = token.trim_start_matches('\n'); + if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { + out.push((id, None)); + continue; + } + let Some(status) = token.chars().next().filter(|c| c.is_ascii_uppercase()) else { continue }; + if !token[1..].bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let paths = if matches!(status, 'R' | 'C') { 2 } else { 1 }; + for _ in 0..paths { + tokens.next(); + } + if let Some(last) = out.last_mut() { + last.1.get_or_insert(status); + } + } + out +} + +/// The path `path` had in `parent` when `commit` renamed it. +fn renamed_from( + r: &Repo, + parent: &str, + commit: &str, + path: &str, + cancel: &CancellationToken, +) -> IdeResult> { + let args = ["diff", "-M", "--name-status", "-z", "--diff-filter=R", "--no-ext-diff", parent, commit, "--"]; + let text = crate::cli::run_cancellable(r.root(), &args, cancel)?.stdout_str(); + let mut tokens = text.split('\0'); + while let Some(status) = tokens.next() { + if !status.starts_with('R') { + continue; + } + let (Some(old), Some(new)) = (tokens.next(), tokens.next()) else { break }; + if new == path { + return Ok(Some(old.to_string())); + } + } + Ok(None) +} + pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult> { if cancel.is_cancelled() { return Err(IdeError::cancelled()); @@ -229,46 +388,11 @@ fn log_indexed( let mut history_paths = HashMap::new(); let path_ids = if paths.is_empty() { None - } else if q.follow && paths.len() == 1 { - let mut ids = HashSet::new(); - let rel = &paths[0]; - let mut args = vec![ - "log".to_string(), - "--format=%x00%H".into(), - "--name-status".into(), - "-z".into(), - "--follow".into(), - "--topo-order".into(), - ]; - if q.first_parent { - args.push("--first-parent".into()); - } - args.push("--end-of-options".into()); - for scope in &scopes { - let mut args = args.clone(); - args.extend(scope.split_whitespace().map(str::to_string)); - args.extend(["--".into(), rel.clone()]); - let output = - crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; - let text = output.stdout_str(); - let mut tokens = text.split('\0').peekable(); - let mut current_path = rel.clone(); - while let Some(token) = tokens.next() { - let token = token.trim_start_matches('\n'); - if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { - ids.insert(id); - history_paths.insert(id, current_path.clone()); - } else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) { - if let (Some(old), Some(new)) = (tokens.next(), tokens.next()) - && new == current_path - { - current_path = old.to_string(); - } - } else if ["A", "D", "M", "T", "U"].contains(&token) { - tokens.next(); - } - } - } + } else if q.follow && paths.len() == 1 && !r.root().join(&paths[0]).is_dir() { + // Following renames is for a file: a directory gains files all the time, and each + // creation would cut its history short. + let (ids, at) = file_history(r, &scopes, &paths[0], q.first_parent, cancel)?; + history_paths = at; Some(ids) } else { Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?) @@ -860,13 +984,11 @@ pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult IdeResult> { } } +/// `sync_all` failures that mean "this filesystem cannot fsync at all", not that +/// the data was lost: ENOTTY (`os error 25`, virtio-fs on macOS VMs answering +/// F_FULLFSYNC/fsync with "Inappropriate ioctl for device") and EINVAL (some +/// network filesystems). Mirrors `sync_error_is_tolerable` in ide-vfs atomic +/// writes; other errors keep failing the update. +fn sync_error_is_tolerable(e: &std::io::Error) -> bool { + if e.kind() == std::io::ErrorKind::InvalidInput { + return true; + } + // EINVAL is already covered by InvalidInput; ENOTTY has no ErrorKind of its own. + #[cfg(unix)] + return e.raw_os_error() == Some(25); + #[cfg(not(unix))] + false +} + fn append(file: &Path, rules: &[IgnoreRule]) -> IdeResult<()> { no_symlinks(file.parent().unwrap())?; fs::create_dir_all(file.parent().unwrap())?; @@ -316,7 +332,16 @@ fn append(file: &Path, rules: &[IgnoreRule]) -> IdeResult<()> { }; temp.as_file().set_permissions(permissions)?; temp.write_all(&updated)?; - temp.as_file().sync_all()?; + if let Err(e) = temp.as_file().sync_all() { + // Filesystems without fsync (virtio-fs on macOS VMs, some network mounts): + // the write itself already went through, so keep the updated ignore file + // instead of failing the whole update. + if sync_error_is_tolerable(&e) { + tracing::debug!("fsync unsupported for {}: {e}; continuing", file.display()); + } else { + return Err(e.into()); + } + } replace_if_unchanged(file, before, temp) } @@ -356,4 +381,19 @@ mod tests { assert!(replace_if_unchanged(&file, before, temp).unwrap_err().message.contains("changed")); assert_eq!(fs::read_to_string(file).unwrap(), "external writer\n"); } + + #[test] + fn sync_errors_without_fsync_support_are_tolerable() { + // Some network filesystems report EINVAL for fsync. + assert!(sync_error_is_tolerable(&std::io::Error::new(std::io::ErrorKind::InvalidInput, "EINVAL"))); + #[cfg(unix)] + { + // virtio-fs on macOS VMs answers fsync with "Inappropriate ioctl for device". + assert!(sync_error_is_tolerable(&std::io::Error::from_raw_os_error(25))); + assert!(sync_error_is_tolerable(&std::io::Error::from_raw_os_error(22))); + } + // Real write failures still fail the update. + assert!(!sync_error_is_tolerable(&std::io::Error::from_raw_os_error(28))); + assert!(!sync_error_is_tolerable(&std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"))); + } } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index e21f689cc..b2be6a05f 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -273,6 +273,12 @@ pub struct CommitOptions { /// entries of the index change; afterwards their index entries match the new commit. #[serde(default)] pub partial: Vec, + /// Files committed with their working-tree content, staged or not. + #[serde(default)] + pub stage: Vec, + /// Staged files left out of the commit; they stay staged afterwards. + #[serde(default)] + pub exclude: Vec, } /// A file of a partial commit: which of its changes against HEAD to commit. @@ -302,6 +308,18 @@ pub struct ChangedFile { pub old_path: Option, } +/// Added and deleted lines of one file between two revisions (`git diff --numstat`). +/// A rename keeps the counts of the content change; a pure rename is `0`/`0`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileLineStats { + /// Path after the change (the only path, when the file was not renamed). + pub path: String, + pub old_path: Option, + pub added: u32, + pub removed: u32, +} + /// A file changed across several commits: its content at `before` ↔ at `after`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -344,6 +362,27 @@ pub struct PushBranchOptions<'a> { pub remote: Option<&'a str>, /// `git push --force-with-lease`. pub force_with_lease: bool, + /// `git push --force` (Force push anyway after a rejected force-with-lease push). + pub force: bool, + /// Record the target as the branch's upstream even when it already has one. + pub set_upstream: bool, + /// Skip the pre-push hook (`--no-verify`). + pub no_verify: bool, +} + +/// Options of [`Repo::push_with`]: the Push dialog's choices for the current branch. +#[derive(Clone, Copy, Debug, Default)] +pub struct PushOptions { + /// `git push --force-with-lease`. + pub force_with_lease: bool, + /// `git push --force` (Force push anyway after a rejected force-with-lease push). + pub force: bool, + /// Push to the branch's remote under the same name and record it as upstream. + pub set_upstream: bool, + /// Push tags too. + pub tags: Option, + /// Skip the pre-push hook (`--no-verify`). + pub no_verify: bool, } /// What an interactive rebase does with one commit. @@ -468,6 +507,10 @@ pub enum CheckoutOutcome { LocalChanges { files: Vec, }, + /// These untracked files (repository-relative paths) would be overwritten. + UntrackedFiles { + files: Vec, + }, /// Local branch `local` has commits that remote branch `remote` does not. LocalBranchAhead { local: String, @@ -480,6 +523,14 @@ pub enum CheckoutOutcome { }, } +/// HEAD before and after a history rewrite, for its Undo. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HeadChange { + pub before: String, + pub after: String, +} + /// A deleted local branch, enough to restore it (IDEA's "Restore" notification action). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -493,6 +544,15 @@ pub struct DeletedBranch { pub unmerged: bool, } +/// A deleted tag, enough to restore it (Restore in the tag-deleted notification). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeletedTag { + pub name: String, + /// What the tag pointed at: its tag object (annotated tags) or commit. + pub target: String, +} + /// Who stands on each side of the conflicts of the operation in progress (merge editor /// titles, as IDEA labels them). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -563,6 +623,8 @@ mod log_index; mod manage; mod ops; mod patch; +mod preserve; +pub use ops::UNSTAGED_CHANGES_PREVENT_REBASE; mod refs; mod remotes; mod repo; @@ -848,7 +910,7 @@ impl Repo { let id = commit_id.to_string(); let contains = format!("--contains={id}"); let branches = - cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?; + cli::run_cancellable(self.root(), &["branch", "-a", &contains, refs::DIRECT_BRANCH_FORMAT], cancel)?; let cache_key = format!("{allowed}:{id}"); let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned(); let mut details = if let Some(details) = cached { @@ -890,7 +952,7 @@ impl Repo { details }; details.containing_branches = - branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect(); + branches.stdout_str().lines().filter(|s| !s.is_empty()).map(str::to_string).collect(); Ok(details) } @@ -994,7 +1056,7 @@ impl Repo { /// `amend` keeps the previous message. pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() }; + let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, ..Default::default() }; write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into())) } /// Commits with the Commit Options popup's choices. @@ -1036,6 +1098,17 @@ impl Repo { write::undo_commit(self, commit) } /// Deletes a local branch (`git branch -d`, `-D` when `force`). + /// Edit Commit Message without touching the index or the working tree; returns HEAD + /// before and after. + pub fn reword(&self, commit: &str, message: &str) -> IdeResult { + write::reword(self, commit, message).map(|(before, after)| HeadChange { before, after }) + } + + /// Undoes a history rewrite: HEAD goes from `after` back to `before`, local changes kept. + pub fn restore_head(&self, before: &str, after: &str) -> IdeResult<()> { + write::restore_head(self, before, after) + } + pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::delete_branch(self, branch, force) @@ -1112,9 +1185,11 @@ impl Repo { pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] }; - let mut args = vec!["pull", "--progress", "--autostash"]; + let mut args = vec!["pull", "--progress"]; args.extend_from_slice(mode); - cli::run_streaming(&self.root, &args, cancel, on_output) + preserve::around(self, "Local changes saved before update", || { + cli::run_streaming(&self.root, &args, cancel, on_output) + }) } /// Pushes the current branch. `set_upstream` pushes to the branch's remote (or /// `origin`) under the same name and records it as upstream. @@ -1125,8 +1200,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { - let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - write::push(self, force_with_lease, set_upstream, None, cancel, on_output) + self.push_with(PushOptions { force_with_lease, set_upstream, ..Default::default() }, cancel, on_output) } /// [`Repo::push`] that also pushes tags (Push dialog › Push tags). pub fn push_with_tags( @@ -1136,9 +1210,23 @@ impl Repo { tags: Option, cancel: &CancellationToken, on_output: &dyn Fn(String), + ) -> IdeResult<()> { + self.push_with(PushOptions { force_with_lease, set_upstream, tags, ..Default::default() }, cancel, on_output) + } + /// Pushes the current branch with the Push dialog's options. + pub fn push_with( + &self, + options: PushOptions, + cancel: &CancellationToken, + on_output: &dyn Fn(String), ) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - write::push(self, force_with_lease, set_upstream, tags, cancel, on_output) + write::push(self, options, cancel, on_output) + } + /// Whether the repository has an executable hook `name` (e.g. `pre-push`), wherever + /// `core.hooksPath` puts it: the Push dialog then offers to skip it. + pub fn has_hook(&self, name: &str) -> IdeResult { + write::has_hook(self, name) } /// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log /// › Push…, Push All up to Here). @@ -1172,6 +1260,12 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push_branch(self, branch, options, cancel, on_output) } + /// Update Project › Reset to the Remote Branch: the current branch is moved to its + /// freshly fetched tracked branch; local commits are dropped, local changes kept. + pub fn reset_to_upstream(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + write::reset_to_upstream(self, cancel, on_output) + } /// Fast-forwards a local branch that is not checked out to its upstream (Branches › /// Update). Fails when the branch has diverged. pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { @@ -1184,7 +1278,12 @@ impl Repo { /// conflicts are unresolved. pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - ops::continue_op(self, cancel, on_output) + ops::continue_op(self, false, cancel, on_output) + } + /// Stage and Retry: stages the changes of tracked files, then continues. + pub fn stage_and_continue(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::continue_op(self, true, cancel, on_output) } pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); @@ -1201,9 +1300,19 @@ impl Repo { commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String), + ) -> IdeResult<()> { + self.cherry_pick_with(commits, false, cancel, on_output) + } + /// [`Repo::cherry_pick`]; `record_origin` adds "(cherry picked from commit …)" (`-x`). + pub fn cherry_pick_with( + &self, + commits: &[String], + record_origin: bool, + cancel: &CancellationToken, + on_output: &dyn Fn(String), ) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - ops::cherry_pick(self, commits, cancel, on_output) + ops::cherry_pick(self, commits, record_origin, cancel, on_output) } /// Reverts `commits` in order (newest first) with git's default messages. pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { @@ -1239,6 +1348,38 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::create_tag(self, name, commit, message, force) } + /// Deletes a local tag, returning what [`Repo::restore_tag`] needs to undo it. + pub fn delete_tag(&self, name: &str) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::delete_tag(self, name) + } + /// Recreates a deleted tag (annotated ones with their message). + pub fn restore_tag(&self, deleted: &DeletedTag) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::restore_tag(self, deleted) + } + /// Pushes one tag to `remote` (Push Tag › Push to …). + pub fn push_tag( + &self, + tag: &str, + remote: &str, + cancel: &CancellationToken, + on_output: &dyn Fn(String), + ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::push_tag(self, tag, remote, false, cancel, on_output) + } + /// Deletes a tag on `remote` (Delete on Remote, after deleting it locally). + pub fn delete_remote_tag( + &self, + tag: &str, + remote: &str, + cancel: &CancellationToken, + on_output: &dyn Fn(String), + ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::push_tag(self, tag, remote, true, cancel, on_output) + } pub fn tags(&self) -> IdeResult> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::tags(self) @@ -1248,6 +1389,12 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::diff_revisions(self, from, to) } + /// Added and deleted lines between `from` and `to`. `paths` limits the diff to those + /// repository-relative paths (both names of a rename); empty diffs every file. + pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::diff_numstat(self, from, to, paths) + } /// Tracked files whose working-tree content differs from `rev`. pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); diff --git a/crates/ide-vcs/src/manage.rs b/crates/ide-vcs/src/manage.rs index 7e5dccd90..870da58f4 100644 --- a/crates/ide-vcs/src/manage.rs +++ b/crates/ide-vcs/src/manage.rs @@ -106,7 +106,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult = vec!["merge".into(), "--autostash".into()]; + let mut args: Vec = vec!["merge".into()]; args.extend(merge_flags(o)?.into_iter().map(String::from)); match o.message.as_deref().map(str::trim).filter(|m| !m.is_empty()) { Some(m) => args.extend(["-m".into(), m.to_string()]), @@ -120,7 +120,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult IdeResult> { - let mut args: Vec = vec!["rebase".into(), "--autostash".into()]; + let mut args: Vec = vec!["rebase".into()]; if o.rebase_merges { args.push("--rebase-merges".into()); } @@ -137,7 +137,7 @@ pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult IdeResult> { - let mut args: Vec = vec!["pull".into(), "--progress".into(), "--autostash".into()]; + let mut args: Vec = vec!["pull".into(), "--progress".into()]; if rebase { if o.no_ff || o.ff_only || o.squash || o.no_commit { return Err(IdeError::invalid("--rebase cannot be combined with merge options")); @@ -213,7 +213,7 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = merge_args(branches, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - run(self.root(), &args, None).map(|_| ()) + crate::preserve::around(self, "Local changes saved before merge", || run(self.root(), &args, None).map(|_| ())) } /// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`). @@ -221,7 +221,7 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = rebase_args(upstream, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - run(self.root(), &args, None).map(|_| ()) + crate::preserve::around(self, "Local changes saved before rebase", || run(self.root(), &args, None).map(|_| ())) } /// Git › Pull…: pulls `branches` of `remote` into the current branch. @@ -237,7 +237,9 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = pull_args(remote, branches, rebase, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - cli::run_streaming(self.root(), &args, cancel, on_output) + crate::preserve::around(self, "Local changes saved before update", || { + cli::run_streaming(self.root(), &args, cancel, on_output) + }) } /// Manage Remotes: adds, removes or edits a remote. @@ -305,13 +307,10 @@ mod tests { #[test] fn merge_args_follow_the_options() { let o = MergeOptions { no_ff: true, message: Some("Merge it".into()), ..Default::default() }; - assert_eq!( - merge_args(&["dev".into()], &o).unwrap(), - ["merge", "--autostash", "--no-ff", "-m", "Merge it", "dev"] - ); + assert_eq!(merge_args(&["dev".into()], &o).unwrap(), ["merge", "--no-ff", "-m", "Merge it", "dev"]); assert_eq!( merge_args(&["a".into(), "b".into()], &MergeOptions::default()).unwrap(), - ["merge", "--autostash", "--no-edit", "a", "b"] + ["merge", "--no-edit", "a", "b"] ); let bad = MergeOptions { ff_only: true, squash: true, ..Default::default() }; assert!(merge_args(&["dev".into()], &bad).is_err()); @@ -322,18 +321,15 @@ mod tests { #[test] fn rebase_and_pull_args() { let o = RebaseOptions { onto: Some("main".into()), rebase_merges: true, keep_empty: false }; - assert_eq!( - rebase_args("feature~3", &o).unwrap(), - ["rebase", "--autostash", "--rebase-merges", "--onto", "main", "feature~3"] - ); + assert_eq!(rebase_args("feature~3", &o).unwrap(), ["rebase", "--rebase-merges", "--onto", "main", "feature~3"]); assert_eq!( pull_args("origin", &["main".into()], false, &MergeOptions { ff_only: true, ..Default::default() }) .unwrap(), - ["pull", "--progress", "--autostash", "--no-rebase", "--ff-only", "origin", "main"] + ["pull", "--progress", "--no-rebase", "--ff-only", "origin", "main"] ); assert_eq!( pull_args("origin", &[], true, &MergeOptions::default()).unwrap(), - ["pull", "--progress", "--autostash", "--rebase", "origin"] + ["pull", "--progress", "--rebase", "origin"] ); assert!(pull_args("origin", &[], true, &MergeOptions { squash: true, ..Default::default() }).is_err()); } diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index d3508f428..e92d20955 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -3,8 +3,9 @@ use crate::cli::{self, run}; use crate::repo::{gx, resolve}; -use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode}; +use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode}; use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::collections::HashSet; /// The in-progress operation git can continue / abort / skip. fn sequencer(r: &Repo) -> IdeResult<&'static str> { @@ -57,7 +58,8 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short), Some("revert") => sides.commit = read("REVERT_HEAD").map(short), // Conflicts outside an operation come from restoring stashed changes (Smart - // Checkout, update with autostash): stage 2 is the new HEAD, stage 3 the user's. + // Checkout, local changes put aside for a merge or update): stage 2 is the new HEAD, + // stage 3 the user's. None if !crate::write::unmerged_paths(r)?.is_empty() => { sides.operation = Some("unstash".into()); sides.reversed = true; @@ -93,9 +95,9 @@ fn branch_at(r: &Repo, commit: &str) -> Option { } ["refs/heads", "refs/remotes"].into_iter().find_map(|refs| { let out = - run(r.root(), &["for-each-ref", "--points-at", commit, "--format=%(refname:short)", refs], None).ok()?; - let names: Vec = - out.stdout_str().lines().filter(|l| !l.is_empty() && !l.ends_with("/HEAD")).map(str::to_string).collect(); + run(r.root(), &["for-each-ref", "--points-at", commit, crate::refs::DIRECT_BRANCH_FORMAT, refs], None) + .ok()?; + let names: Vec = out.stdout_str().lines().filter(|l| !l.is_empty()).map(str::to_string).collect(); match names.as_slice() { [one] => Some(one.clone()), _ => None, @@ -113,13 +115,38 @@ fn check_commits(commits: &[String]) -> IdeResult<()> { Ok(()) } +/// Why a rebase can't continue: tracked files changed while it was stopped, not staged. +pub const UNSTAGED_CHANGES_PREVENT_REBASE: &str = + "There are unstaged changes in tracked files preventing rebase from continuing"; + +fn changed_paths(r: &Repo, args: &[&str]) -> IdeResult> { + let text = run(r.root(), args, None)?.stdout_str(); + Ok(text.split('\0').filter(|p| !p.is_empty()).map(str::to_string).collect()) +} + /// Concludes the operation in progress once every conflict is resolved (staged). -pub(crate) fn continue_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { +/// `stage` first stages the changes of tracked files (Stage and Retry). Without it a +/// rebase stages only edits of files already staged (made while resolving them) and +/// otherwise fails with [`UNSTAGED_CHANGES_PREVENT_REBASE`]. +pub(crate) fn continue_op(r: &Repo, stage: bool, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { let op = sequencer(r)?; let unresolved = unmerged_paths(r)?; if !unresolved.is_empty() { return Err(IdeError::invalid(format!("resolve all conflicts before continuing: {}", unresolved.join(", ")))); } + if stage { + run(r.root(), &["add", "-u"], None)?; + } else if op == "rebase" { + let unstaged = changed_paths(r, &["diff", "--name-only", "-z", "--no-ext-diff"])?; + if !unstaged.is_empty() { + let staged: HashSet = + changed_paths(r, &["diff", "--cached", "--name-only", "-z", "--no-ext-diff"])?.into_iter().collect(); + if !unstaged.iter().all(|p| staged.contains(p)) { + return Err(IdeError::invalid(UNSTAGED_CHANGES_PREVENT_REBASE)); + } + cli::run_paths(r.root(), &["add", "-u"], &unstaged)?; + } + } let args: &[&str] = match op { // `git merge --continue` commits with the prepared MERGE_MSG. "merge" => &["merge", "--continue"], @@ -128,12 +155,16 @@ pub(crate) fn continue_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(Str "revert" => &["revert", "--continue"], _ => &["am", "--continue"], }; - cli::run_streaming(r.root(), args, cancel, out) + let result = cli::run_streaming(r.root(), args, cancel, out); + let finished = finish_operation(r); + result.and(finished) } pub(crate) fn abort_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { let op = sequencer(r)?; - cli::run_streaming(r.root(), &[op, "--abort"], cancel, out) + let result = cli::run_streaming(r.root(), &[op, "--abort"], cancel, out); + let finished = finish_operation(r); + result.and(finished) } pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { @@ -141,7 +172,9 @@ pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String) if op == "merge" { return Err(IdeError::invalid("a merge cannot be skipped; abort it instead")); } - cli::run_streaming(r.root(), &[op, "--skip"], cancel, out) + let result = cli::run_streaming(r.root(), &[op, "--skip"], cancel, out); + let finished = finish_operation(r); + result.and(finished) } /// Merge commits need a mainline (`-m`), which IDEA doesn't offer for cherry-pick / revert. @@ -157,18 +190,23 @@ fn reject_merges(r: &Repo, commits: &[String], verb: &str) -> IdeResult<()> { Ok(()) } -/// Applies `commits` on top of HEAD, in the given order (pass oldest first). +/// Applies `commits` on top of HEAD, in the given order (pass oldest first); +/// `record_origin` adds "(cherry picked from commit …)" to their messages. pub(crate) fn cherry_pick( r: &Repo, commits: &[String], + record_origin: bool, cancel: &CancellationToken, out: &dyn Fn(String), ) -> IdeResult<()> { check_commits(commits)?; reject_merges(r, commits, "cherry-pick")?; let mut args = vec!["cherry-pick"]; + if record_origin { + args.push("-x"); + } args.extend(commits.iter().map(String::as_str)); - cli::run_streaming(r.root(), &args, cancel, out) + apply_skipping_empty(r, "cherry-pick", &args, cancel, out) } /// Creates commits undoing `commits`, in the given order (pass newest first). @@ -177,7 +215,42 @@ pub(crate) fn revert(r: &Repo, commits: &[String], cancel: &CancellationToken, o reject_merges(r, commits, "revert")?; let mut args = vec!["revert", "--no-edit"]; args.extend(commits.iter().map(String::as_str)); - cli::run_streaming(r.root(), &args, cancel, out) + apply_skipping_empty(r, "revert", &args, cancel, out) +} + +/// Runs a cherry-pick / revert sequence. A commit whose changes are already there leaves +/// nothing to commit: it is skipped and the rest of the sequence goes on. +fn apply_skipping_empty( + r: &Repo, + verb: &str, + args: &[&str], + cancel: &CancellationToken, + out: &dyn Fn(String), +) -> IdeResult<()> { + let head_file = if verb == "revert" { "REVERT_HEAD" } else { "CHERRY_PICK_HEAD" }; + let said_nothing = std::cell::Cell::new(false); + let watch = |line: String| { + // git runs with LC_ALL=C; a single commit with nothing left to apply stops here. + said_nothing.set(said_nothing.get() || line.contains("nothing to commit")); + out(line) + }; + let mut result = cli::run_streaming(r.root(), args, cancel, &watch); + if result.is_err() && said_nothing.get() && r.branch_info()?.operation.is_none() { + out("Skipped: its changes are already applied".into()); + return Ok(()); + } + while result.is_err() && !cancel.is_cancelled() { + let Ok(stopped) = std::fs::read_to_string(r.git_dir.join(head_file)) else { break }; + let empty = unmerged_paths(r)?.is_empty() + && cli::run_raw(r.root(), &["diff", "--cached", "--quiet", "HEAD", "--"], None)?.code == Some(0); + if !empty { + break; + } + let id = stopped.trim(); + out(format!("{} skipped: its changes are already applied", &id[..id.len().min(8)])); + result = cli::run_streaming(r.root(), &[verb, "--skip"], cancel, out); + } + result } pub(crate) fn reset( @@ -281,7 +354,7 @@ pub(crate) fn interactive_rebase( let file = work.join(format!("msg-{i}")); std::fs::write(&file, m)?; todo.push_str(&format!( - "exec git commit --amend --allow-empty --no-verify --quiet --cleanup=strip -F {}\n", + "exec git commit --amend --allow-empty --no-verify --quiet --cleanup=whitespace -F {}\n", quote(&file) )); } @@ -289,15 +362,26 @@ pub(crate) fn interactive_rebase( let todo_file = work.join("todo"); std::fs::write(&todo_file, todo)?; let editor = format!("cp {}", quote(&todo_file)); - let mut args = vec!["rebase", "--interactive", "--autostash", "--no-autosquash"]; + // Local changes are put aside and restored when the rebase is over, staged ones as + // staged (git's --autostash would bring them all back unstaged). + crate::preserve::save(r, "Local changes saved before rebase")?; + let mut args = vec!["rebase", "--interactive", "--no-autosquash"]; args.push(base.unwrap_or("--root")); let env = [("GIT_SEQUENCE_EDITOR", std::ffi::OsStr::new(&editor))]; - cli::run_streaming_env(r.root(), &args, &env, cancel, out)?; + let result = cli::run_streaming_env(r.root(), &args, &env, cancel, out); // A stopped rebase still needs the message files for its remaining `exec` lines. - if r.branch_info()?.operation.is_none() { - let _ = std::fs::remove_dir_all(&work); + let finished = finish_operation(r); + result.and(finished) +} + +/// Once a rebase / merge / cherry-pick / revert is over, brings back the local changes +/// put aside for it; the plan files of [`interactive_rebase`] go once it is done. +pub(crate) fn finish_operation(r: &Repo) -> IdeResult<()> { + if r.branch_info()?.operation.is_some() { + return Ok(()); } - Ok(()) + let _ = std::fs::remove_dir_all(r.git_dir.join("openidea-rebase")); + crate::preserve::restore(r) } /// Creates a tag on `commit`: annotated when `message` is non-empty, else lightweight. @@ -325,6 +409,51 @@ pub(crate) fn create_tag(r: &Repo, name: &str, commit: &str, message: Option<&st run(r.root(), &args, message.map(str::as_bytes)).map(|_| ()) } +fn tag_ref(name: &str) -> IdeResult { + let name = name.trim(); + if name.is_empty() || name.starts_with('-') || name.contains("..") || name.contains(':') { + return Err(IdeError::invalid(format!("'{name}' is not a valid tag name"))); + } + Ok(format!("refs/tags/{name}")) +} + +pub(crate) fn delete_tag(r: &Repo, name: &str) -> IdeResult { + let tag = tag_ref(name)?; + let target = run(r.root(), &["rev-parse", "-q", "--verify", &tag], None) + .map_err(|_| IdeError::invalid(format!("Could not find tag {name}")))? + .stdout_str(); + let target = target.trim().to_string(); + run(r.root(), &["update-ref", "-d", &tag, &target], None)?; + Ok(crate::DeletedTag { name: name.trim().to_string(), target }) +} + +pub(crate) fn restore_tag(r: &Repo, deleted: &crate::DeletedTag) -> IdeResult<()> { + let tag = tag_ref(&deleted.name)?; + let target = deleted.target.trim(); + if target.is_empty() || target.starts_with('-') { + return Err(IdeError::invalid("invalid tag target")); + } + // Only recreates it: a tag made with that name meanwhile stays. + run(r.root(), &["update-ref", &tag, target, ""], None).map(|_| ()) +} + +pub(crate) fn push_tag( + r: &Repo, + name: &str, + remote: &str, + delete: bool, + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult<()> { + let tag = tag_ref(name)?; + let remote = remote.trim(); + if remote.is_empty() || remote.starts_with('-') { + return Err(IdeError::invalid(format!("invalid remote '{remote}'"))); + } + let spec = if delete { format!(":{tag}") } else { format!("{tag}:{tag}") }; + cli::run_streaming(r.root(), &["push", "--progress", remote, &spec], cancel, on_output) +} + /// Tag names, sorted. pub(crate) fn tags(r: &Repo) -> IdeResult> { let repo = r.gix()?; @@ -394,6 +523,44 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> { .collect() } +/// Added and deleted lines between two revisions. `paths` limits the diff (pass both names of +/// a rename); an empty list, or one too long for the command line, diffs every file. +pub(crate) fn diff_numstat(r: &Repo, from: &str, to: &str, paths: &[String]) -> IdeResult> { + check_commits(&[from.to_string(), to.to_string()])?; + let mut args = vec!["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to]; + // Windows command lines are short; past this, the unfiltered diff still contains these paths. + let limited = !paths.is_empty() && paths.iter().map(|p| p.len() + 1).sum::() <= 16_000; + if limited { + args.push("--"); + args.extend(paths.iter().filter(|p| !p.is_empty()).map(String::as_str)); + } + let out = run(r.root(), &args, None)?; + Ok(parse_numstat_z(&out.stdout_str())) +} + +/// `git diff --numstat -z -M`: `added\tremoved\tpath\0`, or `added\tremoved\0old\0new\0` for a rename. +/// Binary files (`-`) count as 0. +pub(crate) fn parse_numstat_z(text: &str) -> Vec { + let mut parts = text.split('\0').filter(|s| !s.is_empty()); + let mut out = Vec::new(); + while let Some(rec) = parts.next() { + let mut fields = rec.splitn(3, '\t'); + let added = fields.next().unwrap_or("0").parse().unwrap_or(0); + let removed = fields.next().unwrap_or("0").parse().unwrap_or(0); + let (path, old_path) = if let Some(path) = fields.next().filter(|p| !p.is_empty()) { + (path.to_owned(), None) + } else { + let (Some(old), Some(new)) = (parts.next(), parts.next()) else { break }; + (new.to_owned(), Some(old.to_owned())) + }; + if !path.is_empty() { + out.push(FileLineStats { path, old_path, added, removed }); + } + } + out.sort_by(|a, b| a.path.cmp(&b.path).then(a.old_path.cmp(&b.old_path))); + out +} + /// Files that differ between two revisions (Update Project's "N files updated"). pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult> { check_commits(&[from.to_string(), to.to_string()])?; @@ -457,4 +624,17 @@ mod tests { vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)] ); } + + #[test] + fn numstat_z_parsing_counts_renames_and_binaries_as_zero() { + let text = "1\t0\timg.bin\u{0}2\t1\u{0}file.txt\u{0}renamed.txt\u{0}-\t-\tbin.dat\u{0}"; + assert_eq!( + parse_numstat_z(text), + vec![ + FileLineStats { path: "bin.dat".into(), old_path: None, added: 0, removed: 0 }, + FileLineStats { path: "img.bin".into(), old_path: None, added: 1, removed: 0 }, + FileLineStats { path: "renamed.txt".into(), old_path: Some("file.txt".into()), added: 2, removed: 1 }, + ] + ); + } } diff --git a/crates/ide-vcs/src/preserve.rs b/crates/ide-vcs/src/preserve.rs new file mode 100644 index 000000000..ad5717e04 --- /dev/null +++ b/crates/ide-vcs/src/preserve.rs @@ -0,0 +1,68 @@ +//! Local changes put aside around merge, rebase and pull, and brought back once the +//! operation is over: staged changes as staged (git's `--autostash` brings everything +//! back unstaged). An operation that stops on conflicts keeps them aside until it is +//! continued, skipped, aborted or concluded by a commit. + +use crate::Repo; +use crate::cli::{self, run}; +use crate::write::unmerged_paths; +use ide_core::{IdeError, IdeResult}; + +/// `.git/` holds the id of the stash the changes went to. +const MARKER: &str = "openidea-saved-changes"; + +/// Stashes the local changes of tracked files, if any. +pub(crate) fn save(r: &Repo, message: &str) -> IdeResult<()> { + let marker = r.git_dir.join(MARKER); + if marker.exists() { + // Changes saved by an operation still in progress stay where they are; those of + // one concluded outside the IDE come back first. + if r.branch_info()?.operation.is_some() { + return Ok(()); + } + restore(r)?; + } + if run(r.root(), &["status", "--porcelain", "--untracked-files=no"], None)?.stdout_str().trim().is_empty() { + return Ok(()); + } + let before = run(r.root(), &["rev-parse", "-q", "--verify", "refs/stash"], None).map(|o| o.stdout_str()).ok(); + run(r.root(), &["stash", "push", "-q", "-m", message], None)?; + let id = run(r.root(), &["rev-parse", "refs/stash"], None)?.stdout_str(); + if before.as_deref().map(str::trim) == Some(id.trim()) { + return Err(IdeError::invalid("could not save the local changes")); + } + std::fs::write(marker, id.trim())?; + Ok(()) +} + +/// Brings the saved changes back once no operation is in progress. Fails when they come +/// back with conflicts (they then also stay in the stash). +pub(crate) fn restore(r: &Repo) -> IdeResult<()> { + let marker = r.git_dir.join(MARKER); + let Ok(saved) = std::fs::read_to_string(&marker) else { return Ok(()) }; + if r.branch_info()?.operation.is_some() { + return Ok(()); + } + let _ = std::fs::remove_file(&marker); + let saved = saved.trim(); + let stashes = run(r.root(), &["stash", "list", "--format=%H"], None)?.stdout_str(); + let Some(index) = stashes.lines().position(|id| id.trim() == saved) else { return Ok(()) }; + let entry = format!("stash@{{{index}}}"); + if cli::run_raw(r.root(), &["stash", "pop", "-q", "--index", &entry], None)?.code == Some(0) { + return Ok(()); + } + // The staged part no longer applies as it was: bring everything back unstaged. + if unmerged_paths(r)?.is_empty() && cli::run_raw(r.root(), &["stash", "pop", "-q", &entry], None)?.code == Some(0) { + return Ok(()); + } + Err(IdeError::invalid("local changes were restored with conflicts; resolve them (a copy stays in the stash)")) +} + +/// Runs `op` with the local changes put aside, and brings them back if it finished. +pub(crate) fn around(r: &Repo, message: &str, op: impl FnOnce() -> IdeResult) -> IdeResult { + save(r, message)?; + let result = op(); + let restored = restore(r); + let value = result?; + restored.map(|_| value) +} diff --git a/crates/ide-vcs/src/refs.rs b/crates/ide-vcs/src/refs.rs index 1be20430d..24a0db405 100644 --- a/crates/ide-vcs/src/refs.rs +++ b/crates/ide-vcs/src/refs.rs @@ -4,6 +4,9 @@ use crate::repo::{gx, to_string}; use crate::{Branch, BranchInfo, Repo, Stash}; use ide_core::IdeResult; +// Filter symbolic refs before shortening; `refname:short` also strips remote `/HEAD` suffixes. +pub(crate) const DIRECT_BRANCH_FORMAT: &str = "--format=%(if)%(symref)%(then)%(else)%(refname:lstrip=2)%(end)"; + pub(crate) fn branch_info(r: &Repo) -> IdeResult { let repo = r.gix()?; let head_ref = repo.head_name().map_err(gx)?; @@ -93,6 +96,9 @@ pub(crate) fn branches(r: &Repo) -> IdeResult> { for reference in platform.local_branches().map_err(gx)? { let mut reference = reference.map_err(gx)?; + if reference.target().try_name().is_some() { + continue; + } let full = reference.name().to_owned(); let Ok(id) = reference.peel_to_id().map(|id| id.detach()) else { continue }; let current = head_name.as_ref().is_some_and(|h| h == &full); @@ -118,11 +124,11 @@ pub(crate) fn branches(r: &Repo) -> IdeResult> { } for reference in platform.remote_branches().map_err(gx)? { let mut reference = reference.map_err(gx)?; - let full = reference.name().to_owned(); - // Skip `refs/remotes//HEAD` symbolic pointers. - if full.as_bstr().ends_with(b"/HEAD") { + // Skip symbolic aliases such as `refs/remotes//HEAD`. + if reference.target().try_name().is_some() { continue; } + let full = reference.name().to_owned(); let Ok(id) = reference.peel_to_id().map(|id| id.detach()) else { continue }; out.push(Branch { name: to_string(full.as_ref().shorten()), diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 4e4f92832..1e4549744 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -372,11 +372,13 @@ pub(crate) fn commit( args.extend(["-F", "-"]); Some(message.as_bytes()) }; - if options.partial.is_empty() { + if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() { run(r.root(), &args, stdin)?; } else { - commit_partial(r, &args, stdin, &options.partial, encoding)?; + commit_selected(r, &args, stdin, options, encoding)?; } + // A commit concluding a merge brings back the local changes put aside for it. + let _ = crate::preserve::restore(r); let repo = r.gix()?; Ok(repo.head_id().map_err(gx)?.detach().to_string()) } @@ -391,15 +393,16 @@ impl Drop for TempIndex { } } -/// Commits the index with only the chosen hunks of `partial` files, through a copy of the -/// index (`GIT_INDEX_FILE`), as IDEA's partial commit does: the working tree and the -/// user's index are left alone except that the partial files' entries end up matching -/// the new commit. -fn commit_partial( +/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`): +/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and +/// `partial` only the chosen hunks. The working tree and the user's index are left alone +/// except that the committed files' entries end up matching the new commit, so changes +/// left out keep their staged state. +fn commit_selected( r: &Repo, commit_args: &[&str], stdin: Option<&[u8]>, - partial: &[crate::PartialCommitFile], + options: &CommitOptions, encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult<()> { let tmp = TempIndex(r.git_dir.join("openidea-partial-index")); @@ -408,8 +411,22 @@ fn commit_partial( std::fs::copy(&index, &tmp.0)?; } let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())]; - let mut rels = Vec::new(); - for file in partial { + let rel_paths = |paths: &[ide_core::IpcPath]| -> IdeResult> { + let paths: Vec = paths.iter().map(|p| p.0.clone()).collect(); + r.rel_paths(&paths) + }; + let exclude = rel_paths(&options.exclude)?; + if !exclude.is_empty() { + let args: &[&str] = if r.has_head()? { + &["--literal-pathspecs", "reset", "-q", "HEAD", "--"] + } else { + &["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--"] + }; + cli::run_paths_env(r.root(), args, &exclude, None, &env)?; + } + let mut rels = rel_paths(&options.stage)?; + cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], &rels, None, &env)?; + for file in &options.partial { let rel = r.rel_path(&file.path.0)?; let head = { let repo = r.gix()?; @@ -525,8 +542,16 @@ fn switch_plan(r: &Repo, branch: &str, create: bool, diverged: Option Option> { - let mut lines = - stderr.lines().skip_while(|l| !l.contains("local changes to the following files would be overwritten")); + listed_files(stderr, "local changes to the following files would be overwritten") +} + +/// Untracked files git refuses to overwrite, from its error message. +fn overwritten_untracked(stderr: &str) -> Option> { + listed_files(stderr, "untracked working tree files would be") +} + +fn listed_files(stderr: &str, header: &str) -> Option> { + let mut lines = stderr.lines().skip_while(|l| !l.contains(header)); lines.next()?; Some(lines.take_while(|l| l.starts_with('\t')).map(|l| l.trim().to_string()).collect()) } @@ -547,10 +572,13 @@ pub(crate) fn checkout_with( let mut args = match switch_plan(r, branch, create, options.diverged)? { SwitchPlan::Ahead { local, remote } => return Ok(CheckoutOutcome::LocalBranchAhead { local, remote }), SwitchPlan::Rebase { local, remote } => { - let out = cli::run_raw(r.root(), &["rebase", "--autostash", &remote, &local], None)?; + crate::preserve::save(r, "Local changes saved before checkout")?; + let out = cli::run_raw(r.root(), &["rebase", &remote, &local], None)?; if out.code == Some(0) { + crate::preserve::restore(r)?; return Ok(CheckoutOutcome::Done); } + crate::preserve::restore(r)?; let files = unmerged_paths(r)?; if files.is_empty() { return Err(cli::failure(&["rebase"], &out.stderr, &out.stdout, out.code)); @@ -567,7 +595,11 @@ pub(crate) fn checkout_with( if out.code == Some(0) { return Ok(CheckoutOutcome::Done); } - let Some(files) = overwritten_files(&String::from_utf8_lossy(&out.stderr)) else { + let stderr = String::from_utf8_lossy(&out.stderr); + if let Some(files) = overwritten_untracked(&stderr) { + return Ok(CheckoutOutcome::UntrackedFiles { files }); + } + let Some(files) = overwritten_files(&stderr) else { return Err(cli::failure(&argv, &out.stderr, &out.stdout, out.code)); }; if options.mode != CheckoutMode::Smart { @@ -583,7 +615,17 @@ pub(crate) fn checkout_with( run(r.root(), &["stash", "pop", "-q", "--index"], None)?; return Err(e); } - let pop = cli::run_raw(r.root(), &["stash", "pop", "-q"], None)?; + // Staged changes come back staged; when the index can't be restored as it was (it + // conflicts with the new branch), the changes come back unstaged. + let pop = cli::run_raw(r.root(), &["stash", "pop", "-q", "--index"], None)?; + if pop.code == Some(0) { + return Ok(CheckoutOutcome::Done); + } + let pop = if unmerged_paths(r)?.is_empty() && stash_count(r)? > before { + cli::run_raw(r.root(), &["stash", "pop", "-q"], None)? + } else { + pop + }; if pop.code == Some(0) { return Ok(CheckoutOutcome::Done); } @@ -623,6 +665,104 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: /// Undo Commit: moves the current branch back to the parent of `commit` (which must be /// HEAD), keeping its changes staged. Returns the undone commit's message. +/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top +/// of it with their own trees, then moves HEAD to the new tip. Trees don't change, so the +/// index and the working tree stay as they are (local changes included). Returns HEAD +/// before and after, for [`restore_head`]. +pub(crate) fn reword(r: &Repo, commit: &str, message: &str) -> IdeResult<(String, String)> { + if r.branch_info()?.operation.is_some() { + return Err(IdeError::invalid( + "cannot edit a commit message while a merge, rebase or cherry-pick is in progress", + )); + } + let message = message.trim(); + if message.is_empty() { + return Err(IdeError::invalid("commit message is empty")); + } + let (target, head) = { + let repo = r.gix()?; + let head = repo.head_id().map_err(|_| IdeError::invalid("there is no commit to edit"))?.detach(); + let target = repo.find_object(resolve(&repo, commit)?).map_err(gx)?.peel_to_commit().map_err(gx)?.id; + (target.to_string(), head.to_string()) + }; + if cli::run_raw(r.root(), &["merge-base", "--is-ancestor", &target, &head], None)?.code != Some(0) { + return Err(IdeError::invalid("the commit is not in the current branch")); + } + // target..HEAD must be a straight line on top of the target. + let range = format!("{target}..{head}"); + let listed = run(r.root(), &["rev-list", "--reverse", "--topo-order", "--parents", &range], None)?.stdout_str(); + let mut chain = vec![target.clone()]; + for line in listed.lines() { + let ids: Vec<&str> = line.split_whitespace().collect(); + if ids.len() != 2 || ids[1] != chain.last().unwrap() { + return Err(IdeError::invalid("the commits after this one are not a linear history")); + } + chain.push(ids[0].to_string()); + } + let mut parent: Option = None; + for (i, id) in chain.iter().enumerate() { + let raw = run(r.root(), &["cat-file", "commit", id], None)?.stdout; + let text = String::from_utf8_lossy(&raw); + let (headers, body) = text.split_once("\n\n").unwrap_or((&text, "")); + let mut tree = ""; + let mut parents = Vec::new(); + let mut author = ""; + for line in headers.lines() { + if let Some(v) = line.strip_prefix("tree ") { + tree = v; + } else if let Some(v) = line.strip_prefix("parent ") { + parents.push(v.to_string()); + } else if let Some(v) = line.strip_prefix("author ") { + author = v; + } + } + if parents.len() > 1 { + return Err(IdeError::invalid("merge commits cannot be reworded")); + } + if let Some(p) = &parent { + parents = vec![p.clone()]; + } + let (name, rest) = author.split_once(" <").unwrap_or((author, "")); + let (email, date) = rest.split_once("> ").unwrap_or((rest, "")); + let env = [ + ("GIT_AUTHOR_NAME", std::ffi::OsStr::new(name)), + ("GIT_AUTHOR_EMAIL", std::ffi::OsStr::new(email)), + ("GIT_AUTHOR_DATE", std::ffi::OsStr::new(date)), + ]; + let mut args = vec!["commit-tree", tree]; + for p in &parents { + args.extend(["-p", p.as_str()]); + } + let new_message = if i == 0 { format!("{message}\n") } else { body.to_string() }; + let created = cli::run_env(r.root(), &args, Some(new_message.as_bytes()), &env)?.stdout_str(); + parent = Some(created.trim().to_string()); + } + let new_head = parent.expect("the chain holds the target"); + let reason = format!("reword: {}", message.lines().next().unwrap_or_default()); + run(r.root(), &["update-ref", "-m", &reason, "HEAD", &new_head, &head], None)?; + Ok((head, new_head)) +} + +/// Undo of a history rewrite: moves HEAD from `after` back to `before`, keeping local +/// changes (`git reset --keep`). Refused when HEAD has moved since. +pub(crate) fn restore_head(r: &Repo, before: &str, after: &str) -> IdeResult<()> { + if r.branch_info()?.operation.is_some() { + return Err(IdeError::invalid("cannot undo while a merge, rebase or cherry-pick is in progress")); + } + let head = run(r.root(), &["rev-parse", "--verify", "HEAD"], None)?.stdout_str(); + if head.trim() != after { + return Err(IdeError::invalid("the branch has changed since; undo is no longer possible")); + } + let before = check_rev(before)?; + let tree = |rev: &str| run(r.root(), &["rev-parse", &format!("{rev}^{{tree}}")], None).map(|o| o.stdout_str()); + if tree(before)? == tree(after)? { + run(r.root(), &["update-ref", "-m", "undo", "HEAD", before, after], None)?; + } else { + run(r.root(), &["reset", "-q", "--keep", before, "--"], None)?; + } + Ok(()) +} + pub(crate) fn undo_commit(r: &Repo, commit: &str) -> IdeResult { if r.branch_info()?.operation.is_some() { return Err(IdeError::invalid("cannot undo a commit while a merge, rebase or cherry-pick is in progress")); @@ -711,11 +851,17 @@ fn check_rev(rev: &str) -> IdeResult<&str> { /// Local changes are stashed and restored around the merge (IDEA's "save local changes"). pub(crate) fn merge(r: &Repo, branch: &str) -> IdeResult<()> { - run(r.root(), &["merge", "--no-edit", "--autostash", check_rev(branch)?], None).map(|_| ()) + let branch = check_rev(branch)?; + crate::preserve::around(r, "Local changes saved before merge", || { + run(r.root(), &["merge", "--no-edit", branch], None).map(|_| ()) + }) } pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> { - run(r.root(), &["rebase", "--autostash", check_rev(onto)?], None).map(|_| ()) + let onto = check_rev(onto)?; + crate::preserve::around(r, "Local changes saved before rebase", || { + run(r.root(), &["rebase", onto], None).map(|_| ()) + }) } pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { @@ -781,20 +927,16 @@ pub(crate) fn delete_remote_branch( pub(crate) fn push( r: &Repo, - force_with_lease: bool, - set_upstream: bool, - tags: Option, + o: crate::PushOptions, cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { let mut args: Vec = vec!["push".into(), "--progress".into()]; - if force_with_lease { - args.push("--force-with-lease".into()); - } - if tags == Some(crate::PushTags::CurrentBranch) { + args.extend(force_args(o.force, o.force_with_lease, o.no_verify)); + if o.tags == Some(crate::PushTags::CurrentBranch) { args.push("--follow-tags".into()); } - if set_upstream { + if o.set_upstream { let repo = r.gix()?; let head = repo.head_name().map_err(gx)?.ok_or_else(|| IdeError::invalid("cannot set upstream: HEAD is detached"))?; @@ -817,14 +959,49 @@ pub(crate) fn push( } let argv: Vec<&str> = args.iter().map(String::as_str).collect(); cli::run_streaming(r.root(), &argv, cancel, on_output)?; - if tags == Some(crate::PushTags::All) { + if o.tags == Some(crate::PushTags::All) { // `git push --tags` alone pushes only tags, so it runs after the branch push. let remote = push_remote(r)?; - cli::run_streaming(r.root(), &["push", "--progress", &remote, "--tags"], cancel, on_output)?; + let mut args = vec!["push", "--progress"]; + if o.no_verify { + args.push("--no-verify"); + } + args.extend([remote.as_str(), "--tags"]); + cli::run_streaming(r.root(), &args, cancel, on_output)?; } Ok(()) } +/// `--force` / `--force-with-lease` and `--no-verify` of a push. +fn force_args(force: bool, force_with_lease: bool, no_verify: bool) -> Vec { + let mut args = Vec::new(); + if force { + args.push("--force".into()); + } else if force_with_lease { + args.push("--force-with-lease".into()); + } + if no_verify { + args.push("--no-verify".into()); + } + args +} + +pub(crate) fn has_hook(r: &Repo, name: &str) -> IdeResult { + if name.is_empty() || name.contains(['/', '\\']) { + return Err(IdeError::invalid(format!("invalid hook name '{name}'"))); + } + let path = run(r.root(), &["rev-parse", "--git-path", &format!("hooks/{name}")], None)?.stdout_str(); + let path = r.root().join(path.trim()); + let Ok(meta) = std::fs::metadata(&path) else { return Ok(false) }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + Ok(meta.is_file() && meta.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + Ok(meta.is_file()) +} + /// Remote and branch name a local branch pulls from (`branch..remote` / `.merge`). fn branch_upstream(r: &Repo, branch: &str) -> Option<(String, String)> { let get = |key: &str| { @@ -855,10 +1032,18 @@ pub(crate) fn push_branch( cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { - let PushBranchOptions { revision, target, remote: remote_override, force_with_lease } = opts; + let PushBranchOptions { + revision, + target, + remote: remote_override, + force_with_lease, + force, + set_upstream, + no_verify, + } = opts; check_local_branch(r, branch)?; let remote_override = remote_override.map(str::trim).filter(|s| !s.is_empty()); - let (remote, mut name, set_upstream) = match (remote_override, branch_upstream(r, branch)) { + let (remote, mut name, untracked) = match (remote_override, branch_upstream(r, branch)) { // An explicit remote keeps the upstream's branch name but pushes elsewhere. (Some(remote), Some((_, name))) => (check_rev(remote)?.to_string(), name, false), (Some(remote), None) => (check_rev(remote)?.to_string(), branch.to_string(), revision.is_none()), @@ -879,10 +1064,8 @@ pub(crate) fn push_branch( None => format!("refs/heads/{branch}"), }; let mut args: Vec = vec!["push".into(), "--progress".into()]; - if force_with_lease { - args.push("--force-with-lease".into()); - } - if set_upstream { + args.extend(force_args(force, force_with_lease, no_verify)); + if untracked || (set_upstream && revision.is_none()) { args.push("--set-upstream".into()); } args.extend([remote, format!("{source}:refs/heads/{target}")]); @@ -904,6 +1087,27 @@ pub(crate) fn update_branch( cli::run_streaming(r.root(), &["fetch", "--progress", &remote, &refspec], cancel, on_output) } +/// Update Project › Reset to the Remote Branch: fetches the current branch's tracked +/// branch and moves the branch to it, dropping local commits. Local changes are put +/// aside and brought back (an error with the conflicts left in place when they clash). +pub(crate) fn reset_to_upstream(r: &Repo, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let head = run(r.root(), &["symbolic-ref", "-q", "--short", "HEAD"], None) + .map_err(|_| IdeError::invalid("cannot reset: HEAD is detached"))? + .stdout_str(); + let head = head.trim(); + let no_tracked = + || IdeError::invalid(format!("no tracked branch configured for branch {head} or the branch doesn't exist")); + let (remote, name) = branch_upstream(r, head).ok_or_else(no_tracked)?; + cli::run_streaming(r.root(), &["fetch", "--progress", &remote], cancel, on_output)?; + let tracked = format!("refs/remotes/{remote}/{name}"); + if run(r.root(), &["rev-parse", "-q", "--verify", &format!("{tracked}^{{commit}}")], None).is_err() { + return Err(no_tracked()); + } + crate::preserve::around(r, "Local changes saved before reset", || { + run(r.root(), &["reset", "-q", "--keep", &tracked], None).map(|_| ()) + }) +} + /// The remote the current branch pushes to (`origin`, or the only remote, by default). /// The remote and ref HEAD's branch tracks when the ref has another name: a plain /// `git push` refuses that under the default `push.default=simple`. diff --git a/crates/ide-vcs/tests/git_workflows.rs b/crates/ide-vcs/tests/git_workflows.rs index 00552a0bc..4103350ec 100644 --- a/crates/ide-vcs/tests/git_workflows.rs +++ b/crates/ide-vcs/tests/git_workflows.rs @@ -306,6 +306,17 @@ fn interactive_rebase_reorder_squash_fixup_reword_drop() { assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f1.txt\nf2.txt\nf3.txt"); } +#[test] +fn interactive_rebase_reword_keeps_lines_starting_with_a_hash() { + let (fx, ids) = linear(); + let (cancel, out) = quiet(); + let message = "Subject\n\n#body starting with a hash"; + fx.repo() + .interactive_rebase(Some(&ids[2]), &[entry(&ids[3], RebaseAction::Reword, Some(message))], &cancel, &out) + .unwrap(); + assert_eq!(fx.git(&["log", "-1", "--format=%B"]), message); +} + #[test] fn interactive_rebase_squash_without_message_combines_messages() { let (fx, ids) = linear(); @@ -407,6 +418,89 @@ fn interactive_rebase_conflict_stops_then_continues_with_reword() { assert_eq!(fx.subjects(), vec!["one", "two first", "base"]); } +/// `main` and `feature` both change `c.txt`: rebasing feature onto main stops on it. +fn rebase_stopped_on_a_conflict() -> (Fixture, Repo) { + let fx = Fixture::new(); + fx.write("c.txt", "base\n"); + fx.write("other.txt", "other\n"); + fx.commit_all("base"); + fx.git(&["switch", "-q", "-c", "feature"]); + fx.write("c.txt", "feature\n"); + fx.commit_all("feature change"); + fx.git(&["switch", "-q", "main"]); + fx.write("c.txt", "main\n"); + fx.commit_all("main change"); + fx.git(&["switch", "-q", "feature"]); + let repo = fx.repo(); + assert!(repo.rebase("main").is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + (fx, repo) +} + +#[test] +fn edits_of_a_resolved_file_go_with_it_when_the_rebase_continues() { + let (fx, repo) = rebase_stopped_on_a_conflict(); + fx.write("c.txt", "resolved\n"); + repo.stage(&[fx.path("c.txt")]).unwrap(); + fx.write("c.txt", "resolved\nmore\n"); + let (cancel, out) = quiet(); + repo.continue_operation(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["show", "HEAD:c.txt"]), "resolved\nmore"); +} + +#[test] +fn unstaged_changes_of_other_files_stop_the_rebase_until_staged() { + let (fx, repo) = rebase_stopped_on_a_conflict(); + fx.write("c.txt", "resolved\n"); + repo.stage(&[fx.path("c.txt")]).unwrap(); + fx.write("other.txt", "changed while stopped\n"); + let (cancel, out) = quiet(); + let err = repo.continue_operation(&cancel, &out).unwrap_err(); + assert_eq!(err.message, UNSTAGED_CHANGES_PREVENT_REBASE); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + repo.stage_and_continue(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "changed while stopped"); +} + +#[test] +fn stage_and_retry_after_editing_a_commit() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let plan = [entry(&ids[2], RebaseAction::Edit, None), entry(&ids[3], RebaseAction::Pick, None)]; + repo.interactive_rebase(Some(&ids[1]), &plan, &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + fx.write("f1.txt", "edited\n"); + std::fs::remove_file(fx.path("f2.txt")).unwrap(); + assert!(repo.continue_operation(&cancel, &out).is_err()); + repo.stage_and_continue(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["status", "--porcelain"]), ""); + assert!(!fx.exists("f2.txt")); +} + +#[test] +fn rebase_and_merge_onto_a_branch_named_like_a_tag_use_the_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.commit_all("base"); + fx.git(&["tag", "main"]); + fx.write("m.txt", "m\n"); + let tip = fx.commit_all("main moved on"); + fx.git(&["switch", "-q", "-c", "feature", "HEAD~1"]); + fx.write("f.txt", "f\n"); + fx.commit_all("feature"); + let repo = fx.repo(); + repo.rebase("refs/heads/main").unwrap(); + assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), tip); + fx.git(&["reset", "-q", "--hard", "HEAD~1"]); + fx.git(&["switch", "-q", "-c", "other", "refs/heads/main~1"]); + repo.merge("refs/heads/main").unwrap(); + assert_eq!(fx.head(), tip, "fast-forwarded to the branch, not the tag"); +} + #[test] fn interactive_rebase_edit_stops_for_amend() { let (fx, ids) = linear(); @@ -524,6 +618,157 @@ fn update_merge_with_local_changes_to_the_same_file() { assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1"); } +#[test] +fn update_with_rebase_keeps_a_file_added_then_deleted_added_to_the_index() { + let (fx, bare) = Fixture::with_origin(); + fx.push_from_other(&bare, "remote.txt", "r\n"); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + fx.write("ad.txt", "x\n"); + fx.git(&["add", "ad.txt"]); + std::fs::remove_file(fx.path("ad.txt")).unwrap(); + let (cancel, out) = quiet(); + fx.repo().pull(true, &cancel, &out).unwrap(); + assert!(fx.exists("remote.txt")); + assert!(fx.git(&["status", "--porcelain", "--", "ad.txt"]).starts_with('A'), "still added"); +} + +#[test] +fn reset_to_the_remote_branch_drops_local_commits_and_keeps_local_changes() { + let (fx, bare) = Fixture::with_origin(); + for i in 0..3 { + fx.push_from_other(&bare, "bro.txt", &format!("bro {i}\n")); + } + fx.write("local1.txt", "1\n"); + fx.commit_all("local commit"); + fx.write("untracked.txt", "u\n"); + fx.write("a.txt", "edited\n"); + fx.git(&["add", "a.txt"]); + let (cancel, out) = quiet(); + fx.repo().reset_to_upstream(&cancel, &out).unwrap(); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); + assert!(!fx.exists("local1.txt"), "files of local commits are gone"); + assert!(fx.exists("untracked.txt")); + assert_eq!(fx.git(&["status", "--porcelain", "--", "a.txt"]), "M a.txt", "staged changes stay staged"); + assert_eq!(fx.git(&["stash", "list"]), ""); +} + +#[test] +fn reset_to_the_remote_branch_without_new_remote_commits_drops_local_ones() { + let (fx, _bare) = Fixture::with_origin(); + let before = fx.head(); + fx.write("local1.txt", "1\n"); + fx.commit_all("local commit"); + let (cancel, out) = quiet(); + fx.repo().reset_to_upstream(&cancel, &out).unwrap(); + assert_eq!(fx.head(), before); +} + +#[test] +fn reset_to_the_remote_branch_leaves_conflicting_local_changes_to_resolve() { + let (fx, bare) = Fixture::with_origin(); + fx.push_from_other(&bare, "bro.txt", "bro\n"); + fx.write("bro.txt", "local content\n"); + fx.git(&["add", "bro.txt"]); + let (cancel, out) = quiet(); + assert!(fx.repo().reset_to_upstream(&cancel, &out).is_err()); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); + assert!(fx.read("bro.txt").contains("<<<<<<<"), "the clash is left to resolve"); + assert_eq!(fx.git(&["stash", "list"]).lines().count(), 1, "a copy stays in the stash"); +} + +#[test] +fn reset_to_the_remote_branch_needs_a_tracked_branch_that_exists() { + let (fx, bare) = Fixture::with_origin(); + let (cancel, out) = quiet(); + fx.git(&["branch", "--unset-upstream"]); + let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); + assert!(err.message.contains("no tracked branch"), "{}", err.message); + + fx.git(&["push", "-q", "-u", "origin", "main:feature"]); + fx.git(&["switch", "-q", "-c", "feature", "--track", "origin/feature"]); + git_in(&bare, &["branch", "-D", "feature"]); + fx.git(&["fetch", "-q", "--prune"]); + let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); + assert!(err.message.contains("no tracked branch"), "{}", err.message); +} + +#[cfg(unix)] +#[test] +fn push_can_skip_a_failing_pre_push_hook() { + use std::os::unix::fs::PermissionsExt; + let (fx, _bare) = Fixture::with_origin(); + let repo = fx.repo(); + assert!(!repo.has_hook("pre-push").unwrap()); + let hook = fx.path(".git/hooks/pre-push"); + std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); + std::fs::write(&hook, "#!/bin/sh\necho refused >&2\nexit 1\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(repo.has_hook("pre-push").unwrap()); + + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + let (cancel, out) = quiet(); + assert!(repo.push(false, false, &cancel, &out).is_err(), "the hook refuses the push"); + let options = PushOptions { no_verify: true, ..Default::default() }; + repo.push_with(options, &cancel, &out).unwrap(); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); +} + +#[test] +fn force_push_without_lease_overwrites_a_remote_that_moved() { + let (fx, bare) = Fixture::with_origin(); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + fx.push_from_other(&bare, "remote.txt", "r\n"); + fx.git(&["fetch", "-q"]); + fx.push_from_other(&bare, "remote2.txt", "r\n"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let lease = PushOptions { force_with_lease: true, ..Default::default() }; + let err = repo.push_with(lease, &cancel, &out).unwrap_err(); + assert!(err.message.contains("stale info"), "{}", err.message); + repo.push_with(PushOptions { force: true, ..Default::default() }, &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.head()); +} + +#[test] +fn push_to_another_branch_can_make_it_the_upstream() { + let (fx, _bare) = Fixture::with_origin(); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let to = |target, set_upstream| PushBranchOptions { target: Some(target), set_upstream, ..Default::default() }; + repo.push_branch_to("main", to("feature", false), &cancel, &out).unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/main", "upstream kept"); + repo.push_branch_to("main", to("feature-1", true), &cancel, &out).unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/feature-1"); +} + +#[test] +fn tags_are_pushed_one_at_a_time_deleted_restored_and_deleted_on_the_remote() { + let (fx, bare) = Fixture::with_origin(); + fx.git(&["tag", "-a", "v1", "-m", "release one"]); + fx.git(&["tag", "v2"]); + let repo = fx.repo(); + let (cancel, out) = quiet(); + repo.push_tag("v1", "origin", &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["tag"]), "v1", "only the chosen tag goes"); + repo.push_tag("v1", "origin", &cancel, &out).unwrap(); + + let deleted = repo.delete_tag("v1").unwrap(); + assert_eq!(fx.git(&["tag"]), "v2"); + repo.restore_tag(&deleted).unwrap(); + assert_eq!(fx.git(&["cat-file", "-t", "v1"]), "tag", "annotated, as it was"); + assert!(fx.git(&["tag", "-n1", "v1"]).contains("release one")); + + repo.delete_tag("v1").unwrap(); + repo.delete_remote_tag("v1", "origin", &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["tag"]), ""); + assert!(repo.delete_tag("missing").is_err()); +} + #[test] fn fetch_prunes_deleted_remote_branches() { let (fx, bare) = Fixture::with_origin(); @@ -863,3 +1108,100 @@ fn remote_progress_is_streamed() { fx.repo().push(false, false, &CancellationToken::new(), &move |l| sink.lock().unwrap().push(l)).unwrap(); assert!(!lines.lock().unwrap().is_empty()); } + +#[test] +fn interactive_rebase_restores_local_changes_staged_as_they_were() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + fx.write("f2.txt", "unstaged\n"); + repo.interactive_rebase( + Some(&ids[1]), + &[entry(&ids[2], RebaseAction::Drop, None), entry(&ids[3], RebaseAction::Pick, None)], + &cancel, + &out, + ) + .unwrap(); + assert_eq!(fx.subjects(), vec!["c4", "c2", "c1"]); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); + assert!(!fx.path(".git/openidea-rebase").exists()); +} + +#[test] +fn local_changes_come_back_once_a_stopped_rebase_is_continued() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + // c4 rewrites f3.txt so that dropping c3 conflicts. + fx.write("f3.txt", "3\nmore\n"); + fx.commit_all("c5"); + let c5 = fx.git(&["rev-parse", "HEAD"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + let result = repo.interactive_rebase( + Some(&ids[1]), + &[ + entry(&ids[2], RebaseAction::Drop, None), + entry(&ids[3], RebaseAction::Pick, None), + entry(&c5, RebaseAction::Pick, None), + ], + &cancel, + &out, + ); + assert!(result.is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the rebase is stopped"); + fx.write("f3.txt", "3\nmore\n"); + repo.stage(&[fx.path("f3.txt")]).unwrap(); + repo.continue_operation(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.read("f1.txt"), "staged\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} + +#[test] +fn merge_brings_staged_changes_back_staged() { + let (fx, _ids) = linear(); + let repo = fx.repo(); + fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); + fx.write("side.txt", "side\n"); + fx.git(&["add", "side.txt"]); + fx.git(&["commit", "-q", "-m", "side"]); + fx.git(&["checkout", "-q", "main"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + fx.write("f2.txt", "unstaged\n"); + repo.merge_with(&["side".to_string()], &MergeOptions::default()).unwrap(); + assert!(fx.exists("side.txt")); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} + +#[test] +fn local_changes_come_back_after_a_conflicting_merge_is_committed() { + let (fx, _ids) = linear(); + let repo = fx.repo(); + fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); + fx.write("f4.txt", "side\n"); + fx.git(&["add", "f4.txt"]); + fx.git(&["commit", "-q", "-m", "side f4"]); + fx.git(&["checkout", "-q", "main"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + assert!(repo.merge_with(&["side".to_string()], &MergeOptions::default()).is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge")); + assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the merge is in progress"); + fx.write("f4.txt", "both\n"); + repo.stage(&[fx.path("f4.txt")]).unwrap(); + repo.commit("Merge side", false, false).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.read("f1.txt"), "staged\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index 2d86ebc49..f2af0663e 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -149,6 +149,21 @@ fn merge_versions_put_the_users_work_on_the_left() { assert_eq!(repo.merge_sides().unwrap(), MergeSides { branch: Some("topic".into()), ..Default::default() }); } +#[test] +fn rebase_conflict_names_the_remote_branch_without_its_symbolic_aliases() { + let fx = diverged(); + let main = fx.git(&["rev-parse", "main"]); + fx.git(&["update-ref", "refs/remotes/origin/main", &main]); + fx.git(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + fx.git(&["symbolic-ref", "refs/remotes/origin/alias", "refs/remotes/origin/main"]); + fx.git(&["branch", "-D", "main"]); + let repo = fx.repo(); + assert!(repo.rebase("origin/main").is_err()); + assert_eq!(repo.merge_sides().unwrap().other_branch.as_deref(), Some("origin/main")); + let (cancel, out) = quiet(); + repo.abort_operation(&cancel, &out).unwrap(); +} + #[test] fn rebase_conflict_then_abort_and_skip() { let fx = diverged(); @@ -662,3 +677,45 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap(); assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt"); } + +#[test] +fn cherry_pick_skips_commits_already_applied() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "side"]); + fx.write("a.txt", "2\n"); + let same = fx.commit_all("same change"); + fx.write("b.txt", "b\n"); + let other = fx.commit_all("other change"); + fx.git(&["checkout", "-q", "main"]); + // main already has the first change. + fx.write("a.txt", "2\n"); + fx.commit_all("same change, made on main"); + let repo = fx.repo(); + let cancel = CancellationToken::new(); + let lines = std::sync::Mutex::new(Vec::new()); + let out = |l: String| lines.lock().unwrap().push(l); + + repo.cherry_pick_with(&[same.clone(), other], true, &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None, "nothing left in progress"); + assert_eq!(fx.git(&["log", "--format=%s", "-2"]), "other change\nsame change, made on main"); + assert!(fx.git(&["log", "-1", "--format=%B"]).contains("(cherry picked from commit")); + assert!(lines.lock().unwrap().iter().any(|l| l.starts_with(&same[..8]) && l.contains("skipped"))); +} + +#[test] +fn revert_of_a_commit_already_reverted_is_skipped() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("one"); + fx.write("a.txt", "2\n"); + let two = fx.commit_all("two"); + fx.write("a.txt", "1\n"); + fx.commit_all("undo two by hand"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + repo.revert(&[two], &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "3", "no empty revert commit"); +} diff --git a/crates/ide-vcs/tests/log_projection.rs b/crates/ide-vcs/tests/log_projection.rs index a10d7dd63..822d89569 100644 --- a/crates/ide-vcs/tests/log_projection.rs +++ b/crates/ide-vcs/tests/log_projection.rs @@ -113,6 +113,43 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() { assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N"); } +#[test] +fn containing_branches_exclude_symbolic_aliases_but_keep_direct_head_names() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let tip = commit(dir, "a", "tip"); + git(dir, &["branch", "topic/HEAD"]); + for remote in ["origin", "contributor-742", "issue-739", "issue-765"] { + let main = format!("refs/remotes/{remote}/main"); + git(dir, &["update-ref", &main, &tip]); + git(dir, &["symbolic-ref", &format!("refs/remotes/{remote}/HEAD"), &main]); + git(dir, &["symbolic-ref", &format!("refs/remotes/{remote}/alias"), &main]); + } + git(dir, &["update-ref", "refs/remotes/origin/topic/HEAD", &tip]); + git(dir, &["symbolic-ref", "refs/heads/local-alias", "refs/heads/main"]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let expected = [ + "contributor-742/main", + "issue-739/main", + "issue-765/main", + "main", + "origin/main", + "origin/topic/HEAD", + "topic/HEAD", + ]; + let mut branches = repo.log_details(&tip).unwrap().containing_branches; + branches.sort(); + assert_eq!(branches, expected); + let mut listed: Vec<_> = repo.branches().unwrap().into_iter().map(|b| b.name).collect(); + listed.sort(); + assert_eq!(listed, expected); + let log = repo.log(&LogQuery::default()).unwrap(); + let mut labels: Vec<_> = log[0].refs.iter().filter(|r| *r != "HEAD").cloned().collect(); + labels.sort(); + assert_eq!(labels, expected); +} + #[test] fn followed_history_carries_the_filename_at_each_revision() { let tmp = tempfile::tempdir().unwrap(); @@ -462,3 +499,204 @@ fn case_insensitive_text_folds_non_ascii() { assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]); assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty()); } + +/// File history (Show History of a file) as (subject, path at that revision), newest first. +fn file_history(dir: &Path, path: &str) -> Vec<(String, String)> { + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { path: Some(path.into()), follow: true, ..Default::default() }).unwrap(); + log.into_iter().map(|c| (c.summary, c.history_path.unwrap_or_default())).collect() +} + +fn history_repo() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + git(&dir, &["init", "-q"]); + (tmp, dir) +} + +fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> { + list.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +#[test] +fn file_history_goes_through_a_rename_on_a_merged_branch() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file.txt", "Created"); + git(&dir, &["switch", "-qc", "feature"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed"]); + git(&dir, &["switch", "-q", "main"]); + commit(&dir, "other.txt", "Unrelated"); + git(&dir, &["merge", "-q", "--no-ff", "-m", "Merged", "feature"]); + commit(&dir, "renamed.txt", "Modified"); + let history = file_history(&dir, "renamed.txt"); + assert_eq!(history, pairs(&[("Modified", "renamed.txt"), ("Renamed", "renamed.txt"), ("Created", "file.txt")])); +} + +#[test] +fn file_history_goes_through_a_rename_done_in_a_merge_commit() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file.txt", "Created"); + git(&dir, &["switch", "-qc", "feature"]); + commit(&dir, "side.txt", "Side"); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "feature"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Merge with rename"]); + let history = file_history(&dir, "renamed.txt"); + assert_eq!(history, pairs(&[("Merge with rename", "renamed.txt"), ("Created", "file.txt")])); +} + +#[test] +fn file_history_shows_merges_that_change_the_file_but_not_trivial_ones() { + let (_tmp, dir) = history_repo(); + std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Created"]); + git(&dir, &["switch", "-qc", "feature"]); + std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\nfeature\n").unwrap(); + git(&dir, &["commit", "-qam", "Feature"]); + git(&dir, &["switch", "-q", "main"]); + commit(&dir, "other.txt", "Other"); + git(&dir, &["merge", "-q", "--no-ff", "-m", "Trivial merge", "feature"]); + assert_eq!( + file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect::>(), + vec!["Feature", "Created"] + ); + + git(&dir, &["switch", "-qc", "second", "HEAD~1"]); + std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\n").unwrap(); + git(&dir, &["commit", "-qam", "Second"]); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "second"]); + std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\nfeature\nresolved\n").unwrap(); + git(&dir, &["commit", "-qam", "Non-trivial merge"]); + let subjects: Vec = file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect(); + assert_eq!(subjects[0], "Non-trivial merge"); + assert!(subjects.contains(&"Second".to_string()) && subjects.contains(&"Feature".to_string())); +} + +#[test] +fn file_history_through_a_monorepo_merge_keeps_each_side() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file1.txt", "Created file1"); + git(&dir, &["switch", "-q", "--orphan", "repo2"]); + commit(&dir, "file2.txt", "Created file2"); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-commit", "--allow-unrelated-histories", "repo2"]); + std::fs::create_dir_all(dir.join("repo1")).unwrap(); + std::fs::create_dir_all(dir.join("repo2")).unwrap(); + git(&dir, &["mv", "file1.txt", "repo1/file1.txt"]); + git(&dir, &["mv", "file2.txt", "repo2/file2.txt"]); + git(&dir, &["commit", "-qm", "monorepo merge"]); + assert_eq!( + file_history(&dir, "repo1/file1.txt"), + pairs(&[("monorepo merge", "repo1/file1.txt"), ("Created file1", "file1.txt")]) + ); + assert_eq!( + file_history(&dir, "repo2/file2.txt"), + pairs(&[("monorepo merge", "repo2/file2.txt"), ("Created file2", "file2.txt")]) + ); +} + +#[test] +fn file_history_through_parallel_renames_names_the_path_of_each_revision() { + let (_tmp, dir) = history_repo(); + std::fs::write(dir.join("file.txt"), "base\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Created"]); + git(&dir, &["switch", "-qc", "other"]); + std::fs::write(dir.join("file.txt"), "base\nother\n").unwrap(); + git(&dir, &["commit", "-qam", "Modified on other"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed on other"]); + git(&dir, &["switch", "-q", "main"]); + std::fs::write(dir.join("file.txt"), "main\nbase\n").unwrap(); + git(&dir, &["commit", "-qam", "Modified on main"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed on main"]); + // Both sides changed the file: the merge stops for the resolution. + let _ = Command::new("git").current_dir(&dir).args(["merge", "-q", "--no-ff", "--no-commit", "other"]).output(); + std::fs::write(dir.join("renamed.txt"), "main\nbase\nother\nmerged\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Merged other"]); + let mut history = file_history(&dir, "renamed.txt"); + history.sort(); + assert_eq!( + history, + pairs(&[ + ("Created", "file.txt"), + ("Merged other", "renamed.txt"), + ("Modified on main", "file.txt"), + ("Modified on other", "file.txt"), + ("Renamed on main", "renamed.txt"), + ("Renamed on other", "renamed.txt"), + ]) + ); +} + +#[test] +fn file_history_through_a_cyclic_rename() { + let (_tmp, dir) = history_repo(); + commit(&dir, "a.txt", "Created a"); + git(&dir, &["mv", "a.txt", "b.txt"]); + git(&dir, &["commit", "-qm", "a to b"]); + commit(&dir, "b.txt", "Modified b"); + git(&dir, &["mv", "b.txt", "a.txt"]); + git(&dir, &["commit", "-qm", "b to a"]); + assert_eq!( + file_history(&dir, "a.txt"), + pairs(&[("b to a", "a.txt"), ("Modified b", "b.txt"), ("a to b", "b.txt"), ("Created a", "a.txt")]) + ); +} + +/// One commit per file under `dir/`, made with fast-import so hundreds stay quick. +fn commit_files(repo_dir: &Path, count: usize, name: impl Fn(usize) -> String) { + let mut stream = String::new(); + for i in 0..count { + let path = name(i); + stream.push_str(&format!( + "commit refs/heads/main\ncommitter T {} +0000\ndata 3\nc{}\n", + 1_700_000_000 + i, + i % 10 + )); + stream.push_str(&format!("M 100644 inline {path}\ndata 1\nx\n\n")); + } + let mut child = Command::new("git") + .current_dir(repo_dir) + .args(["fast-import", "--quiet"]) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap(); + assert!(child.wait().unwrap().success()); + git(repo_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + git(repo_dir, &["reset", "-q", "--hard"]); +} + +#[test] +fn followed_directory_history_survives_hundreds_of_created_files() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + // Every created file adds a `^^@` exclusion: ~800 of them passed as arguments exceed + // Windows' command-line limit (os error 206). + commit_files(dir, 800, |i| format!("module/src/main/java/com/example/generated/Class{i:04}.java")); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { path: Some("module".into()), follow: true, ..Default::default() }).unwrap(); + assert_eq!(log.len(), 800); +} + +#[test] +fn many_selected_paths_filter_history_without_a_huge_command_line() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + commit_files(dir, 600, |i| format!("module/src/main/java/com/example/generated/Class{i:04}.java")); + let repo = Repo::discover(dir).unwrap().unwrap(); + let paths: Vec = + (0..500).map(|i| format!("module/src/main/java/com/example/generated/Class{i:04}.java")).collect(); + let paths = paths.into_iter().map(Into::into).collect(); + let log = repo.log(&LogQuery { paths, text: Some("c3".into()), ..Default::default() }).unwrap(); + assert_eq!(log.len(), 50); +} diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index db66e0d6e..ba3fe7fa8 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -594,6 +594,42 @@ fn commit_files_kinds() { assert!(repo.commit_files("HEAD").unwrap().len() == 4); } +/// Line counts follow the same first-parent diff as the commit's file list, including renames. +#[test] +fn diff_numstat_counts_added_deleted_and_renames() { + let fx = Fixture::new(); + fx.write("keep.txt", "a\nb\nc\n"); + fx.write("mod.txt", "m\n"); + fx.write("old.txt", "a fairly long line of content\nanother line\nand a third\n"); + let root = fx.commit_all("root"); + fx.write("mod.txt", "m\nmore\n"); + std::fs::remove_file(fx.path("keep.txt")).unwrap(); + fx.write("fresh.txt", "n1\nn2\n"); + fx.git(&["mv", "old.txt", "new.txt"]); + fx.write("new.txt", "a fairly long line of content\nanother line\nchanged\n"); + let c = fx.commit_all("changes"); + let repo = fx.repo(); + + let stats = repo.diff_numstat(&root, &c, &[]).unwrap(); + let find = |p: &str| stats.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p}: {stats:#?}")); + assert_eq!((find("mod.txt").added, find("mod.txt").removed), (1, 0)); + assert_eq!((find("keep.txt").added, find("keep.txt").removed), (0, 3)); + assert_eq!((find("fresh.txt").added, find("fresh.txt").removed), (2, 0)); + assert_eq!(find("new.txt").old_path.as_deref(), Some("old.txt")); + assert_eq!((find("new.txt").added, find("new.txt").removed), (1, 1)); + + let only = repo.diff_numstat(&root, &c, &["mod.txt".into()]).unwrap(); + assert_eq!(only.iter().map(|f| f.path.as_str()).collect::>(), vec!["mod.txt"]); + // The log passes both names, which is what lets git still report the rename. + let renamed = repo.diff_numstat(&root, &c, &["old.txt".into(), "new.txt".into()]).unwrap(); + assert_eq!(renamed.len(), 1, "{renamed:#?}"); + assert_eq!(renamed[0].old_path.as_deref(), Some("old.txt")); + assert_eq!((renamed[0].added, renamed[0].removed), (1, 1)); + + let created = repo.diff_numstat("4b825dc642cb6eb9a060e54bf8d69288fbee4904", &root, &["mod.txt".into()]).unwrap(); + assert_eq!((created[0].added, created[0].removed), (1, 0)); +} + /// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔ /// after the last one, contiguous or not. #[test] @@ -1651,3 +1687,173 @@ fn restricted_conflict_preview_uses_host_owned_temporary_files() { assert!(versions.merged.unwrap().contains("<<<<<<<")); assert_eq!(std::fs::read_to_string(marker).unwrap(), "untouched"); } + +#[test] +fn commit_leaves_excluded_staged_changes_staged() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.write("old.txt", "renamed\n"); + fx.commit_all("base"); + let repo = fx.repo(); + + // a.txt: staged and edited again; b.txt: staged; new.txt: staged addition; old.txt: staged rename. + fx.write("a.txt", "a staged\n"); + fx.write("b.txt", "b staged\n"); + fx.write("new.txt", "new\n"); + fx.git(&["add", "a.txt", "b.txt", "new.txt"]); + fx.git(&["mv", "old.txt", "moved.txt"]); + fx.write("a.txt", "a on disk\n"); + + // Commit a.txt as it is on disk; leave out b.txt, new.txt and the rename. + let options = CommitOptions { + stage: vec![fx.path("a.txt").into()], + exclude: vec![ + fx.path("b.txt").into(), + fx.path("new.txt").into(), + fx.path("moved.txt").into(), + fx.path("old.txt").into(), + ], + ..Default::default() + }; + repo.commit_with("only a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + + assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt"); + assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "a on disk"); + // What was left out is still staged, exactly as before. + let mut staged: Vec = + fx.git(&["diff", "--cached", "--name-status", "-M"]).lines().map(str::to_string).collect(); + staged.sort(); + assert_eq!(staged, ["A\tnew.txt", "M\tb.txt", "R100\told.txt\tmoved.txt"]); + assert_eq!(fx.git(&["show", ":b.txt"]), "b staged"); + assert_eq!(fx.git(&["diff", "--name-only"]), "", "nothing unstaged is left behind"); + assert!(!fx.dir.join(".git/openidea-partial-index").exists()); +} + +#[test] +fn commit_of_staged_version_keeps_the_unstaged_rest() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.write("b.txt", "1\n"); + fx.commit_all("base"); + let repo = fx.repo(); + fx.write("a.txt", "2\n"); + fx.write("b.txt", "2\n"); + fx.git(&["add", "a.txt", "b.txt"]); + fx.write("a.txt", "3\n"); + + // a.txt committed as staged (its unstaged edit stays), b.txt left out. + let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() }; + repo.commit_with("staged a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + + assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "2"); + assert_eq!(fx.git(&["show", "HEAD:b.txt"]), "1"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "b.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt"); + assert_eq!(fx.read("a.txt"), "3\n"); +} + +#[test] +fn commit_with_exclusions_in_a_repository_without_commits() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.git(&["add", "-A"]); + let repo = fx.repo(); + let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() }; + repo.commit_with("first", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt"); + assert_eq!(fx.git(&["diff", "--cached", "--name-status"]), "A\tb.txt"); +} + +#[test] +fn smart_checkout_keeps_staged_changes_staged() { + let fx = Fixture::new(); + fx.write("f.txt", "1\n2\n3\n4\n5\n"); + fx.write("s.txt", "s\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "other"]); + fx.write("f.txt", "1\nother\n3\n4\n5\n"); + fx.commit_all("other edit"); + fx.git(&["checkout", "-q", "main"]); + let repo = fx.repo(); + + fx.write("f.txt", "1\n2\n3\n4\nlocal\n"); + fx.write("s.txt", "staged\n"); + fx.git(&["add", "s.txt"]); + let opts = CheckoutOptions { mode: CheckoutMode::Smart, diverged: None }; + assert_eq!(repo.checkout_with("other", false, &opts).unwrap(), CheckoutOutcome::Done); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other")); + assert_eq!(fx.read("f.txt"), "1\nother\n3\n4\nlocal\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "s.txt", "staged change stays staged"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f.txt"); + assert!(repo.stashes().unwrap().is_empty()); +} + +#[test] +fn checkout_reports_untracked_files_it_would_overwrite() { + let fx = Fixture::new(); + fx.write("f.txt", "f\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "other"]); + fx.write("dir/new.txt", "theirs\n"); + fx.commit_all("add new"); + fx.git(&["checkout", "-q", "main"]); + fx.write("dir/new.txt", "mine\n"); + let repo = fx.repo(); + for mode in [CheckoutMode::Normal, CheckoutMode::Smart] { + let opts = CheckoutOptions { mode, diverged: None }; + let outcome = repo.checkout_with("other", false, &opts).unwrap(); + assert_eq!(outcome, CheckoutOutcome::UntrackedFiles { files: vec!["dir/new.txt".into()] }); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main")); + assert_eq!(fx.read("dir/new.txt"), "mine\n"); + } +} + +#[test] +fn reword_keeps_local_changes_and_can_be_undone() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + let first = fx.commit_all("first"); + fx.write("a.txt", "2\n"); + let second = fx.commit_all("second"); + fx.write("b.txt", "b\n"); + fx.commit_all("third"); + // Local changes, staged and not, must survive untouched. + fx.write("a.txt", "local\n"); + fx.write("staged.txt", "s\n"); + fx.git(&["add", "staged.txt"]); + let repo = fx.repo(); + + let change = repo.reword(&second, "second, reworded\n\nwith a body").unwrap(); + assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond, reworded\nfirst"); + assert_eq!(fx.git(&["log", "-1", "--format=%B", "HEAD~1"]), "second, reworded\n\nwith a body"); + assert_eq!(fx.git(&["rev-parse", "HEAD~2"]), first, "commits before it keep their ids"); + assert_eq!(fx.git(&["rev-parse", "HEAD"]), change.after); + assert_eq!(fx.git(&["symbolic-ref", "--short", "HEAD"]), "main", "the branch moved, not a detached HEAD"); + assert_eq!(fx.read("a.txt"), "local\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt"); + + repo.restore_head(&change.before, &change.after).unwrap(); + assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond\nfirst"); + assert_eq!(fx.read("a.txt"), "local\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt"); + + // Undo is refused once HEAD has moved. + let change = repo.reword("HEAD", "third, reworded").unwrap(); + fx.git(&["commit", "-q", "--allow-empty", "-m", "later"]); + assert!(repo.restore_head(&change.before, &change.after).is_err()); +} + +#[test] +fn reword_refuses_commits_outside_the_current_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("first"); + fx.git(&["checkout", "-q", "-b", "side"]); + fx.write("a.txt", "side\n"); + let side = fx.commit_all("side"); + fx.git(&["checkout", "-q", "main"]); + assert!(fx.repo().reword(&side, "other").is_err()); +} diff --git a/crates/ide-vfs/src/encoding.rs b/crates/ide-vfs/src/encoding.rs index 92e0d999b..a34159d00 100644 --- a/crates/ide-vfs/src/encoding.rs +++ b/crates/ide-vfs/src/encoding.rs @@ -301,6 +301,21 @@ pub(crate) fn is_atomic_temp(path: &Path) -> bool { path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') && n.contains(TMP_MARKER)) } +/// `sync_all` failures that mean "this filesystem cannot fsync at all", not that +/// the data was lost: ENOTTY (`os error 25`, virtio-fs on macOS VMs answering +/// F_FULLFSYNC/fsync with "Inappropriate ioctl for device") and EINVAL (some +/// network filesystems). Other errors keep failing the write. +fn sync_error_is_tolerable(e: &std::io::Error) -> bool { + if e.kind() == std::io::ErrorKind::InvalidInput { + return true; + } + // EINVAL is already covered by InvalidInput; ENOTTY has no ErrorKind of its own. + #[cfg(unix)] + return e.raw_os_error() == Some(25); + #[cfg(not(unix))] + false +} + /// Writes `bytes` to `path` atomically: temp file in the same directory, fsync, /// rename over the target. Preserves the permissions of an existing file and /// writes through symlinks. Falls back to an in-place write when the directory @@ -347,7 +362,17 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResu }; let result = (|| -> std::io::Result<()> { file.write_all(bytes)?; - file.sync_all()?; + if let Err(e) = file.sync_all() { + // virtio-fs (macOS VMs) and similar filesystems have no fsync/F_FULLFSYNC + // (ENOTTY, EINVAL): the write itself already went through, so the rename is + // still the best durability the filesystem offers — degrade like SQLite + // instead of failing the whole atomic write. + if sync_error_is_tolerable(&e) { + tracing::debug!("fsync unsupported for {}: {e}; continuing", target.display()); + } else { + return Err(e); + } + } if let Some(meta) = &existing { fs::set_permissions(&tmp, meta.permissions())?; } @@ -526,4 +551,19 @@ mod tests { assert_eq!(encode("x", "nope", false).unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput); assert_eq!(encode("中", "windows-1252", false).unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput); } + + #[test] + fn sync_errors_without_fsync_support_are_tolerable() { + // Some network filesystems report EINVAL for fsync. + assert!(sync_error_is_tolerable(&std::io::Error::new(std::io::ErrorKind::InvalidInput, "EINVAL"))); + #[cfg(unix)] + { + // virtio-fs on macOS VMs answers fsync with "Inappropriate ioctl for device". + assert!(sync_error_is_tolerable(&std::io::Error::from_raw_os_error(25))); + assert!(sync_error_is_tolerable(&std::io::Error::from_raw_os_error(22))); + } + // Real write failures still fail the atomic write. + assert!(!sync_error_is_tolerable(&std::io::Error::from_raw_os_error(28))); + assert!(!sync_error_is_tolerable(&std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"))); + } } diff --git a/crates/ide-vfs/src/rules.rs b/crates/ide-vfs/src/rules.rs index cad487a4c..e6c7b66ed 100644 --- a/crates/ide-vfs/src/rules.rs +++ b/crates/ide-vfs/src/rules.rs @@ -81,6 +81,14 @@ fn exclude_path(root: &Path) -> PathBuf { .unwrap_or_else(|| root.join(".git/info/exclude")) } +#[derive(Default)] +struct NestedRepos { + /// Directory -> enclosing repository root when different from `GitignoreStack::repo_root`. + by_dir: HashMap>, + /// Repository root -> shared `GitignoreStack` for that nested repository. + by_root: HashMap>, +} + /// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the /// path's directory up to the repository root (or the file system root when /// not in a repository), then `.git/info/exclude` and the global excludes file. @@ -89,7 +97,7 @@ pub(crate) struct GitignoreStack { /// `.git/info/exclude` and the global excludes file, rooted at the repo root. repo_level: RwLock>, cache: Mutex>>>, - nested: Mutex>>>, + nested: Mutex, } /// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory. @@ -191,13 +199,23 @@ impl GitignoreStack { if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) { map.retain(|dir, _| !dir.starts_with(root)); } - self.nested.lock().clear(); + self.nested.lock().by_root.values().for_each(|s| s.reload_repo_level()); *self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref()); } /// Forgets the cached rules of `dir` and every directory below it. pub fn invalidate_tree(&self, dir: &Path) { - self.nested.lock().clear(); + { + let mut nested = self.nested.lock(); + let target = if dir.file_name().is_some_and(|n| n == ".git") { dir.parent().unwrap_or(dir) } else { dir }; + if nested.by_dir.contains_key(target) || nested.by_root.contains_key(target) { + nested.by_dir.retain(|k, _| !k.starts_with(target)); + nested.by_root.retain(|k, _| !k.starts_with(target)); + } + for stack in nested.by_root.values() { + stack.invalidate_tree(dir); + } + } let mut cache = self.cache.lock(); // Lookups always populate every ancestor, so no key below `dir` exists without `dir`. if cache.contains_key(dir) { @@ -211,10 +229,57 @@ impl GitignoreStack { /// Forgets the cached rules of `dir` (after its `.gitignore` changed). pub fn invalidate(&self, dir: &Path) { - self.nested.lock().clear(); + for stack in self.nested.lock().by_root.values() { + stack.invalidate(dir); + } self.cache.lock().remove(dir); } + /// Returns the shared `GitignoreStack` of the nested repository enclosing `dir`, if any. + fn nested_for(&self, dir: &Path) -> Option> { + let mut nested = self.nested.lock(); + let root = if let Some(cached) = nested.by_dir.get(dir) { + cached.clone() + } else { + let mut chain = Vec::new(); + let mut found = None; + for anc in dir.ancestors() { + if let Some(cached) = nested.by_dir.get(anc) { + found = cached.clone(); + break; + } + chain.push(anc.to_path_buf()); + if self.repo_root.as_deref() == Some(anc) { + found = None; + break; + } + if anc.join(".git").exists() { + found = Some(anc.to_path_buf()); + break; + } + } + for p in chain { + nested.by_dir.insert(p, found.clone()); + } + found + }?; + Some( + nested + .by_root + .entry(root.clone()) + .or_insert_with(|| { + let repo_level = RwLock::new(Self::load_repo_level(Some(&root))); + Arc::new(Self { + repo_root: Some(root), + repo_level, + cache: Mutex::default(), + nested: Mutex::default(), + }) + }) + .clone(), + ) + } + fn matcher_for(&self, dir: &Path) -> Option> { if let Some(cached) = self.cache.lock().get(dir) { return cached.clone(); @@ -250,20 +315,10 @@ impl GitignoreStack { pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool { // A project's watcher can span independent nested repositories. Their local // excludes and ignore ancestry must stop at their own repository boundary. - if let Some(parent) = path.parent() { - let nested = self - .nested - .lock() - .entry(parent.to_path_buf()) - .or_insert_with(|| { - find_repo_root(parent) - .filter(|root| self.repo_root.as_ref() != Some(root)) - .map(|_| Arc::new(Self::new(parent))) - }) - .clone(); - if let Some(nested) = nested { - return nested.is_ignored(path, is_dir); - } + if let Some(parent) = path.parent() + && let Some(nested) = self.nested_for(parent) + { + return nested.is_ignored(path, is_dir); } if self.repo_root.as_deref() == Some(path) { return false; @@ -302,20 +357,10 @@ impl GitignoreStack { /// ignored) still emit its own create/remove so the tree can show it /// appearing marked as ignored, while files inside it stay filtered out. pub fn is_ignored_self(&self, path: &Path, is_dir: bool) -> bool { - if let Some(parent) = path.parent() { - let nested = self - .nested - .lock() - .entry(parent.to_path_buf()) - .or_insert_with(|| { - find_repo_root(parent) - .filter(|root| self.repo_root.as_ref() != Some(root)) - .map(|_| Arc::new(Self::new(parent))) - }) - .clone(); - if let Some(nested) = nested { - return nested.is_ignored_self(path, is_dir); - } + if let Some(parent) = path.parent() + && let Some(nested) = self.nested_for(parent) + { + return nested.is_ignored_self(path, is_dir); } if self.repo_root.as_deref() == Some(path) { return false; @@ -405,4 +450,49 @@ mod tests { assert_eq!(first.repo_root(), again.repo_root()); assert_eq!(again.repo_root(), Some(tmp.path())); } + + #[test] + fn nested_repository_shares_one_gitignore_stack_across_subdirectories() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path(); + let repo = workspace.join("yolo"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(repo.join("a/b/c")).unwrap(); + std::fs::create_dir_all(repo.join("x/y/z")).unwrap(); + std::fs::write(repo.join(".gitignore"), "*.ignored\n").unwrap(); + + let stack = GitignoreStack::new(workspace); + assert!(stack.is_ignored(&repo.join("a/b/c/file.ignored"), false)); + assert!(stack.is_ignored(&repo.join("x/y/z/other.ignored"), false)); + assert!(!stack.is_ignored(&repo.join("x/y/z/keep.rs"), false)); + + assert_eq!(stack.nested.lock().by_root.len(), 1); + let nested = stack.nested.lock().by_root.get(&repo).unwrap().clone(); + assert!(nested.cache.lock().contains_key(&repo)); + + // Invalidating a leaf file or directory inside the nested repo keeps its shared stack. + stack.invalidate_tree(&repo.join("a/b/c/new.rs")); + assert_eq!(stack.nested.lock().by_root.len(), 1); + assert!(Arc::ptr_eq(&nested, stack.nested.lock().by_root.get(&repo).unwrap())); + } + + #[test] + fn a_repository_created_inside_the_workspace_becomes_a_boundary() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path(); + std::fs::create_dir_all(workspace.join(".git")).unwrap(); + std::fs::create_dir_all(workspace.join("sub/deep")).unwrap(); + std::fs::write(workspace.join(".gitignore"), "*.tmp\n").unwrap(); + + let stack = GitignoreStack::new(workspace); + assert!(stack.is_ignored(&workspace.join("sub/deep/a.tmp"), false)); + + std::fs::create_dir(workspace.join("sub/.git")).unwrap(); + stack.invalidate_tree(&workspace.join("sub/.git")); + assert!(!stack.is_ignored(&workspace.join("sub/deep/a.tmp"), false)); + + std::fs::remove_dir(workspace.join("sub/.git")).unwrap(); + stack.invalidate_tree(&workspace.join("sub/.git")); + assert!(stack.is_ignored(&workspace.join("sub/deep/a.tmp"), false)); + } } diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 253d7a826..d00d8778a 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -13,7 +13,7 @@ use crate::FsBatch; use crate::rules::{ExcludeMatcher, GitignoreStack}; use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink}; use notify::event::{ModifyKind, RenameMode}; -use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind}; +use notify::{ErrorKind, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind}; use parking_lot::{Condvar, Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Component, Path, PathBuf}; @@ -27,6 +27,9 @@ const DEBOUNCE: Duration = Duration::from_millis(100); const MAX_DELAY: Duration = Duration::from_millis(1000); /// How often the polling fallback rescans the watched directories. const POLL_INTERVAL: Duration = Duration::from_secs(2); +/// Directories watched synchronously in [`Watcher::start`] before handing the +/// remainder of a large tree to the watcher thread. +const SYNC_WATCH_BUDGET: usize = 256; /// How directories are registered with the OS. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -176,13 +179,14 @@ impl Watcher { }, config, ) - .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; + .map_err(|e| watcher_error("cannot start file watcher", e))?; *state.watcher.lock() = Some(Box::new(watcher)); - if mode == WatchMode::PerDirectory { - state.watch_tree(root, false, &mut Vec::new()); + let deferred = if mode == WatchMode::PerDirectory { + state.watch_tree_bounded(root, SYNC_WATCH_BUDGET) } else { state.add_watch(root); - } + Vec::new() + }; if state.watched.lock().is_empty() { return Err(io_error(format!("cannot watch {}", root.display()))); } @@ -190,7 +194,7 @@ impl Watcher { let state = state.clone(); std::thread::Builder::new() .name("openidea-fs-watch".into()) - .spawn(move || state.run(&*emit)) + .spawn(move || state.run(deferred, &*emit)) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))? }; Ok(Inner { state, thread: Some(thread) }) @@ -223,6 +227,29 @@ fn is_dir_touch(event: ¬ify::Event) -> bool { matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir()) } +const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify.max_user_watches); raise it, e.g. \ + `sudo sysctl -w fs.inotify.max_user_watches=524288`"; + +/// `io_error` with a fix hint when Linux's inotify limits are exhausted: one +/// instance per watcher (`EMFILE`, os error 24) or one watch per directory +/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS +/// error sends users off to search engines otherwise (issue #788). +fn watcher_error(what: &str, error: notify::Error) -> IdeError { + let hint = match error.kind { + ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT), + ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some( + "the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \ + `sudo sysctl -w fs.inotify.max_user_instances=1024`", + ), + ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT), + _ => None, + }; + match hint { + Some(hint) => io_error(format!("{what}: {error}; {hint}")), + None => io_error(format!("{what}: {error}")), + } +} + /// Shared with the notify callback (weakly) and the debounce thread. struct DirState { filter: EventFilter, @@ -239,8 +266,12 @@ impl DirState { self.stop.load(Ordering::SeqCst) } - /// Debounce loop: waits for a quiet period, then processes the batch. - fn run(&self, emit: impl Fn(&FsBatch)) { + /// Debounce loop: finishes any deferred startup watches, then waits for a + /// quiet period and processes each batch. + fn run(&self, deferred: Vec<(PathBuf, bool)>, emit: impl Fn(&FsBatch)) { + if !deferred.is_empty() { + self.watch_stack(deferred, false, &mut Vec::new(), None); + } loop { let events = { let mut q = self.queue.lock(); @@ -298,6 +329,7 @@ impl DirState { // during this batch would otherwise never be seen. for path in &batch.vcs { if !self.watched.lock().contains(path) && path.symlink_metadata().is_ok_and(|m| m.is_dir()) { + self.filter.gitignore.invalidate_tree(path); self.watch_tree(path, false, &mut discovered); } } @@ -349,11 +381,33 @@ impl DirState { /// files and directories found inside (created before the watch was in /// place) are appended to `found`. fn watch_tree(&self, dir: &Path, report: bool, found: &mut Vec) { - let mut stack = vec![dir.to_path_buf()]; - while let Some(dir) = stack.pop() { - if self.stopped() || !self.filter.should_watch_dir(&dir) || !self.add_watch(&dir) { + self.watch_stack(vec![(dir.to_path_buf(), false)], report, found, None); + } + + /// Watches up to `budget` directories synchronously and returns any + /// remaining `(dir, parent_verified)` entries for background registration. + fn watch_tree_bounded(&self, dir: &Path, budget: usize) -> Vec<(PathBuf, bool)> { + self.watch_stack(vec![(dir.to_path_buf(), false)], false, &mut Vec::new(), Some(budget)) + } + + fn watch_stack( + &self, + mut stack: Vec<(PathBuf, bool)>, + report: bool, + found: &mut Vec, + budget: Option, + ) -> Vec<(PathBuf, bool)> { + let mut watched_now = 0; + while let Some((dir, parent_ok)) = stack.pop() { + if self.stopped() { + break; + } + let should_watch = + if parent_ok { self.filter.should_watch_child_dir(&dir) } else { self.filter.should_watch_dir(&dir) }; + if !should_watch || !self.add_watch(&dir) { continue; } + watched_now += 1; let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for entry in entries.flatten() { let path = entry.path(); @@ -362,10 +416,14 @@ impl DirState { found.push(path.clone()); } if is_dir { - stack.push(path); + stack.push((path, true)); } } + if budget.is_some_and(|max| watched_now >= max) { + break; + } } + stack } fn unwatch_tree(&self, dir: &Path) { @@ -469,6 +527,15 @@ impl EventFilter { /// and `worktrees/` (HEAD, index, refs and worktree metadata are what VCS /// refresh needs). pub fn should_watch_dir(&self, dir: &Path) -> bool { + self.should_watch_dir_inner(dir, false) + } + + /// Fast path when `dir`'s parent directory is already known to be watchable. + fn should_watch_child_dir(&self, dir: &Path) -> bool { + self.should_watch_dir_inner(dir, true) + } + + fn should_watch_dir_inner(&self, dir: &Path, parent_ok: bool) -> bool { let Ok(rel) = dir.strip_prefix(&self.root) else { return false }; if rel.as_os_str().is_empty() { return true; @@ -484,7 +551,11 @@ impl EventFilter { _ => false, }; } - !self.excludes.read().is_excluded(rel) && !self.gitignore.is_ignored(dir, true) + if parent_ok { + !self.excludes.read().is_match(rel) && !self.gitignore.is_ignored_self(dir, true) + } else { + !self.excludes.read().is_excluded(rel) && !self.gitignore.is_ignored(dir, true) + } } /// Whether a path found by scanning a new directory would be reported. @@ -1075,6 +1146,42 @@ mod tests { assert_eq!(batches.lock().len(), n, "no events after drop"); } + #[test] + fn per_directory_mode_watches_the_rest_of_a_large_tree_after_start() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let dirs = SYNC_WATCH_BUDGET + 40; + for i in 0..dirs { + std::fs::create_dir_all(root.join(format!("d{i:03}"))).unwrap(); + } + std::fs::create_dir_all(root.join("node_modules/x")).unwrap(); + let batches: Arc>> = Arc::default(); + let sink: SharedSink = Arc::new(|_e: IdeEvent| {}); + let watcher = { + let batches = batches.clone(); + Watcher::start_with( + &root, + vec!["**/node_modules".into()], + sink, + Box::new(move |b| batches.lock().push(b.clone())), + WatchMode::PerDirectory, + ) + .unwrap() + }; + let deadline = Instant::now() + Duration::from_secs(10); + while watcher.watch_count() < dirs + 1 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + // root and every d*, but not node_modules + assert_eq!(watcher.watch_count(), dirs + 1); + std::thread::sleep(Duration::from_millis(200)); + for i in [0, dirs - 1] { + let file = root.join(format!("d{i:03}/f.rs")); + std::fs::write(&file, "x").unwrap(); + assert!(wait_for(&batches, |b| all_paths(b).contains(&file)), "{file:?}"); + } + } + #[test] fn per_directory_mode_follows_worktree_registrations() { let tmp = tempfile::tempdir().unwrap(); @@ -1189,4 +1296,28 @@ mod tests { std::fs::write(root.join("src/new/inner/h.rs"), "x").unwrap(); assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("src/new/inner/h.rs")))); } + + #[test] + fn ordinary_watcher_errors_keep_the_bare_message() { + let other = notify::Error::io(std::io::Error::other("no space left")); + assert_eq!( + watcher_error("cannot start file watcher", other).message, + "cannot start file watcher: no space left" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn inotify_limits_get_a_sysctl_hint() { + let emfile = notify::Error::io(std::io::Error::from_raw_os_error(24)); + let msg = watcher_error("cannot start file watcher", emfile).message; + assert!(msg.contains("os error 24"), "{msg}"); + assert!(msg.contains("max_user_instances"), "{msg}"); + let enospc = notify::Error::io(std::io::Error::from_raw_os_error(28)); + assert!(watcher_error("cannot watch /proj", enospc).message.contains("max_user_watches")); + let max_files = notify::Error::new(ErrorKind::MaxFilesWatch); + assert!(watcher_error("cannot watch /proj", max_files).message.contains("max_user_watches")); + let other = notify::Error::io(std::io::Error::from_raw_os_error(13)); + assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify")); + } } diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 7bb741991..577b626da 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -537,9 +537,15 @@ impl Documents { pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult { self.with_doc(doc, |st| { if st.read_only { + tracing::warn!("edit rejected: {} is read-only", st.path.display()); return Err(read_only_error(&st.path)); } - let (applied, edits) = st.doc.apply_detailed(base_version, &changes)?; + let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| { + // A stale version is routine (the UI rebases); anything else makes the editor drop the edit. + if e.kind != ErrorKind::OutOfSync { + tracing::warn!("edit of {} rejected: {e}", st.path.display()); + } + })?; self.set_dirty(doc, st, true); self.notify_changed(doc, st, &applied, &edits); Ok(st.doc.version()) @@ -735,6 +741,11 @@ impl Documents { self.with_doc(doc, |st| Ok(st.dirty)) } + /// Path and version for navigation queries, without copying document text. + pub fn identity(&self, doc: DocId) -> IdeResult<(PathBuf, u32)> { + self.with_doc(doc, |st| Ok((st.path.clone(), st.doc.version()))) + } + /// Current generation without copying the document text. pub fn version(&self, doc: DocId) -> IdeResult { self.with_doc(doc, |st| Ok(st.doc.version())) @@ -862,17 +873,18 @@ impl Documents { .then(|| st.path.clone()) }) .collect(); - self.reload_from_disk(&paths, force_clean); + self.reload_from_disk(&paths, force_clean, true); } /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. pub fn on_disk_changed(&self, paths: &[PathBuf]) { - self.reload_from_disk(paths, false); + self.reload_from_disk(paths, false, false); } - fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) { + /// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches. + fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { @@ -881,7 +893,23 @@ impl Documents { } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); - if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) { + let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len; + // Blind scans trust the stamp. OS events are authoritative, but they also + // echo our own saves: a clean document whose stamp matches is re-checked by + // bytes — an external write that preserved both length and mtime differs + // from what saving this buffer would write, our own save's echo does not. + // Native-to-ascii properties documents and dirty documents keep the stamp + // dedup: the former's disk bytes legitimately differ from the buffer text, + // the latter warn once per disk state. + if unchanged + && (!force_clean || st.dirty) + && (dedup + || st.dirty + || st.native_to_ascii + || ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom) + .map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default()) + .unwrap_or(true)) + { return Ok(()); } if st.dirty { diff --git a/crates/ide-workspace/src/roots/detect.rs b/crates/ide-workspace/src/roots/detect.rs index 1e243bc26..61f18b9c6 100644 --- a/crates/ide-workspace/src/roots/detect.rs +++ b/crates/ide-workspace/src/roots/detect.rs @@ -32,6 +32,7 @@ const BUILD_FILES: &[&str] = &[ "Pipfile", "requirements.txt", "package.json", + "composer.json", ]; /// Folder names that are (or contain) conventional roots. @@ -214,6 +215,15 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec Vec<(String, RootKind)> { + let Ok(json) = serde_json::from_str::(text) else { return Vec::new() }; + let mut out = Vec::new(); + for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] { + let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue }; + for value in map.values() { + let paths: Vec<&str> = match value { + serde_json::Value::String(p) => vec![p.as_str()], + serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(), + _ => Vec::new(), + }; + for path in paths { + let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect(); + if path.starts_with(['/', '\\']) + || parts.iter().any(|c| *c == ".." || c.contains(':')) + || parts.first() == Some(&"vendor") + { + continue; + } + let sub = parts.join("/"); + if !out.iter().any(|(p, _)| *p == sub) { + out.push((sub, kind)); + } + } + } + } + out +} + /// Gradle's conventional `src//` folders that exist: `(set, lang)`. fn source_sets(dir: &Path) -> Vec<(String, String)> { let mut out = Vec::new(); @@ -307,6 +348,47 @@ mod tests { assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source)); } + #[test] + fn composer_psr4_folders_are_source_and_test_roots() { + let dir = tree(&[ + "app/", + "database/factories/", + "database/seeders/", + "lib/", + "tests/", + "vendor/acme/src/", + "package.json", + ]); + fs::write( + dir.path().join("composer.json"), + r#"{ + "autoload": { "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": ["database/seeders/", "lib", "missing/"], + "Vendored\\": "vendor/acme/src/", + "Outside\\": "../elsewhere/" + } }, + "autoload-dev": { "psr-4": { "Tests\\": "tests/" } } + }"#, + ) + .unwrap(); + let mut roots = summary(&detect(dir.path())); + roots.sort(); + assert_eq!( + roots, + [ + "app=Source@composer.json", + "database/factories=Source@composer.json", + "database/seeders=Source@composer.json", + "lib=Source@composer.json", + "tests=Test@composer.json", + ] + ); + assert!(affects_roots(Path::new("composer.json"))); + assert!(composer_roots("not json").is_empty()); + } + #[test] fn maven_conventional_roots_and_target() { let dir = tree(&[ diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 3b278a0bc..8327f134f 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -479,6 +479,10 @@ mod tests { let text = std::fs::read_to_string(added.join("settings.toml")).unwrap(); assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n"); + let s = + store.set(SettingsScope::Global, "languages.ruff.configurationDirectory", json!("shared/ruff")).unwrap(); + assert_eq!(s.languages.ruff.configuration_directory, "shared/ruff"); + // Project layer is created on demand and wins. let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap(); assert_eq!(s.editor.font_size, 12.0); diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index 5e948a790..da095146b 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -1,6 +1,7 @@ //! Window grouping is independent of a window's project or workspace. use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; /// Platform adapters supply the system's tab preference and active project state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -117,6 +118,43 @@ impl WindowGroups { } } +/// One entry of the window session reopened at the next start (`window-groups.json`): a +/// window group, or a single project window (one project). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedWindows { + pub projects: Vec, + /// Index into `projects` of the group's selected tab. + pub selected: usize, +} + +/// The project windows open now, to reopen at the next start: every group with its selected +/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a +/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped +/// window has no project yet (a restored member still opening): keep the previous session. +pub fn session( + groups: &[WindowGroup], + windows: &[String], + project_of: impl Fn(&str) -> Option, +) -> Option> { + let mut saved = Vec::new(); + for group in groups { + let projects: Option> = group.members.iter().map(|label| project_of(label)).collect(); + let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0); + saved.push(SavedWindows { projects: projects?, selected }); + } + for label in windows { + if groups.iter().any(|g| g.members.contains(label)) { + continue; + } + if let Some(project) = project_of(label) + && !saved.iter().any(|s| s.projects.contains(&project)) + { + saved.push(SavedWindows { projects: vec![project], selected: 0 }); + } + } + Some(saved) +} + #[cfg(test)] mod tests { use super::*; @@ -186,4 +224,28 @@ mod tests { groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap(); assert_eq!(groups.group("a").unwrap().members[0], "d"); } + + #[test] + fn the_session_keeps_groups_and_every_ungrouped_project_window() { + let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }]; + let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]); + let project_of = |label: &str| match label { + "main" => Some(PathBuf::from("/a")), + "project-1" => Some(PathBuf::from("/b")), + "project-2" => Some(PathBuf::from("/c")), + "project-3" => Some(PathBuf::from("/d")), + // The welcome screen or LightEdit. + _ => None, + }; + assert_eq!( + session(&groups, &windows, project_of).unwrap(), + [ + SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 }, + SavedWindows { projects: vec!["/a".into()], selected: 0 }, + SavedWindows { projects: vec!["/d".into()], selected: 0 }, + ] + ); + // A grouped window still opening its project: keep the previous session. + assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None); + } } diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 52c20e959..794af1cf6 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -403,6 +403,46 @@ fn external_changes() { f.docs.on_disk_changed(&[f.root.join("other.txt")]); } +/// An IDE operation that rewrites a file with the same length and mtime still refreshes +/// the clean buffer. +#[test] +fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() { + let f = fixture(); + let path = f.root.join("stamp.txt"); + fs::write(&path, "first").unwrap(); + let id = f.docs.open(&path).unwrap().id; + let mtime = fs::metadata(&path).unwrap().modified().unwrap(); + fs::write(&path, "other").unwrap(); + fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap(); + f.docs.sync_after_disk_change(); + assert_eq!(f.docs.snapshot(id).unwrap().text, "other"); +} + +/// An external write that keeps the byte length and the mtime (timestamp-preserving +/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal: +/// the OS event is still authoritative, so a clean document reloads. +#[test] +fn external_change_preserving_length_and_mtime_reloads_a_clean_document() { + let f = fixture(); + let path = f.root.join("stamp.txt"); + fs::write(&path, "first").unwrap(); // 5 bytes + let id = f.docs.open(&path).unwrap().id; + let mtime = fs::metadata(&path).unwrap().modified().unwrap(); + + fs::write(&path, "other").unwrap(); // 5 bytes, different content + fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + let snap = f.docs.snapshot(id).unwrap(); + assert_eq!(snap.text, "other"); + assert!(!snap.dirty); + + // The reload recorded the new stamp: repeating the same event changes nothing. + let before = f.events.lock().len(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.events.lock().len(), before); + assert_eq!(f.docs.snapshot(id).unwrap().text, "other"); +} + #[test] fn encodings_and_read_only() { let f = fixture(); diff --git a/docs/databases.md b/docs/databases.md new file mode 100644 index 000000000..fb0c9258d --- /dev/null +++ b/docs/databases.md @@ -0,0 +1,64 @@ +# Databases + +The Database tool window (View ▸ Tool Windows ▸ Database) connects to SQLite, +PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, ClickHouse and Oracle, plus the +wire-compatible products listed in **New ▸ Data Source**. Connections are saved +per project in `.openidea/data-sources.toml`; passwords live outside the project +(and never in that file). + +## Oracle: Oracle Instant Client + +Oracle support uses the Oracle Call Interface, so the Oracle client libraries +must be installed on the machine. OpenIDEA does not bundle them: install the +**Oracle Instant Client** for your platform and point the data source at it. + +1. Download the _Basic_ (or _Basic Light_) package of Oracle Instant Client + 19c or later for your OS from + . +2. Unpack it somewhere stable, then, in the data source's **Options** tab, set + **Instant Client** to that directory. +3. General tab: host, port (1521), and the **service name** (for example + `ORCLPDB1`) in the Database field. Connect with the schema's user and its + password. + +`java.sql`-style URLs also work in the URL field: + +``` +oracle://scott:tiger@db.example.com:1521/ORCLPDB1 +jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1 +``` + +A TNS descriptor or alias in the Database field is passed to the client as is. + +### Platform notes + +- **macOS** — the _Basic_ ZIP for ARM (Apple silicon) or x86-64 must match the + app's architecture. Remove the quarantine flag if macOS blocks the libraries: + `xattr -dr com.apple.quarantine /opt/oracle/instantclient_23_5`. +- **Windows** — use the 64-bit package; the app is 64-bit. `PATH` does not have + to include the directory when the Options tab names it. +- **Linux** — install the RPM/DEB or unzip the ZIP; the directory needs the + `libclntsh.so` and `libclntshcore.so` libraries (the ZIP's `libaio`-based + builds also need the `libaio` package). + +Without a working client, Test Connection fails with a clear message +(`DPI-1047: Cannot locate a 64-bit Oracle Client library…`). If the Options tab +is empty and `ORACLE_HOME` is not set, the client is looked up on the system +search path. + +### What Oracle data sources support + +- Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`, + `XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**. +- Tables, views, columns, indexes, foreign keys, triggers, routines and + sequences are read from the `ALL_*` views. +- The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested + `;` included) are sent as one statement, and a lone `/` on its own line ends + one as in SQL*Plus. +- Explain Plan runs `EXPLAIN PLAN` and reads `PLAN_TABLE` (`DBMS_XPLAN` output + is not required). +- Table editor (INSERT/UPDATE/DELETE in one transaction), Import/Export and + generated SQL use Oracle syntax: `"UPPER_CASE"` identifiers, `FETCH FIRST n + ROWS ONLY`, `MODIFY (…)` for column changes. + +Table editing needs a primary key, and the connection must not be read-only. diff --git a/docs/java.md b/docs/java.md new file mode 100644 index 000000000..182788960 --- /dev/null +++ b/docs/java.md @@ -0,0 +1,21 @@ +# Java language servers + +Settings › Languages & Frameworks › Language Servers lists Eclipse JDT Language +Server (`jdtls`) and [kmp-lsp](https://github.com/Hessesian/kmp-lsp). Select either +tool and use **Language server for Java** to choose one. The choice can be global +or limited to the current project. **Automatic** prefers jdtls and falls back to +kmp-lsp when jdtls is not installed; **None** disables both. + +Install kmp-lsp from that page, or point its executable path at an existing +installation. The IDE downloads a platform distribution containing both kmp-lsp +and its native JAR indexer. Reinstall and uninstall manage both together. + +kmp-lsp offers syntax-based Java code insight without running a JVM. It does not +provide JDT's compiler analysis, Lombok integration, or JDT-specific refactorings. +Java Application builds, classpath discovery and the Microsoft Java debugger still +require jdtls; use jdtls for those flows. Maven and Gradle build tasks can run +independently of the selected code-insight server. + +This built-in choice applies to Java. Kotlin and Swift keep their existing server +choices. To use another server, add it with **New Language Server**, assign its +languages, and disable the servers it replaces. diff --git a/docs/kotlin-debugging.md b/docs/kotlin-debugging.md deleted file mode 100644 index 826fde36f..000000000 --- a/docs/kotlin-debugging.md +++ /dev/null @@ -1,63 +0,0 @@ -# Kotlin debugging - -Kotlin Application configurations use the official `fwcd/kotlin-debug-adapter` -release, installed through Settings > Language Servers or the missing-debugger -prompt. A module containing `.kt` files under `src` uses this adapter for both -Application launch and Maven/Gradle JDWP attach, including mixed Java/Kotlin -modules. Pure Java modules keep `java-debug`. - -The adapter runs on the language-server JDK (11+). The application runs on its -resolved project JDK, with the classpath, VM arguments, program arguments and -environment from its run configuration. - -## Source paths - -The adapter infers JVM class names from source paths. OpenIDEA creates a temporary, -package-aligned source mirror outside the project and supplies it as the adapter's -`projectRoot`. For example, `src/main/kotlin/Main.kt` declaring `package org.example` -gets an external `src/main/kotlin/org/example/Main.kt` alias. Breakpoints go through -that alias; stack frames and event paths map back to the original editor file. -Source sets have separate mirror roots. Where file symlinks are unavailable, -OpenIDEA copies sources and refreshes the copies on editor saves. - -Restart debugging after changing a package or source root. The upstream adapter's -path-based resolver does not support renamed Kotlin facades (`@file:JvmName`), or -packages containing a literal `java` or `kotlin` component. Selecting the correct -main class does not remove those breakpoint limitations. - -The Kotlin Application launcher currently passes the classpath directly, without -Java's large-command-line shortening, and decodes application output as UTF-8. -Very large classpaths and non-UTF-8 application output remain limitations; Windows -runtime behavior has not been verified. - -## Startup ordering - -OpenIDEA starts Kotlin applications with JDWP `suspend=y` and attaches the adapter. -It waits for each `setBreakpoints` response before sending `configurationDone`. -Adapter 0.4.4 never completes the `configurationDone` response, so OpenIDEA sends -that request without awaiting its response. - -The official adapter can resume the VMStart event before installing its breakpoint -hooks and DAP listeners. A session-owned loopback JDWP relay holds VM and thread -resume commands until the successful DAP attach response, which follows listener -installation. Other JDWP requests and replies continue during setup. Attach failure -or session shutdown closes the relay and kills any JVM launched by OpenIDEA. No -adapter patch or timed startup sleep is used. An already-running external JVM must -be started with `suspend=y` to preserve its earliest breakpoints. - -## Verification - -`cargo test -p ide-dap --lib` covers relay ordering, fragmented packets, cleanup, -source mapping, and the adapter's missing configuration response. The ignored -`kotlin_official_release_ten_no_delay_runs` test exercises the real DAP client ten -times with an official adapter and a compiled reporter fixture. Set -`KOTLIN_PROBE_CONFIG` to a LaunchConfig JSON file and `KOTLIN_PROBE_ADAPTER` to the -release executable, then run: - -```sh -cargo test -p ide-dap kotlin_official_release_ten_no_delay_runs -- --ignored --nocapture -``` - -The fixture is `src/main/kotlin/Main.kt`, with `package org.example`, a `for (i in -0..3)` loop at line 5 and `println(i)` at line 6, without any startup delay. Each run -must hit line 6, expose `i = 0`, step to line 5, and stop its session. diff --git a/docs/plugins/indexing.md b/docs/plugins/indexing.md new file mode 100644 index 000000000..af146dd7c --- /dev/null +++ b/docs/plugins/indexing.md @@ -0,0 +1,54 @@ +# Background document indexers (API 1.2) + +Use `contributes.providers` for interactive definition, completion and line-marker +requests about one document. A document indexer is for cross-file associations +that need the whole project: it publishes keys once per document, and the host +resolves clicks and gutter markers from memory, without scanning files or +calling the plugin. + +```toml +[[contributes.documentIndexers]] +extensions = ["java", "xml"] +``` + +Implement `openidea_plugin::indexing::DocumentIndexer` and register with +`register_plugin!(Plugin, indexing)` (or `register_plugin!(Plugin, providers, indexing)` +when the plugin also has interactive providers). Components built with other +tools target the `plugin-indexing` world. Indexers require `project.read` and run +only in trusted projects. + +`index` gets the document's text (the editor's while it is open) and the symbols +the host's syntax support found in it, and returns: + +- `definitions`: where keys are declared; +- `references`: ranges that go to the definitions of their key on Go to + Declaration and Ctrl/⌘-click. Elsewhere the language server answers as usual; +- `aliases`: a `prefix#` whose keys also resolve under `target#`, for inherited + members. Chains are followed with bounded depth and candidates; +- `markers`: gutter icons that go to the definitions of their key. + +Keys are the plugin's own strings and ranges are UTF-16. When a key has +definitions in the same detected module, other modules' definitions are left +out. Several targets open a chooser. + +Initial discovery reuses the host's project index. The editor schedules +cancellable batches of at most 16 files; edits and file changes invalidate the +affected entries before they are indexed again. Reloading, disabling or a +failing runtime drops the plugin's graph, and nothing is answered from a +document version older than the editor's. + +Limits: 2 MiB per document, 20,000 files per graph, 4,096 definitions and +references and 4,096 markers per document, 256 aliases, 200,000 entries per +graph, 16 project/window graphs and 256 candidates per lookup. A document over +a limit is not indexed; editing is never blocked. + +## Minimal fixture + +`crates/ide-plugin/tests/fixtures/indexer` uses a tiny line-oriented `.idx` +language to exercise definitions, references, aliases, markers and host syntax +symbols without framework-specific parsing. Run +`cargo test -p ide-host --test plugin_indexing` for the real-component contract +checks. + +Framework plugins (MyBatis mappers, Rails routes) belong in their authors' +repositories and are listed through the registry; see `plugins/README.md`. diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 06f620a9d..b0a5c0f3b 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -324,7 +324,7 @@ a token. -224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file. +242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file. ### Surfaces (UI theme) @@ -407,6 +407,10 @@ a token. | `--diff-modified` | `#385570` | `#c2d8f2` | | `--diff-inserted` | `#294436` | `#bee6be` | | `--diff-deleted` | `#484a4a` | `#d6d6d6` | +| `--diff-modified-stripe` | `#43698d` | `#b6d2f2` | +| `--diff-inserted-stripe` | `#447152` | `#aadeaa` | +| `--diff-deleted-stripe` | `#656e76` | `#c8c8c8` | +| `--diff-conflict-stripe` | `#8f5247` | `#ffc8bd` | ### Syntax (editor scheme) @@ -430,6 +434,20 @@ a token. | `--syn-param` | `#bcbec4` | `#080808` | | `--syn-property` | `#c77dbb` | `#871094` | | `--syn-constant` | `#c77dbb` | `#871094` | +| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` | +| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` | +| `--syn-python-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` | +| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` | +| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` | +| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` | +| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` | +| `--syn-python-builtin` | `var(--syn-builtin)` | `var(--syn-builtin)` | +| `--syn-python-predefined-definition` | `var(--syn-predefined)` | `var(--syn-predefined)` | +| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` | +| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` | +| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` | +| `--syn-php-variable` | `var(--syn-property)` | `var(--syn-property)` | +| `--syn-php-function-call` | `var(--syn-function)` | `var(--syn-function)` | | `--syn-builtin` | `#8888c6` | `#000080` | | `--syn-self` | `#94558d` | `#94558d` | | `--syn-keyword-arg` | `#aa4926` | `#660099` | diff --git a/docs/unity.md b/docs/unity.md deleted file mode 100644 index 28c9eba22..000000000 --- a/docs/unity.md +++ /dev/null @@ -1,23 +0,0 @@ -# Unity projects - -Open the folder containing `Assets` and `ProjectSettings/ProjectVersion.txt`. -OpenIDEA detects Unity without requiring a generated solution and shows the editor -version in the Unity project view. - -The Unity view hides `.meta` files, generated C# project files and cache/build -folders. **Show Meta Files** and **Show Generated Files** reveal them independently; -the regular Project view is still available. Generated folders are excluded from -indexing and search, including folders Unity creates after the project is opened. - -Operations on assets inside `Assets` include their `.meta` files. Rename and move -preserve GUIDs, deletion removes both files, and copy generates new GUIDs and updates -references within the copied group. References to assets outside the group stay -unchanged. File move undo and transactional workspace edits include metadata too. -Destination metadata conflicts are rejected before making changes. - -Projects may be opened through a linked root or parent directory, including macOS -`/var` and `/tmp`. Links inside the project (including Windows junctions) are not -followed by asset operations. For moves/copies to or from ordinary directories, -the external path must have no linked ancestors. Cross-volume asset moves and -copies that require rewriting binary serialized assets are rejected; use Unity -for those operations. diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md deleted file mode 100644 index 02136699c..000000000 --- a/docs/workspaces-and-window-groups.md +++ /dev/null @@ -1,47 +0,0 @@ -# Workspaces and project window groups - -A workspace opens several local project folders in one IDE session. A window group switches between independent IDE sessions; a member can itself contain a workspace. - -## Workspace - -Use **File → New Workspace…**. Choose a name and a separate directory for the workspace, then add project folders. **File → Open Workspace…** opens that directory again. **File → Manage Workspace…** changes its name or linked projects. Removing a link leaves the project's files and open editor buffers intact. - -When opening another local project, choose **Attach** to add it to this window. A plain project becomes a workspace automatically under the IDE app data directory's `workspaces` folder, named after the current project (with a unique directory suffix when needed). The existing editor session carries over after the unsaved-changes guard. **Don't ask again** remembers Attach; change it in **Settings → System Settings → Project → Open project in**. Already linked projects are revealed in Project view. Right-click a non-first linked root and choose **Remove from Workspace** to unlink it without deleting files. - -The workspace stores its manifest in `.openidea/workspace.json`: - -```json -{ - "version": 1, - "name": "Full stack", - "projects": ["/work/frontend", "/work/backend"] -} -``` - -Paths in an existing manifest may be relative to the workspace directory. New and updated manifests use canonical absolute paths. Missing previously linked folders are retained so they can be reconnected or removed. Nested workspace manifests are rejected. - -Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration. - -The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action. - -## Linked Gradle builds in a project - -In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them. - -Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder. - -Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches. - -## Window group - -Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux. - -Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. - -The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. Hidden members are kept at the selected window's frame and display (and follow it after a move or resize), so switching projects only changes which window is visible. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members. - -## Validation - -Automated coverage includes workspace manifest validation and persistence, missing members, independent language-server initialization roots, cross-root search/repositories/run directories, document preservation after unlinking, window-group membership/order, dialog failures and tab keyboard controls. - -Tauri MCP validation on macOS covered workspace creation and management, opening same-named files from both projects, cross-project search, running both projects, tab selection/reordering/detachment, unsaved close cancellation, full-screen switching and application restart restoration. Windows/Linux native-window behavior has not been visually tested. diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts index da15e0e32..f46d96936 100644 --- a/e2e/agentMarkdown.spec.ts +++ b/e2e/agentMarkdown.spec.ts @@ -47,11 +47,16 @@ const REPLY = [ "```", ].join("\n"); -async function openReply(page: Page, text: string) { +async function openReply(page: Page, text: string, imageData?: string) { await page.goto("/"); await page.getByRole("tree", { name: "Project" }).waitFor(); await page.evaluate( - async ({ task, text }) => { + async ({ task, text, imageData }) => { + if (imageData) { + const routePath = "/src/platform/tauriCore.ts"; + const { setInvokeRoute } = await import(routePath); + setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(imageData) : null)); + } // Paths go through variables: knip can't resolve these browser-absolute URLs. const storePath = "/src/features/agents/store.ts"; const store = await import(storePath); @@ -66,7 +71,7 @@ async function openReply(page: Page, text: string) { }); store.dockTask(task.id); }, - { task: TASK, text }, + { task: TASK, text, imageData }, ); const reply = page.locator("[class*='_markdown_']").first(); await reply.locator("h1, p").first().waitFor(); @@ -128,3 +133,19 @@ test("styles quotes, rules, images and task lists", async ({ page }) => { // A checkbox stands in for the bullet. expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none"); }); + +test("loads local and web reply images", async ({ page }) => { + await page.route("https://images.example.com/code.gif", (route) => + route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") }), + ); + const reply = await openReply( + page, + "![Local QR](/tmp/code.png)\n\n![Web QR](https://images.example.com/code.gif)", + PIXEL, + ); + for (const name of ["Local QR", "Web QR"]) { + await expect + .poll(() => reply.getByRole("img", { name }).evaluate((img: HTMLImageElement) => img.naturalWidth)) + .toBe(1); + } +}); diff --git a/e2e/commit.spec.ts b/e2e/commit.spec.ts index 6d0c18526..6f2f8af1d 100644 --- a/e2e/commit.spec.ts +++ b/e2e/commit.spec.ts @@ -69,7 +69,8 @@ test("⌘K commits the checked changes with the typed message", async ({ workben await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible(); const [committed] = await ipcCalls(page, "vcs_commit"); expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" }); - const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]); - expect(staged).toEqual([`${ROOT}/build.gradle.kts`]); + // The commit takes the checked file itself; the index isn't staged beforehand. + expect((committed!.options as { stage: string[] }).stage).toEqual([`${ROOT}/build.gradle.kts`]); + expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0); await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden(); }); diff --git a/e2e/commitInclusion.spec.ts b/e2e/commitInclusion.spec.ts index b5a0bda45..b5d0e0380 100644 --- a/e2e/commitInclusion.spec.ts +++ b/e2e/commitInclusion.spec.ts @@ -8,12 +8,11 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; const ROOT = "/dev/acme-api"; const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`); -/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */ +/** Twenty modified tracked files; `vcs_commit` takes the files it commits out of the status. */ async function stubTwentyChanges(page: Page) { await stubDesktop(page); await page.evaluate( async ({ root, names }) => { - const staged = new Set(); const committed = new Set(); const all = names.map((rel) => ({ path: `${root}/${rel}`, @@ -33,14 +32,10 @@ async function stubTwentyChanges(page: Page) { operation: null, }); h.vcs_status = () => all.filter((f) => !committed.has(f.path)); - h.vcs_stage = (args) => { - for (const path of args.paths as string[]) staged.add(path); - return null; - }; + h.vcs_stage = () => null; h.vcs_unstage = () => null; - h.vcs_commit = () => { - for (const path of staged) committed.add(path); - staged.clear(); + h.vcs_commit = (args) => { + for (const path of (args.options as { stage: string[] }).stage) committed.add(path); return "2222222"; }; const vcsModule = "/src/features/vcs/store.ts"; @@ -71,7 +66,8 @@ test("changes left out of a commit stay unchecked (#1539)", async ({ workbench: await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20"); await panel.getByRole("button", { name: "Commit", exact: true }).click(); await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible(); - expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual( + const [commit] = await ipcCalls(page, "vcs_commit"); + expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual( NAMES.slice(0, 5) .map((rel) => `${ROOT}/${rel}`) .sort(), diff --git a/e2e/configurationNavigation.fixture.ts b/e2e/configurationNavigation.fixture.ts new file mode 100644 index 000000000..613b30c4e --- /dev/null +++ b/e2e/configurationNavigation.fixture.ts @@ -0,0 +1,100 @@ +/** Real CodeMirror mouse/navigation path with a deliberately slow IPC boundary. + * Semantic correctness and JVM timings live in the real-JDT configuration harness. + */ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { commands } from "../src/platform/ipc"; +import { usePlatform } from "../src/platform/platformInfo"; +import { provideEditorService } from "../src/workbench/contributions"; +import { lspDoc } from "../src/editor/lsp/lsp"; +import { navigationExtension } from "../src/editor/lsp/navigation"; +import { hoverExtension } from "../src/editor/lsp/hover"; +import { lspTheme } from "../src/editor/lsp/theme"; + +Object.assign(window, { __TAURI_INTERNALS__: {} }); +usePlatform.setState({ os: "macos" }); +document.body.innerHTML = ` +
    +0`; +const configHost = document.querySelector("#config")!; +const javaHost = document.querySelector("#java")!; +let requests = 0; +let hoverRequests = 0; +let targetLine = 0; +let clickedAt = 0; +const latency: number[] = []; +commands.lspGoto = async () => { + document.querySelector("#requests")!.textContent = String(++requests); + await new Promise((resolve) => setTimeout(resolve, 500)); + return { + status: "ok", + data: [ + { + path: "/fixture/Settings.java", + uri: null, + range: { start: { line: targetLine, col: 24 }, end: { line: targetLine, col: 31 } }, + }, + ], + }; +}; +commands.lspHover = async () => { + hoverRequests++; + return { + status: "ok", + data: { contents: { kind: "plaintext", value: "dingtalk.driver-class-name String" }, range: null }, + }; +}; +commands.lspDocumentLinks = async () => ({ status: "ok", data: [] }); +commands.lspDocumentHighlights = async () => ({ status: "ok", data: [] }); +commands.cancelRequest = async () => ({ status: "ok", data: null }); +const java = new EditorView({ + parent: javaHost, + state: EditorState.create({ + doc: "class Settings { String agentId; }", + extensions: [ + lspDoc.of({ doc: 2, path: "/fixture/Settings.java", language: "java", uiMode: "Java", readOnly: false }), + navigationExtension(), + ], + }), +}); +const config = new EditorView({ + parent: configHost, + state: EditorState.create({ + doc: "dingtalk:\n driver-class-name: demo\n", + extensions: [ + EditorState.languageData.of(() => [{ wordChars: "-" }]), + lspDoc.of({ doc: 1, path: "/fixture/application.yml", language: "yaml", uiMode: "YAML", readOnly: false }), + navigationExtension(), + hoverExtension(), + lspTheme, + ], + }), +}); +config.contentDOM.addEventListener( + "mousedown", + () => { + clickedAt = performance.now(); + }, + true, +); +provideEditorService({ + openFile: async (path, options) => { + javaHost.hidden = false; + configHost.hidden = true; + java.dispatch({ selection: { anchor: java.state.doc.line((options?.selection?.line ?? 0) + 1).from + 24 } }); + document.querySelector("#opened")!.textContent = `${path}:${options?.selection?.line}`; + latency.push(performance.now() - clickedAt); + }, +} as Parameters[0]); +document.querySelector("#back")!.addEventListener("click", () => { + javaHost.hidden = true; + configHost.hidden = false; +}); +document.querySelector("#edit")!.addEventListener("click", () => { + java.dispatch({ changes: { from: 0, insert: "// moved\n" } }); + targetLine++; +}); +Object.assign(window, { navigationMetrics: () => ({ requests, hoverRequests, latency, injectedServerDelayMs: 500 }) }); diff --git a/e2e/configurationNavigation.html b/e2e/configurationNavigation.html new file mode 100644 index 000000000..6850f67bb --- /dev/null +++ b/e2e/configurationNavigation.html @@ -0,0 +1,10 @@ + + + + + Configuration navigation fixture + + + + + diff --git a/e2e/configurationNavigation.spec.ts b/e2e/configurationNavigation.spec.ts new file mode 100644 index 000000000..5221dc93c --- /dev/null +++ b/e2e/configurationNavigation.spec.ts @@ -0,0 +1,91 @@ +import { writeFile } from "node:fs/promises"; +import { test, expect, type Page } from "@playwright/test"; + +async function keyPosition(page: Page) { + const line = page.locator("#config .cm-line").filter({ hasText: "driver-class-name" }); + const box = (await line.boundingBox())!; + // Skip the two-space indentation, land within the actual key. + return { x: box.x + 45, y: box.y + box.height / 2 }; +} +async function open(page: Page) { + await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" })); + await page.goto("/e2e/configurationNavigation.html"); + await expect(page.locator("#config .cm-content")).toContainText("driver-class-name"); +} +async function metrics(page: Page) { + return page.evaluate(() => + ( + window as unknown as { + navigationMetrics: () => { + requests: number; + hoverRequests: number; + latency: number[]; + injectedServerDelayMs: number; + }; + } + ).navigationMetrics(), + ); +} + +test("modifier hover and repeated clicks share results; editing Java invalidates them", async ({ page }, info) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await open(page); + const key = await keyPosition(page); + await page.keyboard.down("Meta"); + await page.mouse.move(key.x, key.y); + await expect(page.locator(".cm-lsp-link")).toHaveText("driver-class-name"); + await expect(page.locator(".cm-lsp-link")).toHaveCSS("cursor", "pointer"); + await expect(page.locator(".cm-lsp-link")).toHaveCSS("color", "rgb(33, 92, 200)"); + expect((await metrics(page)).hoverRequests).toBe(0); + await expect(page.locator(".cm-lsp-nav-info")).toHaveCount(0); + await page.screenshot({ path: info.outputPath("configuration-link.png") }); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0"); + await page.keyboard.up("Meta"); + for (let i = 0; i < 10; i++) { + await page.getByRole("button", { name: "Back to configuration" }).click(); + await page.keyboard.down("Meta"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#java")).toBeVisible(); + await page.keyboard.up("Meta"); + } + const warm = await metrics(page); + expect(warm.requests).toBe(1); + expect(warm.latency).toHaveLength(11); + const evidence = info.outputPath("warm-navigation.json"); + await writeFile(evidence, JSON.stringify(warm, null, 2)); + await info.attach("warm-navigation.json", { path: evidence, contentType: "application/json" }); + await page.getByRole("button", { name: "Edit Java target" }).click(); + await page.getByRole("button", { name: "Back to configuration" }).click(); + await page.keyboard.down("Meta"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:1"); + expect((await metrics(page)).requests).toBe(2); + expect(errors).toEqual([]); +}); + +test("clicking before the hover query completes shares the in-flight request", async ({ page }, info) => { + await open(page); + const key = await keyPosition(page); + await page.keyboard.down("Meta"); + await page.mouse.move(key.x, key.y); + await expect(page.locator("#requests")).toHaveText("1"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0"); + const result = await metrics(page); + expect(result.requests).toBe(1); + await info.attach("inflight-navigation.json", { + body: JSON.stringify(result, null, 2), + contentType: "application/json", + }); +}); + +test("ordinary hover still shows configuration documentation", async ({ page }) => { + await open(page); + const key = await keyPosition(page); + await page.mouse.move(key.x, key.y); + await expect(page.locator(".cm-lsp-hover")).toContainText("dingtalk.driver-class-name String"); + expect((await metrics(page)).requests).toBe(0); + expect((await metrics(page)).hoverRequests).toBeGreaterThan(0); +}); diff --git a/e2e/gitLogCommitChecks.spec.ts b/e2e/gitLogCommitChecks.spec.ts new file mode 100644 index 000000000..9a0a65cac --- /dev/null +++ b/e2e/gitLogCommitChecks.spec.ts @@ -0,0 +1,148 @@ +import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; + +test("shows GitHub check statuses for each commit's repository", async ({ workbench: page }) => { + await page.clock.install(); + await stubDesktop(page); + await page.evaluate(async () => { + const primary = "/dev/acme-api"; + const linked = "/dev/acme-web"; + const tip = "1".repeat(40); + const pending = "2".repeat(40); + const missing = "3".repeat(40); + const h = window.__ipc.handlers; + h.vcs_roots = () => + [ + { root: primary, head: "main" }, + { root: linked, head: "release" }, + ].map((repo) => ({ ...repo, operation: null, headCommit: tip })); + h.vcs_branch_info = ({ repo }) => ({ + head: repo === linked ? "release" : "main", + headCommit: tip, + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_branches = ({ repo }) => + [repo === linked ? "origin/dev" : "origin/other"].map((name) => ({ + name, + commit: tip, + remote: true, + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: 0, + })); + h.vcs_log = ({ repo }) => { + const rows: [string, string][] = + repo === linked + ? [[tip, "Linked repository commit"]] + : [ + [tip, "Primary repository commit"], + [pending, "Pending commit"], + [missing, "Local-only commit"], + ]; + return rows.map(([id, summary], i) => ({ + id, + shortId: id.slice(0, 8), + parents: [], + refs: i === 0 ? [repo === linked ? "origin/dev" : "origin/other"] : [], + author: "A", + email: "a@example.com", + timeMs: 0, + summary, + message: summary, + })); + }; + h.vcs_log_refs = () => ({}); + h.vcs_log_details_batch = ({ revisions }) => + (revisions as string[]).map(() => ({ + message: "Linked repository commit", + containingBranches: [], + signature: "N", + signer: "", + key: "", + fingerprint: "", + })); + h.vcs_commit_files = () => []; + h.vcs_config_get = () => null; + h.vcs_tags = () => []; + h.vcs_status = () => []; + h.vcs_remotes = ({ repo }) => [ + { name: "origin", url: `git@github.com:example/${repo === linked ? "web" : "api"}.git` }, + ]; + let pendingQueries = 0; + h.gh_api = ({ repo, request }) => { + const { variables } = JSON.parse((request as { body: string }).body) as { variables: Record }; + const linkedRepo = repo === linked; + const state = linkedRepo ? "FAILURE" : "SUCCESS"; + if (variables.oid) + return JSON.stringify({ + data: { + repository: { + object: { + statusCheckRollup: { + contexts: { + nodes: [{ __typename: "StatusContext", context: "Frontend", state, targetUrl: null }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + }); + const statuses = Object.entries(variables) + .filter(([key]) => /^oid\d+$/.test(key)) + .map(([key, revision]) => [ + key.replace("oid", "c"), + revision === missing + ? null + : { statusCheckRollup: { state: revision === pending && ++pendingQueries === 1 ? "PENDING" : state } }, + ]); + return JSON.stringify({ data: { repository: Object.fromEntries(statuses) } }); + }; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root: primary, repo: primary }); + await refreshNow(); + const contextModule = "/src/platform/context.ts"; + const { setContext } = await import(/* @vite-ignore */ contextModule); + setContext("vcsAvailable", true); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + await executeAction("vcs.showLog"); + }); + + const primary = page.getByRole("option", { name: /Primary repository commit/ }); + const linked = page.getByRole("option", { name: /Linked repository commit/ }); + await expect(primary).toBeVisible(); + expect(await ipcCalls(page, "gh_api")).toEqual([]); + await page.getByRole("button", { name: "View Options" }).first().click(); + await page.getByRole("menuitem", { name: "Columns", exact: true }).hover(); + await page.getByRole("menuitemcheckbox", { name: "GitHub Commit Checks" }).click({ timeout: 4000 }); + await expect(primary.getByRole("button", { name: "Checks passed" })).toBeVisible(); + await expect(linked.getByRole("button", { name: "Checks failed" })).toBeVisible(); + await expect( + page.getByRole("option", { name: /Pending commit/ }).getByRole("button", { name: "Checks in progress…" }), + ).toBeVisible(); + await expect( + page.getByRole("option", { name: /Local-only commit/ }).getByRole("button", { name: /^Checks / }), + ).toHaveCount(0); + + const calls = await ipcCalls(page, "gh_api"); + expect(calls.map((c) => c.repo).sort()).toEqual(["/dev/acme-api", "/dev/acme-web"]); + expect(calls.map((c) => JSON.parse((c.request as { body: string }).body).variables.repoName).sort()).toEqual([ + "api", + "web", + ]); + await linked.getByRole("button", { name: "Checks failed" }).click(); + await expect(page.getByRole("option", { name: /Frontend/ })).toBeVisible(); + expect((await ipcCalls(page, "gh_api")).at(-1)).toMatchObject({ repo: "/dev/acme-web" }); + + await page.keyboard.press("Escape"); + await page.clock.fastForward(31_000); + await expect( + page.getByRole("option", { name: /Pending commit/ }).getByRole("button", { name: "Checks passed" }), + ).toBeVisible(); +}); diff --git a/e2e/newBranch.spec.ts b/e2e/newBranch.spec.ts new file mode 100644 index 000000000..f07067393 --- /dev/null +++ b/e2e/newBranch.spec.ts @@ -0,0 +1,70 @@ +/** + * Create New Branch: spaces typed or pasted into the name become separators on the fly, as in + * IDEA, so the name stays valid. Git is stubbed at the IPC layer. + */ +import type { Page } from "@playwright/test"; +import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; + +const ROOT = "/dev/acme-api"; + +async function openNewBranchDialog(page: Page) { + await stubDesktop(page); + await page.evaluate(async (root) => { + const h = window.__ipc.handlers; + h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }]; + h.vcs_branch_info = () => ({ + head: "main", + headCommit: "1111111", + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_status = () => []; + h.vcs_create_branch = () => null; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false }); + await refreshNow(); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + void executeAction("vcs.newBranch"); + }, ROOT); + const dialog = page.getByRole("dialog", { name: "Create New Branch" }); + const input = dialog.getByRole("textbox", { name: "New branch name:" }); + await expect(input).toBeVisible(); + return { dialog, input }; +} + +test("a typed space becomes a separator and Create stays enabled", async ({ workbench: page }) => { + const { dialog, input } = await openNewBranchDialog(page); + await input.fill(""); + await input.pressSequentially("fix login redirect"); + await expect(input).toHaveValue("fix-login-redirect"); + await expect(dialog.getByText("Branch name contains invalid characters")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + const [call] = await ipcCalls(page, "vcs_create_branch"); + expect(call).toMatchObject({ name: "fix-login-redirect" }); +}); + +test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => { + const { input } = await openNewBranchDialog(page); + await input.fill("ab"); + await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1)); + await page.keyboard.press("Space"); + await expect(input).toHaveValue("a-b"); + await page.keyboard.type("x"); + await expect(input).toHaveValue("a-xb"); +}); + +test("pasted text has its spaces replaced", async ({ workbench: page }) => { + const { dialog, input } = await openNewBranchDialog(page); + await input.fill(""); + await input.evaluate((el: HTMLInputElement) => { + el.focus(); + document.execCommand("insertText", false, "feature/add new thing"); + }); + await expect(input).toHaveValue("feature/add-new-thing"); + await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled(); +}); diff --git a/e2e/oracle-source.spec.ts b/e2e/oracle-source.spec.ts new file mode 100644 index 000000000..bd5fa72f9 --- /dev/null +++ b/e2e/oracle-source.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "./fixtures"; + +test("configures an Oracle source with its Instant Client directory", async ({ workbench: page }, info) => { + await page.evaluate(async () => { + const storePath = "/src/features/database/store.ts"; + const bindingsPath = "/src/bindings.ts"; + const { useDatabase } = await import(storePath); + const { commands } = await import(bindingsPath); + commands.dbTest = async (source: unknown) => { + document.documentElement.dataset.testedSource = JSON.stringify(source); + return { status: "ok", data: "Oracle Database 23ai Enterprise Edition Release 23.5.0.24.07" }; + }; + useDatabase.setState({ dialog: { kind: "source", source: null } }); + }); + const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true }); + await dialog.getByRole("button", { name: "Data source type", exact: true }).click(); + // The type list is virtualised: reach the entry by typing, then confirm it. + await page.keyboard.type("oracle"); + await page.keyboard.press("Enter"); + await expect(dialog.locator("#db-port")).toHaveValue("1521"); + await expect(dialog.locator("#db-url")).toHaveValue("jdbc:oracle://localhost:1521"); + + // The client libraries are not bundled: the Options tab names their directory (empty: ORACLE_HOME). + const testButton = dialog.getByRole("button", { name: "Test Connection", exact: true }); + await dialog.locator("#db-url").fill("oracle://scott:tiger@db.example:1521/ORCLPDB1"); + await expect(dialog.locator("#db-host")).toHaveValue("db.example"); + await expect(dialog.locator("#db-port")).toHaveValue("1521"); + await expect(dialog.locator("#db-user")).toHaveValue("scott"); + await expect(dialog.locator("#db-database")).toHaveValue("ORCLPDB1"); + await expect(testButton).toBeEnabled(); + + await dialog.getByRole("tab", { name: "Options", exact: true }).click(); + await dialog.locator("#db-instant-client").fill("/opt/oracle/instantclient_23_5"); + await testButton.click(); + await expect(dialog.getByRole("status")).toHaveText(/^Succeeded: Oracle Database 23ai/); + expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({ + driver: "oracle", + host: "db.example", + port: 1521, + user: "scott", + database: "ORCLPDB1", + options: { instantClient: "/opt/oracle/instantclient_23_5" }, + }); + await dialog.screenshot({ path: info.outputPath("oracle-source-options.png") }); +}); diff --git a/e2e/pythonHighlight.fixture.ts b/e2e/pythonHighlight.fixture.ts new file mode 100644 index 000000000..71ad99bd7 --- /dev/null +++ b/e2e/pythonHighlight.fixture.ts @@ -0,0 +1,93 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { openideaHighlightStyle } from "../src/editor/core/cm/highlight"; +import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages"; +import { semanticExtension } from "../src/editor/lsp/semantic"; +import { lspDoc } from "../src/editor/lsp/lsp"; +import { lspTheme } from "../src/editor/lsp/theme"; +import { parseIcls } from "../src/platform/icls"; +import { schemeCss } from "../src/platform/colorScheme"; +import type { EditorDocContext } from "../src/editor/api"; + +const attributes = { + TEXT: { FOREGROUND: "ddeeff", BACKGROUND: "101820" }, + "PY.KEYWORD": { FOREGROUND: "112233" }, + "PY.FUNC_DEFINITION": { FOREGROUND: "224466" }, + "PY.NESTED_FUNC_DEFINITION": { FOREGROUND: "335577" }, + DEFAULT_FUNCTION_CALL: { FOREGROUND: "446688" }, + DEFAULT_LINE_COMMENT: { FOREGROUND: "667788" }, + DEFAULT_BLOCK_COMMENT: { FOREGROUND: "aabbcc" }, + "PY.METHOD_CALL": { FOREGROUND: "557799" }, + "PY.DOC_COMMENT": { FOREGROUND: "6688aa", EFFECT_TYPE: "1" }, + "PY.STRING.B": { FOREGROUND: "7799bb", FONT_TYPE: "1" }, + "PY.VALID_STRING_ESCAPE": { FOREGROUND: "88aacc" }, + "PY.ANNOTATION": { FOREGROUND: "99bbdd", BACKGROUND: "182838" }, + "PY.SELF_PARAMETER": { FOREGROUND: "aa7788" }, + "PY.BUILTIN_NAME": { FOREGROUND: "bb8899" }, + "PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" }, + "PY.STRING.F": { FOREGROUND: "aa8811" }, + "PY.PREDEFINED_DEFINITION": { FOREGROUND: "bb9922" }, + "PY.PREDEFINED_USAGE": { FOREGROUND: "ccaa33" }, + OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" }, + OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" }, + OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" }, + OPENIDEA_pyClassAttribute: { FOREGROUND: "aa4404" }, + OPENIDEA_pyConstant: { FOREGROUND: "aa5505" }, + OPENIDEA_pyProperty: { FOREGROUND: "aa6606" }, + OPENIDEA_pyTypeParameter: { FOREGROUND: "aa7707" }, +}; +const xml = `${Object.entries(attributes) + .map( + ([key, values]) => + ``, + ) + .join("")}`; +const style = document.createElement("style"); +style.textContent = + schemeCss("custom-python", {}, null, { "custom-python": parseIcls(xml).scheme }) + + "body { margin: 24px; background: #101820; color: #ddeeff; } .cm-editor { font: 14px/1.6 monospace; }"; +document.head.append(style); +const support = await loadSupport(findLanguageDescription("Python", "demo.py")!); +new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: `MAX_SIZE: Final = 10 +type Alias = list[int] +fstr = f"value={1}" +name = "global" +T = TypeVar("T") + +def outer(x: T) -> T: + """Syntax Highlighting Demo""" + def nested(y): + print(y + 1) + nested(x) + +class Foo[T]: + count = 0 + def __init__(self: Foo, value: T): + value = b"newline:\\n" + local = 1 + self.instance_attr = value + print(local, MAX_SIZE, name, T) + self.run(whatever=1) # Comment + print(self.__doc__) + @property + def size(self) -> int: + return self.instance_attr + +def unused(param): + return 1 +`, + extensions: [ + support!, + syntaxHighlighting(openideaHighlightStyle), + lspTheme, + lspDoc.of({ language: "python" } as EditorDocContext), + ...(new URLSearchParams(location.search).has("baseline") ? [] : [semanticExtension()]), + ], + }), +}); diff --git a/e2e/pythonHighlight.html b/e2e/pythonHighlight.html new file mode 100644 index 000000000..f3414d19b --- /dev/null +++ b/e2e/pythonHighlight.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/e2e/pythonHighlight.spec.ts b/e2e/pythonHighlight.spec.ts new file mode 100644 index 000000000..aa70a0f49 --- /dev/null +++ b/e2e/pythonHighlight.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; + +test("Python syntax supplements retain PyCharm colours without a language server", async ({ page }, info) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto("/e2e/pythonHighlight.html?baseline"); + await expect(page.locator(".cm-content")).toBeVisible(); + await page.screenshot({ path: info.outputPath("before.png") }); + await page.goto("/e2e/pythonHighlight.html"); + await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveText("outer"); + await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveCSS( + "color", + "rgb(34, 68, 102)", + ); + await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)"); + await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)"); + await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none"); + await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("color", "rgb(119, 153, 187)"); + await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("font-weight", "700"); + await expect(page.locator(".cm-sem-python-escape")).toHaveCSS("color", "rgb(136, 170, 204)"); + await expect(page.locator(".cm-sem-python-annotation").first()).toHaveCSS("color", "rgb(153, 187, 221)"); + await expect(page.locator(".cm-sem-self").first()).toHaveCSS("color", "rgb(170, 119, 136)"); + await expect(page.locator(".cm-sem-python-method-call")).toHaveCSS("color", "rgb(85, 119, 153)"); + await expect( + page + .locator(".cm-line") + .filter({ hasText: "nested(x)" }) + .locator(".cm-sem-function") + .filter({ hasText: /^nested$/ }), + ).toHaveCSS("color", "rgb(68, 102, 136)"); + await expect( + page + .locator(".cm-line") + .filter({ hasText: "# Comment" }) + .locator("span") + .filter({ hasText: /^# Comment$/ }), + ).toHaveCSS("color", "rgb(102, 119, 136)"); + const print = page + .locator(".cm-sem-python-builtin") + .filter({ hasText: /^print$/ }) + .first(); + await expect(print).toHaveText("print"); + await expect(print).toHaveCSS("color", "rgb(187, 136, 153)"); + await expect(page.locator(".cm-sem-python-keyword-arg")).toHaveCSS("color", "rgb(204, 153, 170)"); + await expect(page.locator(".cm-sem-python-local-variable").first()).toHaveCSS("color", "rgb(170, 17, 1)"); + await expect(page.locator(".cm-sem-python-global-variable").first()).toHaveCSS("color", "rgb(170, 34, 2)"); + await expect(page.locator(".cm-sem-python-instance-attribute").first()).toHaveCSS("color", "rgb(170, 51, 3)"); + await expect(page.locator(".cm-sem-python-class-attribute").first()).toHaveCSS("color", "rgb(170, 68, 4)"); + await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)"); + await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)"); + await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)"); + await expect(page.locator(".cm-sem-python-fstring").first()).toHaveCSS("color", "rgb(170, 136, 17)"); + await expect(page.locator(".cm-sem-python-predefined-definition")).toHaveCSS("color", "rgb(187, 153, 34)"); + await expect(page.locator(".cm-sem-python-predefined-usage")).toHaveCSS("color", "rgb(204, 170, 51)"); + await expect(page.locator(".cm-sem-keyword").filter({ hasText: /^type$/ })).toHaveCSS("color", "rgb(17, 34, 51)"); + await expect(page.locator(".cm-sem-unused")).toHaveCSS("color", "rgb(111, 115, 122)"); + expect(errors).toEqual([]); + await page.screenshot({ path: info.outputPath("python-highlighting.png") }); +}); diff --git a/e2e/themes.spec.ts b/e2e/themes.spec.ts index ce717dace..30cc77995 100644 --- a/e2e/themes.spec.ts +++ b/e2e/themes.spec.ts @@ -111,7 +111,7 @@ for (const palette of palettes) { ); await editor.locator(".cm-content").click(); await page.keyboard.press("Meta+a"); - await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible(); + await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("Meta+f"); await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order"); diff --git a/java/java-usages/CONFIGURATION.md b/java/java-usages/CONFIGURATION.md index 11d122a00..ef12eb1c3 100644 --- a/java/java-usages/CONFIGURATION.md +++ b/java/java-usages/CONFIGURATION.md @@ -52,12 +52,27 @@ modules, skips nested independent modules and build output, and honors ignore files for disk discovery. It does not guess a global association from matching text in unrelated projects. -Indexing is lazy. The first explicit navigation/search builds a compact snapshot; +Trusted projects with a Maven/Gradle/Eclipse build file at the root start Java a +moment after opening, even without a Java editor open. Once JDT reports +ServiceReady and compiler defaults are applied, the app asks the extension to warm +the configuration index (`openidea.java.configuration.warmup`). A low-priority +system job waits for workspace builds, then builds the same snapshot navigation +uses, one Java project at a time: modules with open YAML/properties files first, +then modules with open Java editors. At most 64 modules are queued, each attempt +has a cooperative 30-second budget, repeated readiness does not repeat finished +work, and warming only fills free room within the retention limits below (it never +evicts an index a lookup built). A snapshot outdated while it was being built is +dropped. Untrusted projects do not start Java; anything warming skips keeps +on-demand indexing. + +Otherwise indexing is lazy. The first explicit navigation/search builds a compact snapshot; ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations; ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST construction for ordinary Java units, but every accepted association is resolved -semantically. Warm requests reuse the snapshot. Java model changes (sources on +semantically. Builds hold a per-project lock that a waiting lookup can cancel, so a +background build of one project never delays another, and a lookup in the same +project reuses the result. Warm requests reuse the snapshot. Java model changes (sources on disk, classpath, archives) invalidate it conservatively, including dependent-type changes; a fingerprint of working copies with unsaved changes covers edits before they are saved. Opening or closing an editor, reconciling, building, and non-Java @@ -65,6 +80,14 @@ resource changes (configuration files are parsed by the client; metadata files a checked by timestamp) keep it. This is **snapshot invalidation**, not a fully incremental Java semantic index. +In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots +included, and doesn't open the documentation popup. Hover and Go to Definition share +one request per key: a click after the hover reuses its answer. The editor keeps up +to 128 answers / 4,096 locations, drops them on edits, file and project changes, +workspace edits and language-server restarts, and expires them after 30 seconds as a +backstop. Empty answers and errors are not kept, since the import may still be in +progress. + Metadata/JAR timestamps are checked before reuse. The client caches parsed disk configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and performs bounded discovery/parsing on a blocking worker. It does not continuously @@ -74,7 +97,7 @@ Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidat metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem entries per root, and depth limits on recursive bindings/YAML. Java requests and references are cancellable. File/association scan limits and unreadable files carry the -existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a +existing incomplete-result indicator; unsupported syntax keys are omitted. Navigation before warming finishes can take seconds on a cold project; classpath import and source download remain owned by JDT LS. ## Deliberate limits @@ -105,7 +128,8 @@ cargo test -p ide-language -p ide-lsp The isolated real-server harness covers custom bindings, inheritance, nesting, collections/maps, records, constructor aliases, factory methods, consumers, constant keys, negative identity matches, dependency metadata/source locations, -reverse queries, and unsaved-change/close invalidation. Optional stress fixtures +reverse queries, warming (queued once, reused by the first lookup, cancellable), and +unsaved-change/close invalidation. Optional stress fixtures add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`, `spring.datasource.url` and `spring.jackson.date-format` through dependency diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index 252534d5c..cdcb4bef1 100644 --- a/java/java-usages/MANIFEST.MF +++ b/java/java-usages/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.3.0 +Bundle-Version: 1.7.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, diff --git a/java/java-usages/README.md b/java/java-usages/README.md index f5d7b2428..0fde82683 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -12,6 +12,14 @@ The Rust bridge logs the failure once per project session. Cancelled requests an changed source documents remain cancelled and do not start a fallback search. No global `includeAccessors` preference is changed. +An optional fifth argument `"sources"` restricts the search to the workspace's +source folders (no dependency jars, no JDK). A library method can have thousands of +usages in those, which take seconds to search. Find Usages searches the sources alone +(Project Files) unless asked for Project and Libraries; then it shows the sources' usages +first and runs the full search after it. Rows in library classes (`jdt:` URIs) carry their +line as `text`, from the contents the client would request for the class, so the client +need not fetch thousands of class files to show them. + ## Search plan and access semantics For source fields on the verified JDT LS implementation, a single plan merges @@ -20,10 +28,14 @@ Targets are deduplicated by JDT element identity before search, retaining native search scope and ranges. Builder results remain unknown. The optimized native plan is guarded by SHA-256 of ReferencesHandler.class: `31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064` -(JDT LS 1.63.0.202610191322). Different implementations, non-field selections and -library/decompiled selections retain native discovery plus deduplicated category -searches. This conservative fallback preserves coverage when the server changes; -it does not promise the same speedup on unverified versions. +(JDT LS 1.63.0.202610191322). On that version, non-field and library/decompiled +selections resolve their target as `ReferencesHandler.findReferences` does, and every +search replicates `ReferencesHandler.search` with one difference: a class file whose +decompiled source fails to parse loses only its own matches (logged), where the server +throws and fails the whole search after searching most dependencies. Different +implementations retain native discovery plus deduplicated category searches. This +conservative fallback preserves coverage when the server changes; it does not promise +the same speedup or fault isolation on unverified versions. SearchEngine resolves exact targets. A query-local syntax AST per referenced source file supplies expression context without another reference search: simple field @@ -57,7 +69,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.3.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.7.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -75,6 +87,22 @@ unrelated content and non-Java resource changes; its reproducible SHA-256 is `cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`. Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is `cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`. +Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's +source folders, before the standard request that also searches every dependency jar); its +reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`. +Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources +(no dependency implements it), otherwise the app also runs the standard request and adds the +dependencies' implementations after the first ones. The search also covers the project's Kotlin +classes attached by the Kotlin bridge; its reproducible SHA-256 is +`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`. +Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configuration indexes in a +low-priority background job after import, and builds them under cancellable per-project locks +instead of one global lock; its reproducible SHA-256 is +`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`. +Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches each class +file's decompiled source on its own and returns the line (`text`) of each usage in a library +class; its reproducible SHA-256 is +`56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`. ```sh export PATH=/path/to/jdk25/bin:$PATH @@ -104,6 +132,7 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json python3 java/java-usages/tests/hierarchy.py /path/to/jdtls +python3 java/java-usages/tests/implementations.py /path/to/jdtls ``` The harnesses select the JDT LS configuration for the current platform. The first diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 2493c96f8..f637e1e40 100644 Binary files a/java/java-usages/openidea-java-usages.jar and b/java/java-usages/openidea-java-usages.jar differ diff --git a/java/java-usages/plugin.xml b/java/java-usages/plugin.xml index d6c345e06..e201aa451 100644 --- a/java/java-usages/plugin.xml +++ b/java/java-usages/plugin.xml @@ -6,6 +6,10 @@ + + + + diff --git a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java index fdf037bb5..d1c1e8a05 100644 --- a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -4,7 +4,10 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; import java.util.zip.ZipFile; import com.google.gson.*; import org.eclipse.core.resources.ResourcesPlugin; @@ -29,6 +32,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong(); private static final Map CACHE = new LinkedHashMap<>(); + /** One build per project at a time; waiting lookups stay cancellable. */ + private static final Map LOCKS = new ConcurrentHashMap<>(); /** Unsaved edits are covered by the working-copy digest, so reconciles never * invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and * non-Java resources, reported as F_CONTENT on a project, root or package @@ -56,6 +61,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { + if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args); String uri = (String) args.get(0); String key = args.get(1) instanceof String s ? s : null; int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue(); @@ -138,7 +144,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { (line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter()); } private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); } - private static IJavaProject project(String uri) { + static IJavaProject project(String uri) { Path path; try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; } IJavaProject best = null; @@ -163,7 +169,33 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { return result; } - private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + /** Builds (or reuses) the project's index in the background; its generation. */ + static long warm(IJavaProject project, IProgressMonitor monitor) throws Exception { + return index(project, monitor, true).generation; + } + + /** Warming fills free cache room only; it never evicts an index a lookup built. */ + static boolean hasWarmupCapacity(IJavaProject project) { + synchronized (CACHE) { + return CACHE.containsKey(project.getHandleIdentifier()) || (CACHE.size() < 8 && + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() < MAX_ENTRIES); + } + } + + private static Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + return index(project, monitor, false); + } + + private static Index index(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception { + ReentrantLock lock = LOCKS.computeIfAbsent(project.getHandleIdentifier(), ignored -> new ReentrantLock()); + while (!lock.tryLock(50, TimeUnit.MILLISECONDS)) check(monitor); + try { + check(monitor); + return build(project, monitor, background); + } finally { lock.unlock(); } + } + + private static Index build(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception { long revision = REVISION.get(); String id = project.getHandleIdentifier(); Map copies = new TreeMap<>(); @@ -181,7 +213,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); } String workingCopies = HexFormat.of().formatHex(digest.digest()); - Index cached = CACHE.get(id); + Index cached; + synchronized (CACHE) { cached = CACHE.get(id); } if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) { boolean valid = true; for (var entry : cached.files.entrySet()) { @@ -227,11 +260,19 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { roots.add(project.getProject().getLocationURI().toString()); builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project); Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet()); - CACHE.remove(id); - while (!CACHE.isEmpty() && (CACHE.size() >= 8 || - CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) - CACHE.remove(CACHE.keySet().iterator().next()); - CACHE.put(id, result); + check(monitor); + // A warmed index that the model has already outdated is dropped, not published. + if (background && revision != REVISION.get()) throw new OperationCanceledException(); + synchronized (CACHE) { + if (background && ((!CACHE.containsKey(id) && CACHE.size() >= 8) || + CACHE.entrySet().stream().filter(e -> !e.getKey().equals(id)).mapToInt(e -> e.getValue().entries.size()).sum() + + result.entries.size() > MAX_ENTRIES)) throw new OperationCanceledException(); + CACHE.remove(id); + while (!CACHE.isEmpty() && (CACHE.size() >= 8 || + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) + CACHE.remove(CACHE.keySet().iterator().next()); + CACHE.put(id, result); + } return result; } diff --git a/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java new file mode 100644 index 000000000..9a68f5b45 --- /dev/null +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java @@ -0,0 +1,111 @@ +package ai.openidea.configuration; + +import java.util.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.*; + +/** One bounded, cooperative queue per language-server process. No polling timer, + * retained ASTs, or project builds; an ordinary lookup remains the fallback. */ +final class ConfigurationWarmup { + private static final Map pending = new LinkedHashMap<>(); + private static final Set attempted = new HashSet<>(); + private static final Map completed = new LinkedHashMap<>(); + private static boolean scheduled, stopped; + private static String active; + private static final Job job = new Job("Preparing configuration navigation") { + @Override protected IStatus run(IProgressMonitor monitor) { + // Compiler defaults/import can schedule a build after ServiceReady. + // Yield to it instead of blocking a worker on the build job family. + if (building()) { + synchronized (ConfigurationWarmup.class) { + if (!stopped) schedule(2000); + else scheduled = false; + } + return Status.OK_STATUS; + } + IJavaProject project; + synchronized (ConfigurationWarmup.class) { + if (stopped || pending.isEmpty()) { scheduled = false; return Status.OK_STATUS; } + String id = pending.keySet().iterator().next(); + project = pending.remove(id); + active = id; + } + long deadline = System.nanoTime() + 30_000_000_000L; + IProgressMonitor bounded = new ProgressMonitorWrapper(monitor) { + @Override public boolean isCanceled() { + return super.isCanceled() || System.nanoTime() >= deadline || + !project.exists() || !project.getProject().isOpen(); + } + }; + try { + if (ConfigurationCommand.hasWarmupCapacity(project)) { + long build = ConfigurationCommand.warm(project, bounded); + synchronized (ConfigurationWarmup.class) { + if (completed.size() < 8) completed.put(project.getHandleIdentifier(), build); + } + } + } catch (OperationCanceledException e) { + // No partial index is published. A user lookup can retry later. + } catch (Exception e) { + org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.logException("Configuration warmup skipped", e); + } finally { + synchronized (ConfigurationWarmup.class) { + active = null; + if (!stopped && !pending.isEmpty()) schedule(200); + else scheduled = false; + } + } + return Status.OK_STATUS; + } + }; + static { job.setSystem(true); job.setPriority(Job.DECORATE); } + + private static boolean building() { + for (Object family : new Object[] { ResourcesPlugin.FAMILY_AUTO_BUILD, ResourcesPlugin.FAMILY_MANUAL_BUILD }) + for (Job other : Job.getJobManager().find(family)) + if (other.getState() != Job.NONE) return true; + return false; + } + + static synchronized Object request(List args) throws JavaModelException { + String action = args.isEmpty() ? "start" : String.valueOf(args.get(0)); + if (action.equals("cancel")) { + stopped = true; + pending.clear(); + job.cancel(); + if (active == null) scheduled = false; + } else if (action.equals("start") && !stopped) { + // Open Java buffers identify the user's active modules. Keep those + // ahead of the rest when the workspace exceeds the cache budget. + Set open = new HashSet<>(); + for (ICompilationUnit unit : JavaCore.getWorkingCopies(null)) open.add(unit.getJavaProject().getHandleIdentifier()); + for (ICompilationUnit unit : JavaCore.getWorkingCopies( + org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + open.add(unit.getJavaProject().getHandleIdentifier()); + Set preferred = new HashSet<>(); + if (args.size() > 1 && args.get(1) instanceof List uris) { + for (Object uri : uris) if (uri instanceof String text) { + IJavaProject project = ConfigurationCommand.project(text); + if (project != null) preferred.add(project.getHandleIdentifier()); + } + } + List projects = new ArrayList<>(Arrays.asList( + JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects())); + projects.sort(Comparator.comparing((IJavaProject p) -> !preferred.contains(p.getHandleIdentifier())) + .thenComparing(p -> !open.contains(p.getHandleIdentifier())) + .thenComparing(IJavaProject::getElementName)); + for (IJavaProject project : projects) { + String id = project.getHandleIdentifier(); + if (project.getProject().isOpen() && !attempted.contains(id) && pending.size() < 64) { + attempted.add(id); + pending.put(id, project); + } + } + if (!scheduled && !pending.isEmpty()) { scheduled = true; job.schedule(2000); } + } + return Map.of("scheduled", scheduled, "pending", pending.size(), "active", active == null ? "" : active, + "completed", new LinkedHashMap<>(completed)); + } +} diff --git a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java new file mode 100644 index 000000000..1dbedf115 --- /dev/null +++ b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java @@ -0,0 +1,92 @@ +package ai.openidea.implementations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.internal.corext.util.MethodOverrideTester; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.lsp4j.Location; + +/** + * Implementations of the type or method at a position, looked up in the workspace's own code + * only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard + * request builds the type hierarchy over every dependency jar too, which takes long in a project + * with many dependencies. {@code complete} when the type is declared in the sources, which no + * dependency extends; otherwise the client also asks the standard request for the dependencies' + * implementations. + */ +public final class ImplementationsCommand implements IDelegateCommandHandler { + /** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */ + private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin"; + + @Override + public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + List locations = new ArrayList<>(); + boolean complete = find(arguments, locations, monitor); + return Map.of("locations", locations, "complete", complete); + } + + /** Adds the implementations to {@code result}; whether they are all there are. */ + private static boolean find(List arguments, List result, IProgressMonitor monitor) throws Exception { + String uri = (String) arguments.get(0); + int line = ((Number) arguments.get(1)).intValue(); + int column = ((Number) arguments.get(2)).intValue(); + ITypeRoot root = JDTUtils.resolveTypeRoot(uri); + if (root == null) return false; + var preferences = JavaLanguageServerPlugin.getPreferencesManager(); + IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor); + IMethod method = null; + IType type; + if (element instanceof IMethod m) { + int flags = m.getFlags(); + if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false; + method = m; + type = m.getDeclaringType(); + } else if (element instanceof IType t) { + type = t; + } else { + return false; + } + if (type == null || type.getJavaProject() == null) return false; + IRegion sources = JavaCore.newRegion(); + for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) { + for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) { + if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) { + sources.add(fragmentRoot); + } + } + } + ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor); + for (IType subtype : hierarchy.getAllSubtypes(type)) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + IMember found; + if (method == null) { + found = subtype.isInterface() ? null : subtype; + } else { + // Seen from the subtype, so its type arguments substitute the method's type variables. + IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method); + found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding; + } + Location location = found == null ? null : JDTUtils.toLocation(found); + if (location != null) result.add(location); + } + return !type.isBinary(); + } + + /** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */ + private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException { + if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false; + IClasspathEntry entry = root.getRawClasspathEntry(); + if (entry == null) return false; + for (IClasspathAttribute attribute : entry.getExtraAttributes()) { + if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true; + } + return false; + } +} diff --git a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java index 18b1fad66..128b1c003 100644 --- a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java +++ b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java @@ -4,13 +4,25 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.core.*; +import org.eclipse.jdt.core.search.IJavaSearchConstants; +import org.eclipse.jdt.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.SearchParticipant; +import org.eclipse.jdt.core.search.SearchPattern; +import org.eclipse.jdt.core.search.SearchRequestor; import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil; import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; import org.eclipse.jdt.ls.core.internal.JDTUtils; import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.SearchUtils; +import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers; import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; +import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope; import org.eclipse.lsp4j.*; /** Classifies search results using JDT method identities, never reference text. */ @@ -21,8 +33,14 @@ public final class UsagesCommand implements IDelegateCommandHandler { int line = ((Number) arguments.get(1)).intValue(); int column = ((Number) arguments.get(2)).intValue(); boolean declarations = Boolean.TRUE.equals(arguments.get(3)); + // "sources": the workspace's source folders only, without dependency jars or the JDK. A + // library method's usages there are found long before the full search ends. + boolean sources = arguments.size() > 4 && "sources".equals(arguments.get(4)); var preferences = JavaLanguageServerPlugin.getPreferencesManager(); var handler = new ReferencesHandler(preferences); + // The verified server's search is replicated (see `search`); others keep their own. + boolean replicated = sources || NATIVE_PLAN_SUPPORTED; + Search search = replicated ? (element, locations, m, d) -> search(element, locations, m, d, sources) : handler::search; var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations)); Map results = new LinkedHashMap<>(); ITypeRoot root = JDTUtils.resolveTypeRoot(uri); @@ -31,40 +49,95 @@ public final class UsagesCommand implements IDelegateCommandHandler { if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); boolean accessors = preferences.getPreferences().isIncludeAccessors(); SearchPlan plan = new SearchPlan(); - // Only replace native target discovery for the exact implementation verified by - // integration tests. New server implementations retain their full native behavior. - if (selected instanceof IField field && root instanceof ICompilationUnit && - root.getResource() != null && NATIVE_PLAN_SUPPORTED) { - plan.add(field, "field", declarations); - if (accessors) { - plan.add(GetterSetterUtil.getGetter(field), "getter", false); - plan.add(GetterSetterUtil.getSetter(field), "setter", false); - addNativeBuilderTargets(handler, field, plan); - addConfiguredAccessors(field, plan); - } - } else { - // Includes non-fields, library/decompiled roots, and unverified JDT LS builds. - for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown"); - if (selected instanceof IField field) { + ITypeRoot targetRoot = null; + try { + // Only replace native target discovery for the exact implementation verified by + // integration tests. New server implementations retain their full native behavior. + if (selected instanceof IField field && root instanceof ICompilationUnit && + root.getResource() != null && NATIVE_PLAN_SUPPORTED) { plan.add(field, "field", declarations); if (accessors) { plan.add(GetterSetterUtil.getGetter(field), "getter", false); plan.add(GetterSetterUtil.getSetter(field), "setter", false); + addNativeBuilderTargets(handler, field, plan); addConfiguredAccessors(field, plan); } + } else if (replicated) { + // Non-fields and library/decompiled roots: the target ReferencesHandler.findReferences + // resolves (a class file without source through its decompiled working copy), widened + // as it widens a field. + boolean decompiled = preferences.isClientSupportsClassFileContent() && preferences.getPreferences().isIncludeDecompiledSources(); + targetRoot = JDTUtils.resolveTypeRoot(uri, decompiled, monitor); + IJavaElement target = targetRoot == null ? null : + JDTUtils.findElementAtSelection(targetRoot, line, column, preferences, monitor); + if (target == null && targetRoot != null) { + target = targetRoot.getElementAt(JsonRpcHelpers.toOffset(targetRoot.getBuffer(), line, column)); + } + plan.add(target, target instanceof IField ? "field" : "unknown", declarations); + if (accessors && target instanceof IField field) { + plan.add(GetterSetterUtil.getGetter(field), "getter", false); + plan.add(GetterSetterUtil.getSetter(field), "setter", false); + if (NATIVE_PLAN_SUPPORTED) addNativeBuilderTargets(handler, field, plan); + addConfiguredAccessors(field, plan); + } + } else { + // Unverified JDT LS builds. + for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown"); + if (selected instanceof IField field) { + plan.add(field, "field", declarations); + if (accessors) { + plan.add(GetterSetterUtil.getGetter(field), "getter", false); + plan.add(GetterSetterUtil.getSetter(field), "setter", false); + addConfiguredAccessors(field, plan); + } + } } - } - for (var target : plan.targets.values()) { - if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); - add(handler, target.element, target.kind(), target.declarations, results, monitor); + for (var target : plan.targets.values()) { + if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); + add(search, target.element, target.kind(), target.declarations, results, monitor); + } + } finally { + if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot); } if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); List> response = new ArrayList<>(); UsageAccess access = new UsageAccess(monitor, results); - results.forEach((location, kind) -> response.add(Map.of("location", location, "kind", kind, "access", access.classify(location, kind)))); + Map libraryLines = new java.util.HashMap<>(); + for (var entry : results.entrySet()) { + if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); + Location location = entry.getKey(); + Map row = new LinkedHashMap<>(); + row.put("location", location); + row.put("kind", entry.getValue()); + row.put("access", access.classify(location, entry.getValue())); + String text = libraryLine(location, libraryLines, monitor); + if (text != null) row.put("text", text); + response.add(row); + } return response; } + /** + * The line of a usage in a library class (`jdt:` URI), from the contents the client would + * request for it: so the client need not fetch thousands of class files to show the lines. + */ + private static String libraryLine(Location location, Map cache, IProgressMonitor monitor) { + String uri = location.getUri(); + if (uri == null || !uri.startsWith("jdt:")) return null; + if (!cache.containsKey(uri)) { + String contents = null; + try { + contents = JavaLanguageServerPlugin.getContentProviderManager().getContent(JDTUtils.toURI(uri), monitor); + } catch (RuntimeException e) { + if (e instanceof org.eclipse.core.runtime.OperationCanceledException) throw e; + } + cache.put(uri, contents == null ? null : contents.split("\\r\\n?|\\n", -1)); + } + String[] lines = cache.get(uri); + int line = location.getRange().getStart().getLine(); + return lines != null && line >= 0 && line < lines.length ? lines[line] : null; + } + /** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities. * Configuration-file-only naming is deliberately not guessed. */ @@ -191,11 +264,84 @@ public final class UsagesCommand implements IDelegateCommandHandler { (resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName()); } - private static void add(ReferencesHandler handler, IJavaElement element, String kind, boolean declarations, + private interface Search { + void run(IJavaElement element, List locations, IProgressMonitor monitor, boolean declarations) throws Exception; + } + + /** + * ReferencesHandler.search of the verified server, except that a class file whose decompiled + * source fails to parse loses only its own matches: there the server throws and fails the + * whole search, after searching most of the dependencies. `sources`: the workspace's source + * folders only, without dependency jars and the JDK. + */ + private static void search(IJavaElement element, List locations, IProgressMonitor monitor, + boolean declarations, boolean sources) throws CoreException { + SearchPattern pattern = SearchPattern.createPattern(element, IJavaSearchConstants.REFERENCES); + if (pattern == null) return; + if (declarations) { + SearchPattern declaration = SearchPattern.createPattern(element, IJavaSearchConstants.DECLARATIONS); + if (declaration != null) pattern = SearchPattern.createOrPattern(pattern, declaration); + } + var preferences = JavaLanguageServerPlugin.getPreferencesManager(); + SearchScope setting = preferences.getPreferences().getSearchScope(); + int mask = IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS; + if (!sources && setting != SearchScope.projectOnly) { + mask |= IJavaSearchScope.APPLICATION_LIBRARIES; + if (insideJre(element)) mask |= IJavaSearchScope.SYSTEM_LIBRARIES; + } + IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects(); + IJavaSearchScope scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects, mask); + boolean classFiles = preferences.isClientSupportsClassFileContent(); + boolean decompiled = preferences.getPreferences().isIncludeDecompiledSources(); + new SearchEngine().search(pattern, new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() }, scope, + new SearchRequestor() { + @Override + public void acceptSearchMatch(SearchMatch match) throws CoreException { + if (match.getAccuracy() == SearchMatch.A_INACCURATE || !(match.getElement() instanceof IJavaElement found)) return; + if (found.getAncestor(IJavaElement.COMPILATION_UNIT) instanceof ICompilationUnit unit) { + locations.add(JDTUtils.toLocation(unit, match.getOffset(), match.getLength())); + return; + } + if (!classFiles || !(found.getAncestor(IJavaElement.CLASS_FILE) instanceof IClassFile classFile)) return; + if (classFile.getSourceRange() != null) { + locations.add(JDTUtils.toLocation(classFile, match.getOffset(), match.getLength())); + return; + } + if (!decompiled) return; + try { + if (found instanceof IMember member) { + Location location = SearchUtils.searchOtherSources(member); + if (location != null) locations.add(location); + } + locations.addAll(JDTUtils.searchDecompiledSources(found, classFile, false, false, monitor)); + } catch (org.eclipse.core.runtime.OperationCanceledException e) { + throw e; + } catch (RuntimeException | CoreException e) { + JavaLanguageServerPlugin.logInfo("Usages in decompiled " + classFile.getElementName() + " skipped: " + e); + } + } + }, monitor); + } + + /** ReferencesHandler.isInsideJRE: a JDK element is searched in the JDK too. */ + private static boolean insideJre(IJavaElement element) { + if (!(element.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT) instanceof IPackageFragmentRoot root)) return true; + try { + IClasspathEntry entry = root.getRawClasspathEntry(); + if (entry.getEntryKind() != IClasspathEntry.CPE_CONTAINER) return false; + IClasspathContainer container = JavaCore.getClasspathContainer(entry.getPath(), root.getJavaProject()); + return container != null && container.getKind() == IClasspathContainer.K_DEFAULT_SYSTEM; + } catch (JavaModelException e) { + JavaLanguageServerPlugin.logException(e); + return true; + } + } + + private static void add(Search search, IJavaElement element, String kind, boolean declarations, Map results, IProgressMonitor monitor) throws Exception { if (element == null) return; List locations = new ArrayList<>(); - handler.search(element, locations, monitor, declarations); + search.run(element, locations, monitor, declarations); for (var location : locations) { if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; } String previous = results.get(location); diff --git a/java/java-usages/tests/configuration.py b/java/java-usages/tests/configuration.py index f218019fd..f1e09745b 100644 --- a/java/java-usages/tests/configuration.py +++ b/java/java-usages/tests/configuration.py @@ -159,11 +159,28 @@ try: send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}}) request('java/buildWorkspace', True) assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init + def warmup(action='start'): + return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]}) + # Queuing is cheap and idempotent; the index is built by a background job. + queued_at = time.perf_counter() + queued = warmup() + assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued + assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice' + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + warmed = warmup('status') + if not warmed['scheduled']: break + time.sleep(.1) + assert not warmed['scheduled'] and warmed['completed'], warmed + assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again' + prewarm_seconds = time.perf_counter() - queued_at results = {} for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']: start = time.perf_counter() result = query(key) results[key] = {'seconds': time.perf_counter() - start, 'response': result} + if key == 'app': + assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed) assert result['locations'], (key, result) attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']}) assert attached and 'LibrarySettings' in attached and 'port' in attached, attached @@ -238,7 +255,9 @@ try: send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}}) restored = query('app.nested.url') assert restored['locations'], restored - evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]} + cancelled = warmup('cancel') + assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled + evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]} (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) print(json.dumps(evidence, indent=2)) finally: diff --git a/java/java-usages/tests/implementations.py b/java/java-usages/tests/implementations.py new file mode 100644 index 000000000..ed39b519d --- /dev/null +++ b/java/java-usages/tests/implementations.py @@ -0,0 +1,198 @@ +"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home.""" +import json +import pathlib +import platform +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import urllib.parse + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-implementations-')) +config = work / 'config' +config.mkdir() +system, machine = platform.system(), platform.machine().lower() +config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux') +config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name] +config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file()) +(config / 'config.ini').write_bytes(config_source.read_bytes()) +project = work / 'project' +(project / 'src/zoo').mkdir(parents=True) +(project / '.project').write_text('zooorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them. +(project / '.classpath').write_text('') +sources = { + 'Repo.java': '''package zoo; + +public interface Repo { + void save(T item); + + default String name() { + return "repo"; + } +} +''', + 'SubRepo.java': '''package zoo; + +public interface SubRepo extends Repo { +} +''', + 'Base.java': '''package zoo; + +public abstract class Base implements Repo { + public abstract void save(Integer item); +} +''', + 'Numbers.java': '''package zoo; + +public class Numbers extends Base { + public void save(Integer item) { + } +} +''', + 'Words.java': '''package zoo; + +public class Words implements SubRepo { + public void save(String item) { + } + + public String name() { + return "words"; + } +} +''', + 'Task.java': '''package zoo; + +public class Task implements Runnable { + public void run() { + } +} +''', + 'Use.java': '''package zoo; + +public class Use { + void use(Repo repo, Runnable task) { + repo.save("x"); + task.run(); + } + + void alone() { + } +} +''', +} +for name, text in sources.items(): + (project / 'src/zoo' / name).write_text(text) +uri = lambda name: (project / 'src/zoo' / name).as_uri() +# Stands in for a Kotlin class of the project implementing the Java interface. +(work / 'kotlin-src/zoo').mkdir(parents=True) +(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo {\n public void save(Long item) {\n }\n}\n') +subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True) + +log = (work / 'stderr.log').open('w') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) +messages = queue.Queue() +lock = threading.Lock() + + +def send(message): + data = json.dumps(message).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() + + +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: + return + if line == b'\r\n': + break + k, v = line.decode().split(':', 1) + headers[k.lower()] = v.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None}) + else: + messages.put(message) + + +threading.Thread(target=read, daemon=True).start() +seq = 0 + + +def request(method, params): + global seq + seq += 1 + send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + msg = messages.get(timeout=max(1, deadline - time.monotonic())) + if msg.get('id') == seq: + if 'error' in msg: + raise RuntimeError(msg['error']) + return msg.get('result') + raise TimeoutError(method) + + +def position(name, needle): + lines = sources[name].splitlines() + line = next(i for i, t in enumerate(lines) if needle in t) + return line, lines[line].index(needle) + + +def command(name, *args): + return request('workspace/executeCommand', {'command': name, 'arguments': list(args)}) + + + + +def implementations(file, needle): + line, col = position(file, needle) + found = command('openidea.java.implementations', uri(file), line, col) + names = [] + for loc in found['locations']: + name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name + names.append(f"{name}:{loc['range']['start']['line']}") + return sorted(names), found['complete'] + + +try: + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}}) + assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['commands'] + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + for name, text in sources.items(): + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}}) + request('java/buildWorkspace', True) + # (implementations in the sources, whether they are all there are) + cases = { + # Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface. + ('Repo.java', 'Repo'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True), + # Through the type arguments; not the abstract re-declaration. + ('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True), + ('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True), + ('Repo.java', 'name()'): (['Words.java:6'], True), + # A library type's implementations in the sources; the library has more. + ('Use.java', 'Runnable task'): (['Task.java:2'], False), + ('Use.java', 'run();'): (['Task.java:3'], False), + ('Use.java', 'alone()'): ([], True), + } + failures = [] + for (file, needle), expected in cases.items(): + actual = implementations(file, needle) + print(f'{file} {needle!r}: {actual}') + if actual != expected: + failures.append((file, needle, expected, actual)) + assert not failures, failures + print('ok') +finally: + proc.kill() + shutil.rmtree(work, ignore_errors=True) diff --git a/java/java-usages/tests/verify.py b/java/java-usages/tests/verify.py index 825c31b81..9a4188126 100644 --- a/java/java-usages/tests/verify.py +++ b/java/java-usages/tests/verify.py @@ -106,6 +106,14 @@ def request(method, params): if 'error' in msg: raise RuntimeError(msg['error']) return msg.get('result') raise TimeoutError(method) +def assert_sources(line, col, full=None): + """The "sources" scope finds what the full search finds in the workspace's sources.""" + usages = lambda *scope: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False,*scope]}) + full = usages() if full is None else full + sources = usages('sources') + key = lambda rows: sorted(json.dumps(r, sort_keys=True) for r in rows) + assert key(sources) == key(r for r in full if r['location']['uri'].startswith('file:')), (line, col, sources, full) + return sources try: init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, 'import':{'gradle':{'enabled':False}, 'maven':{'enabled':False}}}}}}) send({'jsonrpc':'2.0','method':'initialized','params':{}}) @@ -123,6 +131,11 @@ try: assert kinds.count('getter') == expected[0], (field, kinds) assert kinds.count('setter') == expected[1], (field, kinds) assert kinds.count('field') == expected[2], (field, kinds) + assert_sources(line, col, after) + # A JDK method: in the sources, the same as the full search's sources part. + line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'println' in t) + evidence['jdkSources'] = assert_sources(line, text.index('println')) + assert evidence['jdkSources'], 'no JDK method usages in the sources' # Respect the existing global preference: the command must not switch it on. send({'jsonrpc':'2.0','method':'workspace/didChangeConfiguration','params':{'settings':{'java':{'references':{'includeAccessors':False}}}}}) disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]}) diff --git a/java/lombok/README.md b/java/lombok/README.md index 4959509c0..844091c8f 100644 --- a/java/lombok/README.md +++ b/java/lombok/README.md @@ -1,6 +1,6 @@ # Lombok agent for JDT LS -`lombok-1.18.48-openidea.2.jar` is the app-provided Java agent for JDT LS. It includes: +`lombok-1.18.48-openidea.3.jar` is the app-provided Java agent for JDT LS. It includes: - [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores `lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`. @@ -10,6 +10,11 @@ that call in the `@SneakyThrows` try block (ECJ then fails with "Internal Error compiling … BlockScope cannot be cast to MethodScope" and the whole workspace build fails) and put `@NonNull` checks before it; constructors Lombok generates lacked the statement. +- [`patches/builder-compact-constructor.patch`](patches/builder-compact-constructor.patch), not yet + proposed upstream. `@Builder` on a compact record constructor (`record R(...) { @Builder R {} }`) + failed under ECJ ("The method builder() is undefined", missing builder setters), because ECJ has + not filled in a compact constructor's implicit parameters when Lombok runs. The builder now takes + its parameters from the record components in that case; javac was unaffected. The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download. Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection @@ -19,7 +24,7 @@ The filename includes the patch version because JDT LS workspace keys include th changing agents must rebuild classes produced by the old agent. Old jars remain for running windows. Replace this temporary bundle with an official Maven Central release once that release includes -#4059 and #4089. Restore the registry's Maven Central install method and remove the embedded jar. Keep the +#4059, #4089 and the compact-constructor builder fix. Restore the registry's Maven Central install method and remove the embedded jar. Keep the workspace migration test when changing its path. ## Provenance and build @@ -33,10 +38,12 @@ workspace migration test when changing its path. - PR #4089 (`Rawi01/lombok` `eclipse-constructor-call-statement`): - `7832faa49b35c839088866daf865384c82cd555f` - `3f3a80b857a969839ab1baba12767aec4965ac32` -- Upstream version: `1.18.48`; `openidea.2` identifies this distribution of the patched agent - (`openidea.1` had #4059 only). +- Then `git apply` [`patches/builder-compact-constructor.patch`](patches/builder-compact-constructor.patch) + (changes `src/core/lombok/eclipse/handlers/HandleBuilder.java` only). +- Upstream version: `1.18.48`; `openidea.3` identifies this distribution of the patched agent + (`openidea.1` had #4059 only; `openidea.2` added #4089). Upstream's own `java -jar ... --version` still reports `1.18.48`. -- SHA-256: `2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379` +- SHA-256: `26069a8138680f5e1100c83cd064d6aa7314385a1afcef74269cdcc41beb0442` - License: [MIT, including bundled component notices](LICENSE), also retained inside the jar. - Build environment: macOS arm64, Temurin 25.0.5, Apache Ant 1.10.15, upstream Ivy setup (`openidea.1` was built with OpenJDK 23.0.1). @@ -51,8 +58,9 @@ git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring git fetch https://github.com/Rawi01/lombok.git eclipse-constructor-call-statement git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b git cherry-pick 7832faa49b35c839088866daf865384c82cd555f 3f3a80b857a969839ab1baba12767aec4965ac32 +git apply /path/to/openidea/java/lombok/patches/builder-compact-constructor.patch ant dist -cp dist/lombok.jar /path/to/openidea/java/lombok/lombok-1.18.48-openidea.2.jar +cp dist/lombok.jar /path/to/openidea/java/lombok/lombok-1.18.48-openidea.3.jar ``` The changelog context conflicts because the first commit adds a release-note line under the @@ -62,13 +70,15 @@ skipped). The #4089 commits apply cleanly. The final source diff from v1.18.48 c `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`, `test/core/src/lombok/launch/PatchFixesHiderTest.java` (#4059) and the Eclipse handlers `EclipseHandlerUtil`, `HandleConstructor`, `HandleFieldNameConstants`, `HandleNonNull`, -`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089); -no other Java source changes were made. -In the build checkout, the two remote resolvers in -`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the -local resolver and all dependency coordinates were retained. The JVM was configured to use the -local HTTP/HTTPS proxy for downloads. Ant's timestamps mean a rebuild need not be byte-identical; -`SHA256SUMS` records the checked-in artifact and is verified by the Rust regression tests. +`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089) +and `HandleBuilder` (the compact-constructor patch); no other Java source changes were made. +`openidea.3` was built with the unmodified upstream build scripts. (`openidea.2`'s build checkout +reordered the two remote resolvers in `buildScripts/ivysettings.xml` to try Maven Central before +Eclipse staging and used a local HTTP/HTTPS proxy for downloads.) Compared with `openidea.2`, the +only changed class is `HandleBuilder`; other differences are build timestamps in the generated +`META-INF/services` files and the nested `mavenEcjBootstrapAgent.jar`. Ant's timestamps mean a +rebuild need not be byte-identical; `SHA256SUMS` records the checked-in artifact and is verified by +the Rust regression tests. ## Validation @@ -98,6 +108,17 @@ On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and projec Failed with three internal errors and wrote no classes; `openidea.2` Succeeded with all classes. - `java/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.2`. +`openidea.3` (compact-constructor builders) on macOS arm64, JDT LS ECJ +`compiler.batch_3.46.200.v20261008-1115`, Temurin 25: + +- [`tests/verify.py`](tests/verify.py) compiles and runs + [`tests/BuilderCompactConstructor.java`](tests/BuilderCompactConstructor.java) with javac and with + ECJ plus the agent: builders on compact constructors (with validation, `toBuilder`, `setterPrefix`, + generics, `@Singular`, `@NonNull`, empty records), a long-form canonical constructor, a + non-canonical record constructor, type-level record builders, and the #4089 constructor cases. + Both passed with `openidea.3`; with `openidea.2`, ECJ failed on every compact-constructor builder. +- `java/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.3`. + Windows and Linux runtime validation has not been performed. Rust tests cover source selection for both supported architectures on all three OS variants, offline provisioning, corruption repair, concurrent windows, explicit user overrides, and JDT LS workspace migration. diff --git a/java/lombok/SHA256SUMS b/java/lombok/SHA256SUMS index 462614044..3a1d69d59 100644 --- a/java/lombok/SHA256SUMS +++ b/java/lombok/SHA256SUMS @@ -1 +1 @@ -2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379 lombok-1.18.48-openidea.2.jar +26069a8138680f5e1100c83cd064d6aa7314385a1afcef74269cdcc41beb0442 lombok-1.18.48-openidea.3.jar diff --git a/java/lombok/lombok-1.18.48-openidea.2.jar b/java/lombok/lombok-1.18.48-openidea.3.jar similarity index 92% rename from java/lombok/lombok-1.18.48-openidea.2.jar rename to java/lombok/lombok-1.18.48-openidea.3.jar index b4e1036c9..219d59e65 100644 Binary files a/java/lombok/lombok-1.18.48-openidea.2.jar and b/java/lombok/lombok-1.18.48-openidea.3.jar differ diff --git a/java/lombok/patches/builder-compact-constructor.patch b/java/lombok/patches/builder-compact-constructor.patch new file mode 100644 index 000000000..3e38fee71 --- /dev/null +++ b/java/lombok/patches/builder-compact-constructor.patch @@ -0,0 +1,21 @@ +diff --git a/src/core/lombok/eclipse/handlers/HandleBuilder.java b/src/core/lombok/eclipse/handlers/HandleBuilder.java +index 64a3fa9..6ebaaaf 100755 +--- a/src/core/lombok/eclipse/handlers/HandleBuilder.java ++++ b/src/core/lombok/eclipse/handlers/HandleBuilder.java +@@ -493,10 +493,13 @@ public class HandleBuilder extends EclipseAnnotationHandler { + if (!checkBuilderClassNameAnnotationConflict("Builder", "lombok.Builder", job.builderClassName, ast, annotationNode)) return; + + if (fillParametersFrom != null) { +- for (EclipseNode param : fillParametersFrom.down()) { +- if (param.getKind() != Kind.ARGUMENT) continue; ++ // ECJ has not populated the implicit arguments of a compact record constructor yet. ++ boolean compactConstructor = parent.get() instanceof ConstructorDeclaration ++ && isRecord(job.parentType) && (parent.get().bits & IsCanonicalConstructor) != 0; ++ for (EclipseNode param : (compactConstructor ? job.parentType : fillParametersFrom).down()) { ++ if (compactConstructor ? !isRecordField(param) : param.getKind() != Kind.ARGUMENT) continue; + BuilderFieldData bfd = new BuilderFieldData(); +- Argument arg = (Argument) param.get(); ++ AbstractVariableDeclaration arg = (AbstractVariableDeclaration) param.get(); + + Annotation[] copyableAnnotations = findCopyableAnnotations(param); + diff --git a/java/lombok/tests/BuilderCompactConstructor.java b/java/lombok/tests/BuilderCompactConstructor.java new file mode 100644 index 000000000..a4aec1ed1 --- /dev/null +++ b/java/lombok/tests/BuilderCompactConstructor.java @@ -0,0 +1,139 @@ +import java.math.BigDecimal; +import java.util.List; +import lombok.Builder; +import lombok.NonNull; +import lombok.Singular; +import lombok.SneakyThrows; +import lombok.RequiredArgsConstructor; + +public class BuilderCompactConstructor { + @Builder(toBuilder = true) + record Payment(String number, String phone, BigDecimal amount, + BigDecimal serviceCharge, BigDecimal total, Status status) { + @Builder + Payment { + if (serviceCharge == null) serviceCharge = BigDecimal.ZERO; + } + } + + enum Status { PENDING, SUCCESS, EXPIRED } + + record ConstructorOnly(String name, int count) { + @Builder(toBuilder = true, setterPrefix = "with") + ConstructorOnly { + if (count < 0) throw new IllegalArgumentException("count"); + } + } + + @Builder(toBuilder = true) + record TypeOnly(int count) { + TypeOnly { count++; } + } + + record Generic(@NonNull T value, @Singular List tags) { + static final String IGNORED = "not a component"; + + @Builder(toBuilder = true) + Generic {} + } + + record Empty() { + @Builder + Empty {} + } + + record Canonical(String name, int count) { + @Builder + Canonical(String name, int count) { + this.name = name.trim(); + this.count = count; + } + } + + record Explicit(String name, int count) { + @Builder + Explicit(String name) { this(name, 7); } + } + + static class Ordinary { + final String name; + + @Builder + @SneakyThrows + Ordinary(String name) { this.name = name; } + } + + static class Parent { + Parent(String name) {} + } + + static class ConstructorCalls extends Parent { + final String name; + + @SneakyThrows + ConstructorCalls() { + this("default"); + assert name.equals("default"); + } + + @SneakyThrows + ConstructorCalls(@NonNull String name) { + super(name); + this.name = name; + } + } + + @RequiredArgsConstructor + static class GeneratedConstructor { + final int initialized = 7; + @NonNull final String name; + } + + @Builder(builderMethodName = "methodBuilder", builderClassName = "MethodBuilder") + static String named(String name) { return name; } + + public static void main(String[] args) { + var payment = Payment.builder().number("inquiry").phone("123") + .amount(BigDecimal.TEN).total(BigDecimal.TEN).status(Status.PENDING).build(); + assert payment.number().equals("inquiry"); + assert payment.phone().equals("123"); + assert payment.amount().equals(BigDecimal.TEN); + assert payment.serviceCharge().equals(BigDecimal.ZERO); + assert payment.total().equals(BigDecimal.TEN); + assert payment.status() == Status.PENDING; + assert payment.toBuilder().phone("456").build().phone().equals("456"); + assert Payment.builder().serviceCharge(BigDecimal.ONE).build().serviceCharge().equals(BigDecimal.ONE); + + var constructor = ConstructorOnly.builder().withName("test").withCount(2).build(); + assert constructor.toBuilder().withCount(3).build().count() == 3; + assert constructor.name().equals("test"); + try { + ConstructorOnly.builder().withCount(-1).build(); + throw new AssertionError("compact constructor validation was bypassed"); + } catch (IllegalArgumentException expected) {} + + assert TypeOnly.builder().count(2).build().count() == 3; + assert Empty.builder().build().equals(new Empty()); + var generic = Generic.builder().value("value").tag("one").build(); + assert generic.value().equals("value"); + assert generic.tags().equals(List.of("one")); + assert generic.toBuilder().tag("two").build().tags().equals(List.of("one", "two")); + try { + Generic.builder().build(); + throw new AssertionError("non-null check was bypassed"); + } catch (NullPointerException expected) {} + + assert Canonical.builder().name(" test ").count(2).build().equals(new Canonical("test", 2)); + assert Explicit.builder().name("test").build().equals(new Explicit("test", 7)); + assert Ordinary.builder().name("test").build().name.equals("test"); + assert methodBuilder().name("test").build().equals("test"); + assert new ConstructorCalls().name.equals("default"); + assert new ConstructorCalls("explicit").name.equals("explicit"); + assert new GeneratedConstructor("generated").initialized == 7; + assert new GeneratedConstructor("generated").name.equals("generated"); + try { + new ConstructorCalls(null); + throw new AssertionError("constructor non-null check was bypassed"); + } catch (NullPointerException expected) {} + } +} diff --git a/java/lombok/tests/verify.py b/java/lombok/tests/verify.py new file mode 100644 index 000000000..e164f4ce4 --- /dev/null +++ b/java/lombok/tests/verify.py @@ -0,0 +1,33 @@ +"""Compile and run builder regressions with javac and the compiler shipped by JDT LS. + +Usage: python3 java/lombok/tests/verify.py /path/to/jdtls /path/to/lombok.jar +Requires a JDK 21+ on PATH. All generated files stay in a temporary directory. +""" + +import os +import pathlib +import subprocess +import sys +import tempfile + + +def main(): + home, lombok = (pathlib.Path(p).resolve() for p in sys.argv[1:3]) + ecj, = (home / "plugins").glob("org.eclipse.jdt.core.compiler.batch_*.jar") + source = pathlib.Path(__file__).with_name("BuilderCompactConstructor.java") + with tempfile.TemporaryDirectory(prefix="openidea-lombok-builders-") as tmp: + for compiler in ("javac", "ecj"): + output = pathlib.Path(tmp) / compiler + output.mkdir() + if compiler == "javac": + command = ["javac", "--release", "21", "-processorpath", str(lombok)] + else: + command = ["java", f"-javaagent:{lombok}", "-jar", str(ecj), "-21"] + subprocess.run(command + ["-cp", str(lombok), "-d", str(output), str(source)], check=True) + subprocess.run(["java", "-ea", "-cp", os.pathsep.join((str(output), str(lombok))), + "BuilderCompactConstructor"], check=True) + print(f"{compiler}: builder regression assertions passed", flush=True) + + +if __name__ == "__main__": + main() diff --git a/knip.ts b/knip.ts index 572c234ad..9e9e6deca 100644 --- a/knip.ts +++ b/knip.ts @@ -1,11 +1,14 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { - // Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins. + // Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins; + // standalone browser fixtures are loaded by their own HTML pages. + entry: ["e2e/*.fixture.{ts,tsx}"], project: [ "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", "!src/editor/core/cm/jsp/parser.terms.ts", + "!src/editor/core/cm/astro/parser.terms.ts", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts", diff --git a/languages/astro.toml b/languages/astro.toml new file mode 100644 index 000000000..77be8d99a --- /dev/null +++ b/languages/astro.toml @@ -0,0 +1,27 @@ +id = "astro" +name = "Astro" +extensions = ["astro"] +block-comment = [""] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "Astro" + +[[language-servers]] +name = "astro-ls" +command = "astro-ls" +args = ["--stdio"] +root-markers = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts", "astro.config.cjs", "astro.config.cts"] +# astro-ls ships no TypeScript and refuses to start without `tsdk`. Servers start +# in their workspace root, so this relative path is the project's own TypeScript +# (6 or older: 7 has no tsserverlibrary.js). A TypeScript elsewhere, such as an +# npm workspace root, goes in Settings › Language Servers › astro-ls › +# Initialization options as an absolute `typescript.tsdk`. +initialization-options = { typescript = { tsdk = "node_modules/typescript/lib" } } +install-hint = "npm i -g @astrojs/language-server, and add typescript@6 to the project" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/csharp.toml b/languages/csharp.toml index ef8d6bd4a..35e2b516c 100644 --- a/languages/csharp.toml +++ b/languages/csharp.toml @@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls" adapter = "netcoredbg" command = "netcoredbg" args = ["--interpreter=vscode"] -install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)" +install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/fsharp.toml b/languages/fsharp.toml index cf989e421..d9c80d997 100644 --- a/languages/fsharp.toml +++ b/languages/fsharp.toml @@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true } adapter = "netcoredbg" command = "netcoredbg" args = ["--interpreter=vscode"] -install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)" +install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/java.toml b/languages/java.toml index af9967e9c..e7997db1a 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -58,3 +58,10 @@ generatesMetadataFilesAtProjectRoot = false exclusions = ["**/node_modules/**", "**/.metadata/**", "**/archetype-resources/**", "**/META-INF/maven/**"] maven = { enabled = true } gradle = { enabled = true, wrapper = { enabled = true } } + +[[language-servers]] +name = "kmp-lsp" +fallback = true +command = "kmp-lsp" +root-markers = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "build.xml", ".project"] +install-hint = "Install kmp-lsp in Settings › Languages & Frameworks › Language Servers (https://github.com/Hessesian/kmp-lsp)" diff --git a/languages/javascript.toml b/languages/javascript.toml index 70954b50a..1830bf6cc 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -59,4 +59,4 @@ adapter = "js-debug" command = "js-debug-adapter" args = ["${port}", "127.0.0.1"] transport = "tcp" -install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`" +install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index cdab8c31f..a19a5e850 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -58,4 +58,4 @@ adapter = "js-debug" command = "js-debug-adapter" args = ["${port}", "127.0.0.1"] transport = "tcp" -install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`" +install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/kotlin.toml b/languages/kotlin.toml index 097eafcd3..30820ea4f 100644 --- a/languages/kotlin.toml +++ b/languages/kotlin.toml @@ -32,4 +32,4 @@ install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kot [debugger] adapter = "kotlin-debug-adapter" command = "kotlin-debug-adapter" -install-hint = "Install https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH" +install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/log.toml b/languages/log.toml index 5c2f58821..9fa0481e8 100644 --- a/languages/log.toml +++ b/languages/log.toml @@ -2,3 +2,4 @@ id = "log" name = "Log" extensions = ["log"] indent = { tab-size = 4, insert-spaces = true } +ui-mode = "Log" diff --git a/languages/php.toml b/languages/php.toml index b0fc0f4e3..e3dcf927d 100644 --- a/languages/php.toml +++ b/languages/php.toml @@ -29,4 +29,4 @@ install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usag [debugger] adapter = "php-debug" command = "php-debug-adapter" -install-hint = "Install vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug" +install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug" diff --git a/languages/python.toml b/languages/python.toml index a9b4ea922..b3f331a44 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -1,5 +1,6 @@ id = "python" name = "Python" +formatter = { tool = "ruff", parser = "python", range = true } extensions = ["py", "pyi", "pyw"] filenames = ["SConstruct", "SConscript"] first-line = "^#!.*\\bpython[0-9.]*\\b" diff --git a/languages/rust.toml b/languages/rust.toml index fcd712e3e..766651082 100644 --- a/languages/rust.toml +++ b/languages/rust.toml @@ -23,4 +23,4 @@ adapter = "codelldb" command = "codelldb" args = ["--port", "${port}"] transport = "tcp" -install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH" +install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/shellscript.toml b/languages/shellscript.toml index ebda1d965..718f03d63 100644 --- a/languages/shellscript.toml +++ b/languages/shellscript.toml @@ -17,4 +17,4 @@ install-hint = "npm i -g bash-language-server (optionally install shellcheck and [debugger] adapter = "bash-debug" command = "bash-debug-adapter" -install-hint = "Install https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)" +install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/typescript.toml b/languages/typescript.toml index 951c09223..33bed030f 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -69,4 +69,4 @@ adapter = "js-debug" command = "js-debug-adapter" args = ["${port}", "127.0.0.1"] transport = "tcp" -install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`" +install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 3d4b1377c..ceb0171df 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -58,4 +58,4 @@ adapter = "js-debug" command = "js-debug-adapter" args = ["${port}", "127.0.0.1"] transport = "tcp" -install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`" +install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers" diff --git a/languages/vue.toml b/languages/vue.toml index e47d985a8..d55e99563 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -18,6 +18,7 @@ install-hint = "npm i -g @vue/language-server typescript@6" # reaches it through `tsserver/request`, which the client forwards to vtsls. [[language-servers]] name = "vtsls" +tsserver-project = true command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] diff --git a/languages/zig.toml b/languages/zig.toml index b1fe5144f..667cb2b1a 100644 --- a/languages/zig.toml +++ b/languages/zig.toml @@ -16,4 +16,4 @@ adapter = "codelldb" command = "codelldb" args = ["--port", "${port}"] transport = "tcp" -install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)" +install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers" diff --git a/package.json b/package.json index 538d9de5f..1fb159a4a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.37", + "version": "0.1.38", "license": "Apache-2.0", "type": "module", "scripts": { @@ -22,13 +22,15 @@ "gen:windows-icons": "node scripts/gen-windows-icons.ts", "gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts", "gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts", + "gen:astro-grammar": "lezer-generator src/editor/core/cm/astro/astro.grammar --typeScript -o src/editor/core/cm/astro/parser.ts", "gen:theme-docs": "node scripts/theme/gen-docs.ts", "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json", "dev:status": "node scripts/dev-status.ts", "e2e": "playwright test", "perf": "node perf/run.ts", - "perf:rust": "node perf/rust.ts" + "perf:rust": "node perf/rust.ts", + "release:prepare": "node scripts/release-prepare.ts" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/plugins/README.md b/plugins/README.md index d2f1ab09d..e0637f12b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,10 +34,10 @@ plugins/ ├─ regex-tester/ # html view talking to its code, settings, the editor's selection └─ license-header/ # file-opened events, quick fix, project scan in a task sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host) -crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave +crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens ``` -The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world +The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.3.0`, world `plugin`). ## 1. Quick start @@ -94,6 +94,8 @@ homepage = "https://example.com" # optional license = "MIT" # optional change-notes = "1.2.0 — …" # shown under What's New categories = ["tools"] # marketplace categories, see below +icon = "icon.svg" # optional, see below +icon-dark = "icon_dark.svg" # optional engines.openidea = "^1.0" # the plugin API this package needs wasm = "dist/plugin.wasm" # omit for data-only plugins activation = ["on-language:markdown"] # optional, see below @@ -116,6 +118,14 @@ icon themes or languages is filed under those, any other under `other`. Categories an IDE doesn't know, and any after the first three it knows, are ignored (`check` warns about both). +`icon` is an SVG of the package shown in Settings › Plugins and the +marketplace; `icon-dark`, if given, replaces it with dark themes. Each is at +most 16 KiB and sanitised like file icons (no scripts, external references or +`foreignObject`); an invalid one fails the package. Without `icon`, the +plugin gets the generic plugin icon. `icon` is new in API 1.2: IDEs before it +refuse the manifest, so a package with one declares `engines.openidea = "^1.2"` +(`check` and installing refuse it otherwise). + All paths are relative to the package directory and may not leave it. A manifest is validated as a whole: if one contribution is invalid, nothing is installed or loaded. Contribution types the IDE does not know (a newer API) are @@ -127,23 +137,42 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK. ### Contribution points -| Point | Declaration | Code needed | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `commands` | `id`, `title`, `category?`, `when?` | to run them | -| `themes` | `path` (theme JSON), `editor-scheme?` | no | -| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | -| `languages` | `path` (`languages/.toml` format, see §6) | no | -| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | -| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | -| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | -| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no | -| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | -| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | -| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | -| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` | -| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | -| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | -| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | +| Point | Declaration | Code needed | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `commands` | `id`, `title`, `category?`, `when?` | to run them | +| `themes` | `path` (theme JSON), `editor-scheme?` | no | +| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | +| `languages` | `path` (`languages/.toml` format, see §6) | no | +| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | +| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | +| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | +| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no | +| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | +| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | +| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | +| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | +| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | +| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | + +A `viewItem.context` menu needs `view`, the id of one of the plugin's own +views. It adds the commands to the right-click menu of the items in that +view's `tree` or `list`, and each command gets the item's key as its argument. +`when` clauses can use `viewItem` (the key) and `viewItemKind` (the key up to +its first `:`, so `pod` for `pod:default/nginx`; the whole key when it has no +`:`). Only the menu's `when` sees them, not the command's own `when`; a command +whose `when` is false shows greyed out. Other menus refuse `view`, a web view +has no item menu, and no menu opens when no declaration matches the item. +The menu is new in API 1.2, so a plugin using it declares +`engines.openidea = "^1.2"`. For example: + +```toml +[[contributes.menus]] +menu = "viewItem.context" +view = "pods" +command = "kube.logs" +when = "viewItemKind == pod" +``` Declared contributions are live as soon as the plugin is enabled — commands show in Find Action, menus and the status bar, shortcuts work, themes and icon @@ -152,6 +181,24 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's declared commands. A plugin shortcut never takes a keystroke the IDE (or the user) already uses; the user can rebind it in the keymap editor. +A `when` clause (on commands and menus) can test whether the open project +contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`. +The path is exact (no globs) and relative to the project root; in a workspace the +key is true when any of its projects contains the path. It updates when +files change. An IDE that doesn't know the key treats it as +false: the plugin still installs and the key reads as false there. +Plugins can set their own keys, `plugin..`, with `host::set_context`: +a key set from a command or event applies to that window, one set outside any +applies to every window, and a window's value wins over the global one. Keys are +kept across a window reload and idle reclaim, and dropped when the plugin stops. + +### Background associations + +`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional +`document-indexer` export. It publishes cross-file associations (definitions, references, +aliases and gutter markers) in background batches, not on the editing path. See +[Background document indexers](../docs/plugins/indexing.md). + ### Activation Code is instantiated lazily: when one of the plugin's commands runs, or when @@ -306,22 +353,27 @@ then run `openidea-plugin check`. | `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. | | `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. | | `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. | +| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin..` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. | | `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | | `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. | | `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. | +| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. | +| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | +| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | | `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | | `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | | `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | | `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | | `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | +| `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | | `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | | `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:` in the project root: output that keeps coming, interactive sessions. | | `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | | `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | `run-command` receives an optional string argument: `executeAction("plugin..", "arg")` -from another feature, or nothing when run from Search Everywhere / a shortcut. +from another feature, or nothing when run from Search Everywhere / a shortcut. `ctx.selected_paths()` lists the files and folders a Project view or editor tab menu ran the command on (the argument is the first of them); it is empty otherwise. No capability is needed. ### Events @@ -350,7 +402,7 @@ declaration matches the file's language are called (and loaded): ```toml [[contributes.providers]] -kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions" +kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens" languages = ["java", "xml"] # editor language ids; "*" for any ``` @@ -380,6 +432,31 @@ dropped at 50 ms like a slow one. Inline completion is exempt from the 50 ms (it need the network) but is cancelled as soon as the user types on: check `Task::is_cancelled` or just let the host interrupt the call. +#### Semantic highlighting + +The `semantic-tokens` kind (API 1.3: `engines.openidea = "^1.3"`) colours +tokens in documents of the declared languages, including languages the plugin +doesn't contribute, such as `php`. Register with +`register_plugin!(MyPlugin, semantic_tokens)` (any combination works: +`(MyPlugin, providers, views, semantic_tokens)`) and implement +`openidea_plugin::semantic_tokens::SemanticTokens`: + +```rust +impl SemanticTokens for MyPlugin { + fn semantic_tokens(&mut self, doc: &Document) -> Vec { + let (start, end) = (text::position(&doc.text, 10), text::position(&doc.text, 14)); + vec![SemanticToken::new(Range { start, end }, TokenKind::Method)] + } +} +``` + +A token covers part of one line and carries an LSP token kind (`TokenKind`: +`method`, `property`, `type`, …) and modifiers (`TokenModifier`: `static`, +`deprecated`, …); the WIT takes them as kebab-case strings. The tokens join +the language server's semantic tokens and win where they overlap one, so the +editor's colour scheme styles them like the server's. They are asked again +after each edit, under the edit-path budget; at most 65,536 per document. + ### Network and secrets `host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host @@ -466,6 +543,50 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and `openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only ever added. +#### Services views + +`location = "services"` (API 1.3: `engines.openidea = "^1.3"`) puts a view's +tree in the Services tool window, next to Docker and the run configurations, +instead of a tool window of its own. Its code renders it (no `web`): + +```toml +[[contributes.views]] +id = "kube" +title = "Kubernetes" +icon = "icons/kubernetes.svg" +location = "services" + +[capabilities] +optional = ["process:kubectl"] +``` + +The items of the view's `tree` are Services nodes: `render`, `handle` and the +toolbar work as in a tool window, the toolbar's buttons acting on the one +selected node, and `viewItem.context` menus give the nodes a right-click menu. +The pane beside the tree comes from the `service-provider` export: register +`register_plugin!(MyPlugin, views, services)` and implement +`openidea_plugin::services::Services`, which returns the selected node's tabs +(at most 8; asked again after `host::refresh_view("kube")`): + +```rust +impl Services for MyPlugin { + fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result, Error> { + let pod = key.trim_start_matches("pod:"); + let log = ProcessLog::new("kubectl", &["logs", "-f", "--tail=1000", pod]) + .toggle("ts", "Timestamps", &["--timestamps"]); + Ok(vec![Tab::new("log", "Log", log), Tab::new("inspect", "Inspect", Pane::Text(self.describe(pod)?))]) + } +} +``` + +The IDE switches tabs without calling the plugin. A tab is a `Pane::Message`, +read-only `Pane::Text` (at most 1 MiB) or a `ProcessLog`: the IDE asks for the +pane again and runs that program (it needs the plugin's `process:` +grant) in the project root, following its output in the log Docker containers +use (ANSI stripped, the last 5000 lines and 16 KiB per line, Soft-wrap, Clear, +the exit code at the end). A toggle appends its `args` and starts the program +again. It stops when the node's pane closes or the Services window is hidden. + #### Web views A view or custom editor can instead be a web page of the package, for UIs the @@ -920,8 +1041,8 @@ the API itself follows _Evolving the plugin API_ below. ## 10. Evolving the plugin API -Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36 and 1.1.0 -with 0.1.37. wasmtime decides what stays compatible: it resolves interface names +Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0 +with 0.1.37 and 1.2.0 with 0.1.38. wasmtime decides what stays compatible: it resolves interface names by semver (a component importing `openidea:plugin/x@1.0.0` links against a host defining `@1.1.0`, and the host finds its exports the same way), but function types must match exactly — a record with another field count, or a @@ -964,9 +1085,10 @@ Checked automatically: released version `wit/` must match it exactly, so additions start with a version bump. Each snapshot passed this check against the one before, so one snapshot covers every release. -- Releasing: when the `wit/` version changes, copy `wit/*.wit` over - `tests/api/released/` in the release commit. The release workflow refuses a - tag whose `wit/` version differs from the snapshot's. +- Releasing: `pnpm release:prepare ` makes the release commit's + changes; when the `wit/` version changed, it copies `wit/*.wit` over + `tests/api/released/`. The release workflow refuses a tag whose `wit/` + version differs from the snapshot's. - `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy matches, the version check (rules 4, 5); a simulated next-minor host (every import stubbed at the newer version, plus extra functions and an extra diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index dd6b1b115..c91fb19f8 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 5cd3a6b0d..10714c313 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d +eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 0c5b399d7..49d583963 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index d07b95aa6..346ce1ca0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250 +0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 2b7172c52..c32998eac 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 25087c64f..129770a9f 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b +564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index c00455582..d12bd47c9 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index b2bdee8e8..630ba0c84 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d +f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index d74e615fe..0806128d3 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 5c61c4c01..5f9e986af 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114 +85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af diff --git a/plugins/official/harbor-night/themes/harbor-night.scheme.json b/plugins/official/harbor-night/themes/harbor-night.scheme.json index 41bd29668..ee267f13d 100644 --- a/plugins/official/harbor-night/themes/harbor-night.scheme.json +++ b/plugins/official/harbor-night/themes/harbor-night.scheme.json @@ -22,6 +22,20 @@ "--syn-param": "#d9dfe8", "--syn-property": "#b9a3f0", "--syn-constant": "#e58fa3", + "--syn-python-local-variable": "#d9dfe8", + "--syn-python-global-variable": "#d9dfe8", + "--syn-python-instance-attribute": "#d9dfe8", + "--syn-python-class-attribute": "#b9a3f0", + "--syn-python-constant": "#e58fa3", + "--syn-python-property": "#b9a3f0", + "--syn-python-type-parameter": "#8fd4c9", + "--syn-python-builtin": "#6fc3df", + "--syn-python-predefined-definition": "#c4a0f2", + "--syn-python-predefined-usage": "#c4a0f2", + "--syn-python-fstring": "#9ccc7a", + "--syn-python-bytes": "#9ccc7a", + "--syn-php-variable": "#b9a3f0", + "--syn-php-function-call": "#f2d07a", "--syn-builtin": "#6fc3df", "--syn-self": "#d6849c", "--syn-keyword-arg": "#c4a0f2", diff --git a/plugins/official/harbor-night/themes/harbor-night.theme.json b/plugins/official/harbor-night/themes/harbor-night.theme.json index a8f90cf3a..ed7d0886a 100644 --- a/plugins/official/harbor-night/themes/harbor-night.theme.json +++ b/plugins/official/harbor-night/themes/harbor-night.theme.json @@ -67,6 +67,10 @@ "--diff-modified": "#2f4f63", "--diff-inserted": "#294436", "--diff-deleted": "#43494f", + "--diff-modified-stripe": "#3a6380", + "--diff-inserted-stripe": "#447152", + "--diff-deleted-stripe": "#5f676f", + "--diff-conflict-stripe": "#8f5247", // Shadows and window controls "--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)", diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 8d431a4f7..bba7cd927 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index a824dc2ab..613dd1d17 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777 +659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index ecdc7cca6..3906ca057 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index b89a5e83f..aaf5f027a 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b +51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 5a9d2d01d..5201faad7 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 0d4ec6985..bab71bf24 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25 +bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb diff --git a/plugins/official/mybatis/src/lib.rs b/plugins/official/mybatis/src/lib.rs index d10b135a5..6b99bb69b 100644 --- a/plugins/official/mybatis/src/lib.rs +++ b/plugins/official/mybatis/src/lib.rs @@ -70,6 +70,156 @@ fn ident(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '$' } +/// The index just past the string or char literal opening at `bytes[i]`. +fn skip_string(bytes: &[u8], mut i: usize) -> usize { + let quote = bytes[i]; + i += 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b if b == quote => return i + 1, + _ => i += 1, + } + } + i +} + +/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`. +fn skip_comment(bytes: &[u8], mut i: usize) -> usize { + if bytes.get(i + 1) == Some(&b'/') { + bytes[i + 2..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |n| i + 2 + n) + } else { + i += 2; + while i + 1 < bytes.len() && (bytes[i] != b'*' || bytes[i + 1] != b'/') { + i += 1; + } + (i + 2).min(bytes.len()) + } +} + +/// A method declaration in a fragment of an interface's body: the name's byte +/// range and its parameter list's `(` and `)` (offsets in the fragment). +#[derive(Debug, PartialEq, Eq)] +struct Declaration { + name: (usize, usize), + parens: (usize, usize), +} + +/// The method declaration in `stmt`, skipping comments, strings and annotations +/// on the way: an annotation's arguments nest parentheses and may hold strings +/// and comments, so the name is the identifier before the first `(` left over — +/// `@Select("…") User find(…)` names `find`, not `Select`. Anything past that +/// `(` (where `@Param(…)` lives) only matters for its matching `)`, so it never +/// confuses the scan. +fn scan_declaration(stmt: &str) -> Option { + let bytes = stmt.as_bytes(); + let mut i = 0; + let mut nested = 0usize; // paren depth inside an annotation's arguments + while i < bytes.len() { + match bytes[i] { + b' ' | b'\t' | b'\r' | b'\n' => i += 1, + b'/' if matches!(bytes.get(i + 1), Some(b'/' | b'*')) => i = skip_comment(bytes, i), + b'"' | b'\'' => i = skip_string(bytes, i), + b'@' if nested == 0 => { + // The annotation's qualified name, then (optionally) its arguments. + i += 1; + while i < bytes.len() && (ident(bytes[i] as char) || bytes[i] == b'.') { + i += 1; + } + let mut j = i; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + if bytes.get(j) == Some(&b'(') { + nested = 1; + i = j + 1; + } + } + b'(' if nested > 0 => { + nested += 1; + i += 1; + } + b')' if nested > 0 => { + nested -= 1; + i += 1; + } + b'(' => { + let before = stmt[..i].trim_end(); + let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1); + if start == before.len() { + return None; + } + // The matching `)`, minding strings and nested parens. + let (mut close, mut depth) = (i + 1, 1usize); + while close < bytes.len() { + match bytes[close] { + b'"' | b'\'' => close = skip_string(bytes, close), + b'/' if matches!(bytes.get(close + 1), Some(b'/' | b'*')) => close = skip_comment(bytes, close), + b'(' => { + depth += 1; + close += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + return Some(Declaration { name: (start, before.len()), parens: (i, close) }); + } + close += 1; + } + _ => close += 1, + } + } + return None; // an unterminated parameter list is no declaration + } + _ => i += 1, + } + } + None +} + +/// The parameter names in a declaration's parameter list: `@Param("…")` wins, +/// otherwise the parameter's last identifier. Split at commas outside nested +/// parens, braces, brackets and strings, so `@Size(min = 1, max = 5) String n` +/// stays one parameter and a `@Param("a,b")` keeps its whole name. +fn params_of(list: &str) -> Vec { + let bytes = list.as_bytes(); + let mut parts = Vec::new(); + let (mut depth, mut start) = (0usize, 0usize); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' | b'\'' => i = skip_string(bytes, i), + b'/' if matches!(bytes.get(i + 1), Some(b'/' | b'*')) => i = skip_comment(bytes, i), + b'(' | b'{' | b'[' => { + depth += 1; + i += 1; + } + b')' | b'}' | b']' => { + depth = depth.saturating_sub(1); + i += 1; + } + b',' if depth == 0 => { + parts.push(&list[start..i]); + start = i + 1; + i += 1; + } + _ => i += 1, + } + } + parts.push(&list[start..]); + parts + .into_iter() + .filter_map(|p| { + // `@Param("id") long id` → the @Param name wins. + if let Some(q) = p.find("@Param(\"") { + return p[q + 8..].split('"').next().map(str::to_owned); + } + p.split_whitespace().last().map(|n| n.trim_matches(|c: char| !ident(c)).to_owned()) + }) + .filter(|n| !n.is_empty()) + .collect() +} + /// The interface's name and methods (with their parameter names). pub fn parse_java(text: &str) -> MapperJava { let package = @@ -95,28 +245,14 @@ pub fn parse_java(text: &str) -> MapperJava { } ';' if depth == 0 => { let stmt = &text[stmt_start..k]; - if let Some(paren) = stmt.find('(') { - let before = stmt[..paren].trim_end(); - let name_start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1); - let method = &before[name_start..]; - if !method.is_empty() && !before.contains(" default ") { - let s = stmt_start + name_start; - let params = stmt[paren + 1..stmt.rfind(')').unwrap_or(stmt.len())] - .split(',') - .filter_map(|p| { - // `@Param("id") long id` → the @Param name wins. - if let Some(q) = p.find("@Param(\"") { - return p[q + 8..].split('"').next().map(str::to_owned); - } - p.split_whitespace().last().map(|n| n.trim_matches(|c: char| !ident(c)).to_owned()) - }) - .filter(|n| !n.is_empty()) - .collect(); - methods.push(( - Named { name: method.to_owned(), range: range_of(text, s, s + method.len()) }, - params, - )); - } + if let Some(decl) = scan_declaration(stmt) + && !stmt[..decl.parens.0].contains(" default ") + { + let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1); + methods.push(( + Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) }, + params_of(&stmt[decl.parens.0 + 1..decl.parens.1]), + )); } stmt_start = k + 1; } @@ -327,6 +463,22 @@ mod tests { assert_eq!(xml.statements[0].range, Range::new(Position::new(2, 14), Position::new(2, 22))); } + const ANNOTATED: &str = concat!( + "package com.acme;\n\n", + "import org.apache.ibatis.annotations.Param;\n\n", + "public interface UserMapper {\n", + " @Select(\"SELECT (a, b) FROM users WHERE c = ')' OR d = 'x, y'\")\n", + " List selectAnnotated(Long id);\n\n", + " @Cacheable(key = @Key(value = \"x(y)\", of = {@Part(\"a)b\"), @Part(\"c,d\")}))\n", + " @Override\n", + " // a comment (with parens) before the name\n", + " User findAnnotated(@Param(\"id\") Long id, @Size(min = 1, max = 5) String name);\n\n", + " List selectPlain(Long id);\n", + "}\n" + ); + + const ANNOTATED_XML: &str = "\n\n \n \n\n"; + fn setup() -> MockHost { let host = MockHost::install(); host.add_file("src/main/java/com/acme/UserMapper.java", JAVA); @@ -335,6 +487,68 @@ mod tests { host } + fn annotated_setup() -> MockHost { + let host = MockHost::install(); + host.add_file("src/main/java/com/acme/UserMapper.java", ANNOTATED); + host.add_file("src/main/resources/mapper/UserMapper.xml", ANNOTATED_XML); + host.add_file("pom.xml", ""); + host + } + + /// Methods behind method-level annotations keep their names, parameters and + /// positions: the scanner skips comments, strings and annotation arguments + /// (nested annotations, `(` `)` `{` `}` `,` in strings) before the name. + #[test] + fn parses_methods_with_annotations() { + let java = parse_java(ANNOTATED); + let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect(); + assert_eq!( + names, + [ + ("selectAnnotated", vec!["id".to_owned()]), + ("findAnnotated", vec!["id".to_owned(), "name".to_owned()],), + ("selectPlain", vec!["id".to_owned()]) + ], + "annotation names must not become method names, @Param wins, argument commas must not split a parameter" + ); + // ` List ` before the name on its line (UTF-16 columns). + assert_eq!(java.methods[0].0.range, Range::new(Position::new(6, 15), Position::new(6, 30))); + assert_eq!(java.methods[1].0.range, Range::new(Position::new(11, 9), Position::new(11, 22))); + } + + /// A multibyte annotation string on the name's line keeps UTF-16 columns: + /// `中文` is one unit each and `😀` two, while bytes would run ahead. + #[test] + fn keeps_utf16_columns_around_multibyte_annotations() { + let text = "package com.acme;\n\npublic interface UserMapper {\n @Select(\"中文 😀 注释\") List find(Long id);\n}\n"; + let java = parse_java(text); + assert_eq!(java.methods.len(), 1); + assert_eq!(java.methods[0].0.name, "find"); + // `"中文 😀 注释"` costs 10 UTF-16 units (😀 two); `find` starts at 4 + 8 + 1 + 10 + 1 + 11 = 35. + assert_eq!(java.methods[0].0.range, Range::new(Position::new(3, 35), Position::new(3, 39))); + assert_eq!(java.methods[0].1, ["id".to_owned()]); + } + + /// Annotated methods still get both directions of navigation. + #[test] + fn navigates_annotated_methods() { + let _host = annotated_setup(); + let mut n = Navigator::default(); + let java = doc("/project/src/main/java/com/acme/UserMapper.java", "java", ANNOTATED); + let markers = n.line_markers(&java); + assert_eq!(markers.iter().map(|m| m.line).collect::>(), [6, 11], "selectPlain has no statement"); + assert_eq!(markers[1].tooltip, "Go to UserMapper.xml: findAnnotated"); + + let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", ANNOTATED_XML); + let to_java = n.definition(&xml, Position::new(2, 20)); + assert_eq!(to_java.len(), 1); + assert_eq!( + (to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start), + (true, Position::new(6, 15)), + "the XML statement jumps back to the annotated method" + ); + } + fn doc(path: &str, language: &str, text: &str) -> Document { Document { path: path.into(), language: language.into(), version: 1, text: text.into() } } @@ -361,6 +575,17 @@ mod tests { assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()"); } + #[test] + fn method_annotations_do_not_swallow_the_method_name() { + // A method-level annotation with arguments: `stmt.find('(')` would land + // inside `@DataPermission(…)` and read `DataPermission` as the name. + let java = parse_java( + "package com.acme;\n\npublic interface SecuredMapper {\n @DataPermission(column = \"id\", scope = (@Scope(\"self\")))\n List findAll(@Param(\"org\") String org);\n\n @org.acme.Check(value = \"(nested)\")\n int count();\n}\n", + ); + let names: Vec<_> = java.methods.iter().map(|(m, p)| (m.name.as_str(), p.clone())).collect(); + assert_eq!(names, [("findAll", vec!["org".to_owned()]), ("count", Vec::::new())]); + } + #[test] fn completes_ids_and_parameters() { let _host = setup(); diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index e9cec69d8..bbc75cf59 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index a727fdce8..894025033 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf +7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 7a36721c8..c04b1123a 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 937d87db1..953175777 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2 +00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 204171108..c4cc0b12e 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index a934a4a48..094c8bb6e 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722 +eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043 diff --git a/scripts/cnb-mirror.test.ts b/scripts/cnb-mirror.test.ts new file mode 100644 index 000000000..ee59cc8c2 --- /dev/null +++ b/scripts/cnb-mirror.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { KEEP, mirrorManifest, releasesToPrune } from "./cnb-mirror.ts"; + +describe("mirrorManifest", () => { + const gh = "https://github.com/l0ng-ai/openidea/releases/download/v0.1.38"; + const manifest = { + version: "0.1.38", + notes: "", + pub_date: "2026-11-06T00:00:00Z", + platforms: { + "darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` }, + "windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` }, + }, + }; + + it("points every bundle at the CNB release and keeps the signatures", () => { + const cnb = "https://cnb.cool/l0ng-ai/openidea/-/releases/download/v0.1.38"; + expect(mirrorManifest(manifest, "v0.1.38")).toEqual({ + ...manifest, + platforms: { + "darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` }, + "windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` }, + }, + }); + }); + + it("refuses a manifest of another release", () => { + expect(() => mirrorManifest(manifest, "v0.1.39")).toThrow(/darwin-aarch64/); + }); +}); + +describe("releasesToPrune", () => { + const release = (n: number) => ({ + id: String(n), + tag_name: `v0.1.${n}`, + created_at: `2026-11-${String(n).padStart(2, "0")}T00:00:00Z`, + }); + + it("keeps the newest releases and removes the rest", () => { + const releases = Array.from({ length: KEEP + 3 }, (_, i) => release(i + 1)).reverse(); + expect(releasesToPrune(releases, `v0.1.${KEEP + 3}`).map((r) => r.tag_name)).toEqual([ + "v0.1.3", + "v0.1.2", + "v0.1.1", + ]); + }); + + it("removes nothing within the limit, and never the release being mirrored", () => { + expect(releasesToPrune([release(1), release(2)], "v0.1.2")).toEqual([]); + // A rerun for an old tag: it is the oldest, but it was just uploaded. + expect(releasesToPrune([release(1), release(2), release(3)], "v0.1.1", 2)).toEqual([]); + }); +}); diff --git a/scripts/cnb-mirror.ts b/scripts/cnb-mirror.ts new file mode 100644 index 000000000..e86031f30 --- /dev/null +++ b/scripts/cnb-mirror.ts @@ -0,0 +1,159 @@ +/** + * Mirrors a published release to CNB (cnb.cool/l0ng-ai/openidea), where the updater's + * CNB source reads `releases/latest/download/latest.json`: + * `node scripts/cnb-mirror.ts ` with `CNB_TOKEN` set (scopes `repo-code:r`, + * `repo-release:rw`). `` holds the release's assets (`gh release download`) and + * `release.json` (`gh release view --json body,isPrerelease`). Every asset is uploaded, + * `latest.json` last and pointing at CNB, so the mirror's manifest never names a bundle it + * lacks. Only the newest `KEEP` releases stay on CNB. Safe to rerun: uploads overwrite. + */ +import { openAsBlob, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CNB_REPO = "l0ng-ai/openidea"; +const GITHUB_REPO = "l0ng-ai/openidea"; +const API = `https://api.cnb.cool/${CNB_REPO}`; +export const KEEP = 10; +const PARALLEL = 4; +const MANIFEST = "latest.json"; +const RELEASE_INFO = "release.json"; + +interface Manifest { + version: string; + notes?: string; + pub_date?: string; + platforms: Record; +} + +interface CnbRelease { + id: string; + tag_name: string; + created_at: string; +} + +/** `manifest` with each bundle URL moved from the GitHub release to the CNB one. */ +export function mirrorManifest(manifest: Manifest, tag: string): Manifest { + const from = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/`; + const to = `https://cnb.cool/${CNB_REPO}/-/releases/download/${tag}/`; + const platforms: Manifest["platforms"] = {}; + for (const [key, entry] of Object.entries(manifest.platforms)) { + if (!entry.url.startsWith(from)) throw new Error(`${key}: ${entry.url} is not an asset of ${tag}`); + platforms[key] = { ...entry, url: to + entry.url.slice(from.length) }; + } + return { ...manifest, platforms }; +} + +/** Releases beyond the newest `keep` (by creation time), never `current`. */ +export function releasesToPrune(releases: CnbRelease[], current: string, keep = KEEP): CnbRelease[] { + const newest = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at)); + return newest.slice(keep).filter((r) => r.tag_name !== current); +} + +function token(): string { + const value = process.env.CNB_TOKEN?.trim(); + if (!value) throw new Error("CNB_TOKEN is not set"); + return value; +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path.startsWith("https://") ? path : API + path, { + method, + headers: { + authorization: `Bearer ${token()}`, + accept: "application/vnd.cnb.api+json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 404 && method === "GET") return null; + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status} ${text}`); + return text ? (JSON.parse(text) as T) : null; +} + +async function withRetry(what: string, run: () => Promise, attempts = 3): Promise { + for (let i = 1; ; i++) { + try { + return await run(); + } catch (e) { + if (i >= attempts) throw e; + console.warn(`${what}: ${e instanceof Error ? e.message : e}; retrying (${i}/${attempts - 1})`); + await new Promise((r) => setTimeout(r, 5000 * i)); + } + } +} + +async function ensureRelease(tag: string, body: string, prerelease: boolean): Promise { + const existing = await api("GET", `/-/releases/tags/${encodeURIComponent(tag)}`); + if (existing) return existing.id; + const created = await api("POST", "/-/releases", { + tag_name: tag, + target_commitish: "main", + name: tag, + body, + prerelease, + make_latest: prerelease ? "false" : "true", + }); + return created!.id; +} + +async function upload(releaseId: string, name: string, file: Blob) { + const target = await api<{ upload_url: string; verify_url: string }>( + "POST", + `/-/releases/${releaseId}/asset-upload-url`, + { asset_name: name, size: file.size, overwrite: true }, + ); + const put = await fetch(target!.upload_url, { method: "PUT", body: file }); + if (!put.ok) throw new Error(`PUT ${name}: HTTP ${put.status} ${await put.text()}`); + await api("POST", target!.verify_url); +} + +async function uploadAll(releaseId: string, dir: string, names: string[]) { + const queue = [...names]; + const worker = async () => { + for (let name = queue.shift(); name; name = queue.shift()) { + const path = join(dir, name); + const mb = (statSync(path).size / 1048576).toFixed(1); + const started = Date.now(); + await withRetry(name, async () => upload(releaseId, name, await openAsBlob(path))); + console.log(`uploaded ${name} (${mb} MB, ${Math.round((Date.now() - started) / 1000)} s)`); + } + }; + await Promise.all(Array.from({ length: PARALLEL }, worker)); +} + +async function prune(current: string) { + const releases = (await api("GET", "/-/releases?page_size=100")) ?? []; + for (const old of releasesToPrune(releases, current)) { + await api("DELETE", `/-/releases/${old.id}`); + await api("DELETE", `/-/git/tags/${encodeURIComponent(old.tag_name)}`).catch((e) => console.warn(String(e))); + console.log(`removed ${old.tag_name}`); + } +} + +async function main() { + const [tag, dir] = process.argv.slice(2); + if (!tag || !dir) throw new Error("usage: node scripts/cnb-mirror.ts "); + const info = JSON.parse(readFileSync(join(dir, RELEASE_INFO), "utf8")) as { body?: string; isPrerelease?: boolean }; + const manifest = JSON.parse(readFileSync(join(dir, MANIFEST), "utf8")) as Manifest; + const mirrored = mirrorManifest(manifest, tag); + const assets = readdirSync(dir).filter( + (n) => n !== MANIFEST && n !== RELEASE_INFO && statSync(join(dir, n)).isFile(), + ); + + const releaseId = await ensureRelease(tag, info.body ?? "", !!info.isPrerelease); + console.log(`CNB release ${tag} (${releaseId}): uploading ${assets.length} assets`); + await uploadAll(releaseId, dir, assets); + const blob = new Blob([JSON.stringify(mirrored, null, 2)], { type: "application/json" }); + await withRetry(MANIFEST, () => upload(releaseId, MANIFEST, blob)); + console.log(`uploaded ${MANIFEST}: ${Object.keys(mirrored.platforms).length} platforms`); + await prune(tag); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/scripts/release-prepare.test.ts b/scripts/release-prepare.test.ts new file mode 100644 index 000000000..efc2c7d4b --- /dev/null +++ b/scripts/release-prepare.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SNAPSHOT, + apiVersion, + cargoVersion, + parseVersion, + setCargoVersion, + setJsonVersion, + syncApiSnapshot, +} from "./release-prepare.ts"; + +const dirs: string[] = []; +afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))); + +function repo(wit: Record, snapshot: Record): string { + const root = mkdtempSync(join(tmpdir(), "release-prepare-")); + dirs.push(root); + for (const [dir, files] of [ + ["wit", wit], + [SNAPSHOT, snapshot], + ] as const) { + mkdirSync(join(root, dir), { recursive: true }); + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, dir, name), text); + } + return root; +} +const pkg = (version: string) => `package openidea:plugin@${version};\n`; + +describe("versions", () => { + it("accepts x.y.z with or without a leading v", () => { + expect(parseVersion("0.1.38")).toBe("0.1.38"); + expect(parseVersion("v0.1.38")).toBe("0.1.38"); + expect(() => parseVersion("0.1")).toThrow(); + expect(() => parseVersion(undefined)).toThrow(); + }); + + it("changes only the workspace version in Cargo.toml", () => { + const toml = [ + "[workspace]", + 'members = ["crates/*"]', + "", + "[workspace.package]", + 'authors = ["a"]', + 'version = "0.1.37"', + 'edition = "2024"', + "", + "[workspace.dependencies]", + 'foo = { version = "0.1.37" }', + "", + ].join("\n"); + expect(cargoVersion(toml)).toBe("0.1.37"); + const next = setCargoVersion(toml, "0.1.38"); + expect(cargoVersion(next)).toBe("0.1.38"); + expect(next).toContain('foo = { version = "0.1.37" }'); + expect(next.split("\n")).toHaveLength(toml.split("\n").length); + }); + + it("keeps a JSON file's formatting", () => { + const json = '{\n "name": "openidea",\n "version": "0.1.37",\n "private": true\n}\n'; + expect(setJsonVersion(json, "0.1.38")).toBe(json.replace("0.1.37", "0.1.38")); + }); +}); + +describe("plugin API snapshot", () => { + it("copies wit/ over the snapshot when the API version moved, dropping stale files", () => { + const root = repo( + { "plugin.wit": pkg("1.2.0"), "indexing.wit": pkg("1.2.0") }, + { "plugin.wit": pkg("1.1.0"), "old.wit": pkg("1.1.0"), "README.md": "kept" }, + ); + expect(syncApiSnapshot(root)).toEqual({ from: "1.1.0", to: "1.2.0", copied: true }); + expect(readdirSync(join(root, SNAPSHOT)).sort()).toEqual(["README.md", "indexing.wit", "plugin.wit"]); + expect(apiVersion(readFileSync(join(root, SNAPSHOT, "plugin.wit"), "utf8"))).toBe("1.2.0"); + }); + + it("leaves the snapshot alone when the API is unchanged", () => { + const root = repo({ "plugin.wit": pkg("1.1.0") + "// edited\n" }, { "plugin.wit": pkg("1.1.0") }); + expect(syncApiSnapshot(root)).toEqual({ from: "1.1.0", to: "1.1.0", copied: false }); + expect(readFileSync(join(root, SNAPSHOT, "plugin.wit"), "utf8")).toBe(pkg("1.1.0")); + }); +}); diff --git a/scripts/release-prepare.ts b/scripts/release-prepare.ts new file mode 100644 index 000000000..ae6cd3ac4 --- /dev/null +++ b/scripts/release-prepare.ts @@ -0,0 +1,114 @@ +/** + * Prepares the release commit: `pnpm release:prepare 0.1.38` sets the app version in + * Cargo.toml, package.json and src-tauri/tauri.conf.json, refreshes Cargo.lock, and, + * when wit/ carries a newer plugin API than the released snapshot, copies wit/*.wit + * over sdk/rust/openidea-plugin/tests/api/released/ (the release workflow refuses a + * tag whose wit/ and snapshot versions differ). Commit the result as `chore: v`. + */ +import { spawnSync } from "node:child_process"; +import { copyFileSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SNAPSHOT = "sdk/rust/openidea-plugin/tests/api/released"; + +/** `0.1.38` from `0.1.38` or `v0.1.38`; throws for anything else. */ +export function parseVersion(arg: string | undefined): string { + const version = arg?.replace(/^v/, ""); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`expected a version like 0.1.38, got ${arg}`); + return version; +} + +function compare(a: string, b: string): number { + const [x, y] = [a, b].map((v) => v.split(".").map(Number)); + for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return x![i]! - y![i]!; + return 0; +} + +/** The line index and value of `version` in `[workspace.package]`. */ +function workspaceVersionLine(lines: string[]): { index: number; version: string } { + const start = lines.findIndex((line) => line.trim() === "[workspace.package]"); + for (let i = start + 1; start >= 0 && i < lines.length && !lines[i]!.startsWith("["); i++) { + const match = /^version = "([^"]+)"$/.exec(lines[i]!.trim()); + if (match) return { index: i, version: match[1]! }; + } + throw new Error("Cargo.toml: no version in [workspace.package]"); +} + +export function cargoVersion(toml: string): string { + return workspaceVersionLine(toml.split("\n")).version; +} + +export function setCargoVersion(toml: string, version: string): string { + const lines = toml.split("\n"); + const { index } = workspaceVersionLine(lines); + lines[index] = `version = "${version}"`; + return lines.join("\n"); +} + +/** Replaces the top-level `"version"` of a JSON file, keeping its formatting. */ +export function setJsonVersion(json: string, version: string): string { + const current = (JSON.parse(json) as { version?: unknown }).version; + if (typeof current !== "string") throw new Error("no top-level version"); + return json.replace(`"version": "${current}"`, `"version": "${version}"`); +} + +/** `1.2.0` from a `package openidea:plugin@1.2.0;` line. */ +export function apiVersion(wit: string): string { + const match = /^package openidea:plugin@([^;]+);$/m.exec(wit); + if (!match) throw new Error("no `package openidea:plugin@…;` line"); + return match[1]!; +} + +/** + * Copies `wit/*.wit` over the snapshot when wit/ declares another API version, and + * returns the versions; leaves the snapshot alone otherwise (no API change to release). + */ +export function syncApiSnapshot(root: string): { from: string; to: string; copied: boolean } { + const wit = join(root, "wit"); + const snapshot = join(root, SNAPSHOT); + const to = apiVersion(readFileSync(join(wit, "plugin.wit"), "utf8")); + const from = apiVersion(readFileSync(join(snapshot, "plugin.wit"), "utf8")); + if (from === to) return { from, to, copied: false }; + for (const file of readdirSync(snapshot)) if (file.endsWith(".wit")) rmSync(join(snapshot, file)); + for (const file of readdirSync(wit)) if (file.endsWith(".wit")) copyFileSync(join(wit, file), join(snapshot, file)); + return { from, to, copied: true }; +} + +function edit(path: string, change: (text: string) => string) { + const before = readFileSync(path, "utf8"); + const after = change(before); + if (after === before) throw new Error(`${path}: nothing changed`); + writeFileSync(path, after); +} + +function main() { + const root = fileURLToPath(new URL("..", import.meta.url)); + const version = parseVersion(process.argv[2]); + const current = cargoVersion(readFileSync(join(root, "Cargo.toml"), "utf8")); + if (compare(version, current) <= 0) throw new Error(`${version} is not newer than the current ${current}`); + + edit(join(root, "Cargo.toml"), (text) => setCargoVersion(text, version)); + edit(join(root, "package.json"), (text) => setJsonVersion(text, version)); + edit(join(root, "src-tauri/tauri.conf.json"), (text) => setJsonVersion(text, version)); + const cargo = spawnSync("cargo", ["update", "--workspace"], { cwd: root, stdio: "inherit" }); + if (cargo.status !== 0) throw new Error("cargo update --workspace failed"); + console.log(`version ${current} -> ${version} (Cargo.toml, Cargo.lock, package.json, tauri.conf.json)`); + + const api = syncApiSnapshot(root); + console.log( + api.copied + ? `plugin API ${api.from} -> ${api.to}: copied wit/*.wit to ${SNAPSHOT}/` + : `plugin API ${api.to}: unchanged since the last release`, + ); + console.log(`next: commit as "chore: v${version}", then tag v${version}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + console.error(`release:prepare: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 6b93a1e7e..76b998a19 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -73,6 +73,19 @@ mod imp { wit::ui::remove_status_item(id) } + pub fn set_context(key: &str, value: ContextValue) -> Result<(), Error> { + let value = match value { + ContextValue::Flag(b) => wit::ui::ContextValue::Flag(b), + ContextValue::Number(n) => wit::ui::ContextValue::Number(n), + ContextValue::Text(t) => wit::ui::ContextValue::Text(t), + }; + wit::ui::set_context(key, &value).map_err(error) + } + + pub fn remove_context(key: &str) { + wit::ui::remove_context(key) + } + pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { wit::ui::copy_to_clipboard(text).map_err(error) } @@ -81,6 +94,37 @@ mod imp { wit::ui::open_url(url).map_err(error) } + fn pick_items(items: &[PickItem]) -> Vec { + items + .iter() + .map(|i| wit::ui::PickItem { + key: i.key.clone(), + label: i.label.clone(), + description: i.description.clone(), + }) + .collect() + } + + pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + wit::ui::pick(title, &pick_items(items)).map_err(error) + } + + pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { + wit::ui::pick_many(title, &pick_items(items)).map_err(error) + } + + pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { + wit::ui::input(prompt, value).map_err(error) + } + + pub fn confirm(title: &str, body: &str) -> Result { + wit::ui::confirm(title, body).map_err(error) + } + + pub fn reveal_view(view: &str) -> Result<(), Error> { + wit::views::reveal(view).map_err(error) + } + pub fn storage_get(key: &str) -> Result, Error> { wit::storage::get(key).map_err(error) } @@ -117,6 +161,10 @@ mod imp { i.editor() } + pub fn invocation_selected_paths(i: InvocationRef<'_>) -> Vec { + i.selected_paths() + } + pub fn editor_path(e: &EditorRes) -> String { e.path() } @@ -327,6 +375,12 @@ impl<'a> Invocation<'a> { pub fn editor(&self) -> Option { imp::invocation_editor(self.inner).map(|inner| TextEditor { inner }) } + + /// The files and folders the command was run on, as absolute paths. Empty when it was not + /// run on any, or when the menu was for a tab without a file (a virtual document). + pub fn selected_paths(&self) -> Vec { + imp::invocation_selected_paths(self.inner) + } } /// A project open in the IDE. @@ -545,6 +599,19 @@ pub fn remove_status_item(id: &str) { imp::remove_status_item(id) } +/// Sets the plugin's context key that `when` clauses test as `plugin..`, in +/// the window the current command or event came from (every window when there is +/// none): `set_context("connected", true)`. A window's value takes precedence over +/// the global one. The key is 1-64 of `A-Za-z0-9_-`; at most 100 keys. +pub fn set_context(key: &str, value: impl Into) -> Result<(), Error> { + imp::set_context(key, value.into()) +} + +/// Removes the key [`set_context`] set in the same scope. +pub fn remove_context(key: &str) { + imp::remove_context(key) +} + /// Copies `text` to the system clipboard. Needs `clipboard`. pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { imp::copy_to_clipboard(text) @@ -555,6 +622,38 @@ pub fn open_url(url: &str) -> Result<(), Error> { imp::open_url(url) } +/// Shows a list in the window the current command or event came from and +/// waits for the key of the chosen item, or `None` when the user dismisses +/// it. Needs no capability; [`Error::Invalid`] outside a command or event of a +/// window (and in editor providers). The wait doesn't count against the +/// call's time budget, but the plugin handles nothing else meanwhile. +pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + imp::pick(title, items) +} + +/// Like [`pick`], with any number of items chosen. +pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { + imp::pick_many(title, items) +} + +/// Asks for a line of text, pre-filled with `value`; `None` when dismissed. +/// Same rules as [`pick`]. +pub fn input(prompt: &str, value: Option<&str>) -> Result, Error> { + imp::input(prompt, value) +} + +/// Asks for confirmation: `true` on OK, `false` on Cancel or Escape. +/// Same rules as [`pick`]. +pub fn confirm(title: &str, body: &str) -> Result { + imp::confirm(title, body) +} + +/// Brings one of the plugin's declared views to the front in the window the +/// current command or event came from. +pub fn reveal_view(view: &str) -> Result<(), Error> { + imp::reveal_view(view) +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/indexing.rs b/sdk/rust/openidea-plugin/src/indexing.rs new file mode 100644 index 000000000..bb55b9272 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/indexing.rs @@ -0,0 +1,46 @@ +//! Background document indexing (`[[contributes.documentIndexers]]`): return +//! the keys a document defines and refers to; the host keeps the project-wide +//! graph and resolves Go to Declaration and gutter markers from it. Register +//! with `register_plugin!(Plugin, indexing)`. +use crate::Range; + +/// A symbol the host's syntax support found in the document. +#[derive(Clone, Debug)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} + +pub struct Document { + pub path: String, + pub text: String, + pub symbols: Vec, +} + +/// A range tied to a key the plugin chooses. +#[derive(Clone, Debug)] +pub struct Anchor { + pub key: String, + pub range: Range, +} + +/// Keys starting with `prefix` also resolve under `target` (both end with `#`). +pub struct Alias { + pub prefix: String, + pub target: String, +} + +#[derive(Default)] +pub struct Edges { + pub definitions: Vec, + pub references: Vec, + pub aliases: Vec, + /// Gutter icons that go to the definitions of their key. + pub markers: Vec, +} + +pub trait DocumentIndexer { + fn index(&mut self, doc: &Document) -> Edges; +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 1172d78f5..0c0a44d31 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -40,9 +40,12 @@ //! wasm32-wasip2`); see `plugins/README.md`. pub mod host; +pub mod indexing; #[cfg(not(target_arch = "wasm32"))] pub mod mock; pub mod providers; +pub mod semantic_tokens; +pub mod services; pub mod syntax; pub mod text; mod types; @@ -89,7 +92,7 @@ pub mod providers_bindings { export_macro_name: "__export_editor_providers_world", default_bindings_module: "openidea_plugin::providers_bindings", with: { - "openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, }, }); } @@ -106,16 +109,54 @@ pub mod views_bindings { export_macro_name: "__export_views_world", default_bindings_module: "openidea_plugin::views_bindings", with: { - "openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/workspace@1.1.0": crate::bindings::openidea::plugin::workspace, - "openidea:plugin/editor@1.1.0": crate::bindings::openidea::plugin::editor, - "openidea:plugin/invocation@1.1.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, }, }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod indexing_bindings { + wit_bindgen::generate!({ + world: "indexing", path: "wit", pub_export_macro: true, + export_macro_name: "__export_indexing_world", + default_bindings_module: "openidea_plugin::indexing_bindings", + with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + }); +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod services_bindings { + wit_bindgen::generate!({ + world: "services", path: "wit", pub_export_macro: true, + export_macro_name: "__export_services_world", + default_bindings_module: "openidea_plugin::services_bindings", + with: { + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod semantic_bindings { + wit_bindgen::generate!({ + world: "semantic-tokens", path: "wit", pub_export_macro: true, + export_macro_name: "__export_semantic_tokens_world", + default_bindings_module: "openidea_plugin::semantic_bindings", + with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + }); +} + /// The plugin API version this SDK targets. -pub const API_VERSION: &str = "1.1"; +pub const API_VERSION: &str = "1.3"; /// A plugin. One instance lives as long as the plugin's Wasm instance (until /// it is disabled, crashes or is reclaimed when idle). @@ -142,7 +183,7 @@ pub trait Plugin: 'static { /// `syntax`, `T` is also a [`syntax::SyntaxProvider`] for its languages. #[macro_export] macro_rules! register_plugin { - ($plugin:ty) => { + ($plugin:ty $(, $export:ident)* $(,)?) => { // Natively (tests) nothing is exported; keep the type "used". #[cfg(not(target_arch = "wasm32"))] const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; @@ -150,80 +191,58 @@ macro_rules! register_plugin { const _: () = { struct __OpenideaComponent; $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $($crate::__optional_exports!($export, $plugin, __OpenideaComponent);)* $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); }; }; - ($plugin:ty, providers) => { - #[cfg(not(target_arch = "wasm32"))] - const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; - #[cfg(target_arch = "wasm32")] - const _: () = { - struct __OpenideaComponent; - $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); - $crate::__providers_exports!($plugin, __OpenideaComponent); - $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); - }; - }; - ($plugin:ty, views) => { - #[cfg(not(target_arch = "wasm32"))] - const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; - #[cfg(target_arch = "wasm32")] - const _: () = { - struct __OpenideaComponent; - $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); - $crate::__views_exports!($plugin, __OpenideaComponent); - $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); - }; - }; - ($plugin:ty, providers, views) => { - #[cfg(not(target_arch = "wasm32"))] - const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; - #[cfg(target_arch = "wasm32")] - const _: () = { - struct __OpenideaComponent; - $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); - $crate::__providers_exports!($plugin, __OpenideaComponent); - $crate::__views_exports!($plugin, __OpenideaComponent); - $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); - }; - }; - ($plugin:ty, syntax) => { - #[cfg(not(target_arch = "wasm32"))] - const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; - #[cfg(target_arch = "wasm32")] - const _: () = { - struct __OpenideaComponent; - $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); +} - impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for __OpenideaComponent { - fn highlights( - language: ::std::string::String, - text: ::std::string::String, - ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Token> - { - $crate::__rt::highlights::<$plugin>(&language, &text) - } - - fn outline( - language: ::std::string::String, - text: ::std::string::String, - ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Symbol> - { - $crate::__rt::outline::<$plugin>(&language, &text) - } - - fn folding( - language: ::std::string::String, - text: ::std::string::String, - ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Fold> - { - $crate::__rt::folding::<$plugin>(&language, &text) - } +#[doc(hidden)] +#[macro_export] +macro_rules! __optional_exports { + (providers, $plugin:ty, $component:ident) => { + $crate::__providers_exports!($plugin, $component); + }; + (views, $plugin:ty, $component:ident) => { + $crate::__views_exports!($plugin, $component); + }; + (indexing, $plugin:ty, $component:ident) => { + $crate::__indexing_exports!($plugin, $component); + }; + (semantic_tokens, $plugin:ty, $component:ident) => { + $crate::__semantic_tokens_exports!($plugin, $component); + }; + (services, $plugin:ty, $component:ident) => { + $crate::__services_exports!($plugin, $component); + }; + (syntax, $plugin:ty, $component:ident) => { + impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component { + fn highlights( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Token> + { + $crate::__rt::highlights::<$plugin>(&language, &text) } - $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); - $crate::syntax_bindings::__export_syntax_world!(__OpenideaComponent with_types_in $crate::syntax_bindings); - }; + fn outline( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Symbol> + { + $crate::__rt::outline::<$plugin>(&language, &text) + } + + fn folding( + language: ::std::string::String, + text: ::std::string::String, + ) -> ::std::vec::Vec<$crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Fold> + { + $crate::__rt::folding::<$plugin>(&language, &text) + } + } + + $crate::syntax_bindings::__export_syntax_world!($component with_types_in $crate::syntax_bindings); }; } @@ -313,6 +332,58 @@ macro_rules! __views_exports { }; } +#[doc(hidden)] +#[macro_export] +macro_rules! __indexing_exports { + ($plugin:ty, $component:ident) => { + impl $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Guest for $component { + fn index(doc: $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document) + -> $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + $crate::__rt::index_document::<$plugin>(doc) + } + } + $crate::indexing_bindings::__export_indexing_world!($component with_types_in $crate::indexing_bindings); + }; +} + +/// `register_plugin!(T, services)`: `T` is also a [`services::Services`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __services_exports { + ($plugin:ty, $component:ident) => { + impl $crate::services_bindings::exports::openidea::plugin::service_provider::Guest for $component { + fn detail( + view: ::std::string::String, + key: ::std::string::String, + ctx: &$crate::bindings::openidea::plugin::invocation::Invocation, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::services_bindings::openidea::plugin::service_types::Tab>, + $crate::bindings::openidea::plugin::types::Error, + > { + $crate::__rt::service_detail::<$plugin>(view, key, ctx) + } + } + $crate::services_bindings::__export_services_world!($component with_types_in $crate::services_bindings); + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __semantic_tokens_exports { + ($plugin:ty, $component:ident) => { + impl $crate::semantic_bindings::exports::openidea::plugin::semantic_tokens_provider::Guest for $component { + fn semantic_tokens( + doc: $crate::semantic_bindings::exports::openidea::plugin::semantic_tokens_provider::Document, + ) -> ::std::vec::Vec< + $crate::semantic_bindings::exports::openidea::plugin::semantic_tokens_provider::SemanticToken, + > { + $crate::__rt::semantic_tokens::<$plugin>(doc) + } + } + $crate::semantic_bindings::__export_semantic_tokens_world!($component with_types_in $crate::semantic_bindings); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! __plugin_exports { @@ -522,6 +593,52 @@ pub mod __rt { .collect() } + pub fn index_document( + d: crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document, + ) -> crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + use crate::indexing as i; + use crate::indexing_bindings::exports::openidea::plugin::document_indexer as w; + let doc = i::Document { + path: d.path, + text: d.text, + symbols: d + .symbols + .into_iter() + .map(|s| i::Symbol { + name: s.name, + kind: s.kind, + container: s.container, + range: crate::host::__range_from(s.range), + }) + .collect(), + }; + let e = with::(|p| p.index(&doc)); + fn anchor(a: i::Anchor) -> w::Anchor { + w::Anchor { key: a.key, range: crate::host::__range_to(a.range) } + } + w::Edges { + definitions: e.definitions.into_iter().map(anchor).collect(), + references: e.references.into_iter().map(anchor).collect(), + aliases: e.aliases.into_iter().map(|a| w::Alias { prefix: a.prefix, target: a.target }).collect(), + markers: e.markers.into_iter().map(anchor).collect(), + } + } + + pub fn semantic_tokens( + d: crate::semantic_bindings::exports::openidea::plugin::semantic_tokens_provider::Document, + ) -> Vec { + use crate::semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as w; + let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text }; + with::(|p| p.semantic_tokens(&doc)) + .into_iter() + .map(|t| w::SemanticToken { + range: crate::host::__range_to(t.range), + kind: t.kind.as_str().into(), + modifiers: t.modifiers.into_iter().map(|m| m.as_str().into()).collect(), + }) + .collect() + } + // ---- views ---- use crate::views as v; @@ -611,6 +728,34 @@ pub mod __rt { with::(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) } + pub fn service_detail( + view: String, + key: String, + ctx: &b::invocation::Invocation, + ) -> Result, b::types::Error> { + use crate::services as s; + use crate::services_bindings::openidea::plugin::service_types as w; + let ctx = Invocation::__new(ctx); + let tab = |t: s::Tab| w::Tab { + id: t.id, + label: t.label, + body: match t.body { + s::Pane::Message(m) => w::Pane::Message(m), + s::Pane::Text(text) => w::Pane::Text(text), + s::Pane::Log(log) => w::Pane::Log(w::ProcessLog { + program: log.program, + args: log.args, + toggles: log + .toggles + .into_iter() + .map(|t| w::LogToggle { id: t.id, label: t.label, args: t.args }) + .collect(), + }), + }, + }; + with::(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out) + } + pub fn view_event( view: String, event: vt::ViewEvent, diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index a011569eb..aa4efa1c6 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -8,7 +8,7 @@ use crate::host::Invocation; use crate::types::*; use std::cell::RefCell; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::marker::PhantomData; use std::rc::Rc; @@ -56,9 +56,11 @@ struct State { other_projects: Vec, files: BTreeMap, editor: Option, + selected_paths: Vec, notifications: Vec, logs: Vec<(LogLevel, String)>, status: BTreeMap, + context: BTreeMap, storage: BTreeMap, subscriptions: BTreeSet, tasks: Vec, @@ -68,6 +70,8 @@ struct State { fetch_error: Option, clipboard: Option, opened_urls: Vec, + answers: VecDeque>>, + revealed_views: Vec, diagnostics: BTreeMap>, secrets: BTreeMap, net: Option, @@ -144,6 +148,11 @@ impl MockHost { self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned()); } + /// Sets the paths invocations report as selected. + pub fn select_paths(&self, paths: &[&str]) { + self.state.borrow_mut().selected_paths = paths.iter().map(|p| (*p).to_owned()).collect(); + } + /// Focuses an editor on `text` with selections `(line, col, line, col)`. pub fn open_editor(&self, path: &str, language: &str, text: &str, selections: &[(u32, u32, u32, u32)]) { let selections = @@ -185,6 +194,10 @@ impl MockHost { self.state.borrow().status.clone() } + pub fn context_keys(&self) -> BTreeMap { + self.state.borrow().context.clone() + } + pub fn storage(&self) -> BTreeMap { self.state.borrow().storage.clone() } @@ -224,6 +237,19 @@ impl MockHost { self.state.borrow().opened_urls.clone() } + /// The answer the next [`crate::host::pick`], [`crate::host::input`] or + /// [`crate::host::confirm`] gets: the chosen keys, the text (one element), + /// anything for OK, or `None` for dismissed. Unscripted prompts are dismissed. + pub fn answer_next_prompt(&self, answer: Option<&[&str]>) { + let answer = answer.map(|a| a.iter().map(|&s| s.to_owned()).collect()); + self.state.borrow_mut().answers.push_back(answer); + } + + /// Views brought forward with [`crate::host::reveal_view`], in order. + pub fn revealed_views(&self) -> Vec { + self.state.borrow().revealed_views.clone() + } + /// Makes `vcs-fetch` fail. pub fn fail_fetch(&self, error: Option) { self.state.borrow_mut().fetch_error = error; @@ -377,6 +403,15 @@ pub mod current { with(|s| s.status.remove(id)); } + pub fn set_context(key: &str, value: ContextValue) -> Result<(), Error> { + with(|s| s.context.insert(key.to_owned(), value)); + Ok(()) + } + + pub fn remove_context(key: &str) { + with(|s| s.context.remove(key)); + } + pub fn copy_to_clipboard(text: &str) -> Result<(), Error> { with(|s| { require(s, "clipboard")?; @@ -396,6 +431,36 @@ pub mod current { }) } + pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { + let keys = pick_many(title, items)?; + match keys { + Some(keys) if keys.len() > 1 => Err(Error::Invalid("more than one item chosen".into())), + keys => Ok(keys.and_then(|keys| keys.into_iter().next())), + } + } + + pub fn pick_many(_title: &str, items: &[PickItem]) -> Result>, Error> { + let answer = with(|s| s.answers.pop_front().flatten()); + let Some(keys) = answer else { return Ok(None) }; + if let Some(unknown) = keys.iter().find(|k| !items.iter().any(|i| &i.key == *k)) { + return Err(Error::Invalid(format!("{unknown:?} is not one of the items"))); + } + Ok(Some(keys)) + } + + pub fn input(_prompt: &str, _value: Option<&str>) -> Result, Error> { + Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next())) + } + + pub fn confirm(_title: &str, _body: &str) -> Result { + Ok(with(|s| s.answers.pop_front().flatten()).is_some()) + } + + pub fn reveal_view(view: &str) -> Result<(), Error> { + with(|s| s.revealed_views.push(view.to_owned())); + Ok(()) + } + pub fn storage_get(key: &str) -> Result, Error> { with(|s| { require(s, "storage")?; @@ -473,6 +538,10 @@ pub mod current { }) } + pub fn invocation_selected_paths(_: InvocationRef<'_>) -> Vec { + with(|s| s.selected_paths.clone()) + } + pub fn editor_path(_: &EditorRes) -> String { editor(|_, e| e.path.clone()) } @@ -717,6 +786,14 @@ mod tests { use super::*; use crate::host; + #[test] + fn invocations_carry_selected_paths() { + let host = MockHost::install(); + assert!(host.invocation().selected_paths().is_empty()); + host.select_paths(&["/p/a", "/p/b"]); + assert_eq!(host.invocation().selected_paths(), ["/p/a", "/p/b"]); + } + #[test] fn records_and_enforces() { let mock = MockHost::install(); @@ -733,6 +810,13 @@ mod tests { assert_eq!(host::storage::keys("").unwrap(), ["k"]); host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None }); assert_eq!(mock.status_items()["s"].text, "x"); + host::set_context("a", 1.0).unwrap(); + host::set_context("b", "x").unwrap(); + assert_eq!(mock.context_keys()["a"], ContextValue::Number(1.0)); + assert_eq!(mock.context_keys()["b"], ContextValue::Text("x".into())); + host::remove_context("a"); + host::remove_context("b"); + assert!(mock.context_keys().is_empty()); mock.set_vcs_remotes(&[("origin", "git@github.com:o/r.git")]); assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "git@github.com:o/r.git".into())]); host::copy_to_clipboard("copied").unwrap(); @@ -740,6 +824,22 @@ mod tests { host::open_url("https://example.com/a").unwrap(); assert_eq!(mock.opened_urls(), ["https://example.com/a"]); assert!(matches!(host::open_url("file:///etc/passwd"), Err(Error::Invalid(_)))); + let items = [PickItem::new("a", "A"), PickItem::new("b", "B").description("second")]; + assert_eq!(host::pick("Pick", &items).unwrap(), None); + mock.answer_next_prompt(Some(&["b"])); + assert_eq!(host::pick("Pick", &items).unwrap().as_deref(), Some("b")); + mock.answer_next_prompt(Some(&["a", "b"])); + assert_eq!(host::pick_many("Pick", &items).unwrap(), Some(vec!["a".to_owned(), "b".to_owned()])); + mock.answer_next_prompt(Some(&["zzz"])); + assert!(matches!(host::pick_many("Pick", &items), Err(Error::Invalid(_)))); + mock.answer_next_prompt(Some(&["x.txt"])); + assert_eq!(host::input("Name", Some("a.txt")).unwrap().as_deref(), Some("x.txt")); + mock.answer_next_prompt(Some(&[])); + assert!(host::confirm("Sure?", "Really").unwrap()); + mock.answer_next_prompt(None); + assert!(!host::confirm("Sure?", "Really").unwrap()); + host::reveal_view("panel").unwrap(); + assert_eq!(mock.revealed_views(), ["panel"]); { let task = host::begin_task("Work", true); task.report(1, Some(2), None); diff --git a/sdk/rust/openidea-plugin/src/semantic_tokens.rs b/sdk/rust/openidea-plugin/src/semantic_tokens.rs new file mode 100644 index 000000000..b61387dc2 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/semantic_tokens.rs @@ -0,0 +1,137 @@ +use crate::types::Range; + +pub use crate::providers::Document; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum TokenKind { + Namespace, + Type, + Class, + Enum, + Interface, + Struct, + TypeParameter, + Parameter, + Variable, + Property, + EnumMember, + Event, + Function, + Method, + Macro, + Keyword, + Modifier, + Comment, + String, + Number, + Regexp, + Operator, + Decorator, +} + +impl TokenKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Namespace => "namespace", + Self::Type => "type", + Self::Class => "class", + Self::Enum => "enum", + Self::Interface => "interface", + Self::Struct => "struct", + Self::TypeParameter => "type-parameter", + Self::Parameter => "parameter", + Self::Variable => "variable", + Self::Property => "property", + Self::EnumMember => "enum-member", + Self::Event => "event", + Self::Function => "function", + Self::Method => "method", + Self::Macro => "macro", + Self::Keyword => "keyword", + Self::Modifier => "modifier", + Self::Comment => "comment", + Self::String => "string", + Self::Number => "number", + Self::Regexp => "regexp", + Self::Operator => "operator", + Self::Decorator => "decorator", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum TokenModifier { + Declaration, + Definition, + Readonly, + Static, + Deprecated, + Abstract, + Async, + Modification, + Documentation, + DefaultLibrary, +} + +impl TokenModifier { + pub fn as_str(self) -> &'static str { + match self { + Self::Declaration => "declaration", + Self::Definition => "definition", + Self::Readonly => "readonly", + Self::Static => "static", + Self::Deprecated => "deprecated", + Self::Abstract => "abstract", + Self::Async => "async", + Self::Modification => "modification", + Self::Documentation => "documentation", + Self::DefaultLibrary => "default-library", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SemanticToken { + pub range: Range, + pub kind: TokenKind, + pub modifiers: Vec, +} + +impl SemanticToken { + pub fn new(range: Range, kind: TokenKind) -> Self { + Self { range, kind, modifiers: Vec::new() } + } + + pub fn modifier(mut self, modifier: TokenModifier) -> Self { + self.modifiers.push(modifier); + self + } +} + +pub trait SemanticTokens { + fn semantic_tokens(&mut self, doc: &Document) -> Vec; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Position; + + #[test] + fn names_follow_the_wit_vocabulary() { + assert_eq!(TokenKind::TypeParameter.as_str(), "type-parameter"); + assert_eq!(TokenKind::EnumMember.as_str(), "enum-member"); + assert_eq!(TokenModifier::DefaultLibrary.as_str(), "default-library"); + } + + #[test] + fn a_token_collects_its_modifiers() { + let range = Range { start: Position { line: 1, character: 2 }, end: Position { line: 1, character: 6 } }; + let token = SemanticToken::new(range, TokenKind::Method) + .modifier(TokenModifier::Static) + .modifier(TokenModifier::Deprecated); + assert_eq!(token.modifiers, [TokenModifier::Static, TokenModifier::Deprecated]); + } +} diff --git a/sdk/rust/openidea-plugin/src/services.rs b/sdk/rust/openidea-plugin/src/services.rs new file mode 100644 index 000000000..cc1738361 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/services.rs @@ -0,0 +1,76 @@ +//! The pane beside the Services tree for the nodes of the plugin's +//! `location = "services"` views (world `plugin-services`). The nodes, the +//! toolbar and their events come from [`crate::views::Views`] as in any view; +//! register both: `register_plugin!(MyPlugin, views, services)`. + +use crate::{Error, Invocation}; + +/// A switch above a log: when on, `args` are appended and the program starts again. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogToggle { + pub id: String, + pub label: String, + pub args: Vec, +} + +/// A program the IDE runs (with the plugin's `process:` grant, in the +/// project root), its output followed in a log until it exits or the pane closes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessLog { + pub program: String, + pub args: Vec, + pub toggles: Vec, +} + +impl ProcessLog { + pub fn new(program: &str, args: &[&str]) -> Self { + Self { program: program.to_owned(), args: strings(args), toggles: Vec::new() } + } + + /// Adds a switch above the log that appends `args` when on. + pub fn toggle(mut self, id: &str, label: &str, args: &[&str]) -> Self { + self.toggles.push(LogToggle { id: id.to_owned(), label: label.to_owned(), args: strings(args) }); + self + } +} + +/// The body of a [`Tab`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Pane { + /// A centred message: an empty state or why there is nothing to show. + Message(String), + /// Read-only text the user can select and copy (at most 1 MiB). + Text(String), + Log(ProcessLog), +} + +impl From for Pane { + fn from(log: ProcessLog) -> Self { + Self::Log(log) + } +} + +/// A tab of a node's pane; the IDE switches tabs without calling the plugin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tab { + pub id: String, + pub label: String, + pub body: Pane, +} + +impl Tab { + pub fn new(id: &str, label: &str, body: impl Into) -> Self { + Self { id: id.to_owned(), label: label.to_owned(), body: body.into() } + } +} + +/// The detail pane of the nodes of the plugin's services views. +pub trait Services { + /// The tabs of node `key` of view `view` (at most 8), asked when the node is + /// selected and again after [`crate::host::refresh_view`] of the view. + fn detail(&mut self, view: &str, key: &str, ctx: &Invocation) -> Result, Error>; +} + +fn strings(args: &[&str]) -> Vec { + args.iter().map(|a| (*a).to_owned()).collect() +} diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 6a6fbd7a5..4e0eb1140 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -143,6 +143,60 @@ pub struct StatusItem { pub command: Option, } +/// An entry of [`crate::host::pick`] and [`crate::host::pick_many`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickItem { + /// Returned when the item is chosen. + pub key: String, + pub label: String, + pub description: Option, +} + +impl PickItem { + pub fn new(key: &str, label: &str) -> Self { + Self { key: key.to_owned(), label: label.to_owned(), description: None } + } + + pub fn description(mut self, description: &str) -> Self { + self.description = Some(description.to_owned()); + self + } +} + +/// A context key's value, as `when` clauses compare it ([`crate::host::set_context`]). +#[derive(Clone, Debug, PartialEq)] +pub enum ContextValue { + Flag(bool), + /// A finite number. + Number(f64), + /// At most 1024 bytes. + Text(String), +} + +impl From for ContextValue { + fn from(value: bool) -> Self { + Self::Flag(value) + } +} + +impl From for ContextValue { + fn from(value: f64) -> Self { + Self::Number(value) + } +} + +impl From<&str> for ContextValue { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From for ContextValue { + fn from(value: String) -> Self { + Self::Text(value) + } +} + /// Kinds of workbench events; later SDKs add more. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] diff --git a/sdk/rust/openidea-plugin/tests/api/released/events.wit b/sdk/rust/openidea-plugin/tests/api/released/events.wit index 7da95eac5..0685cdf6f 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/events.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/events.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Workbench events, delivered in batches to `event-handler.handle`. interface events { diff --git a/sdk/rust/openidea-plugin/tests/api/released/exports.wit b/sdk/rust/openidea-plugin/tests/api/released/exports.wit index 808377432..311a61196 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/exports.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; interface lifecycle { use types.{context, error}; diff --git a/sdk/rust/openidea-plugin/tests/api/released/host.wit b/sdk/rust/openidea-plugin/tests/api/released/host.wit index c7ac7f916..357a9f53e 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/host.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/host.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Writes to the IDE log, tagged with the plugin id. interface log { @@ -17,16 +17,54 @@ interface ui { command: option, } + record pick-item { + /// Returned when the item is chosen. + key: string, + label: string, + description: option, + } + + /// A context key's value, as `when` clauses compare it. + variant context-value { + flag(bool), + /// A finite number. + number(f64), + /// At most 1024 bytes. + text(string), + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); + /// Sets the plugin's context key `key`, which `when` clauses test as + /// `plugin..`, in the window the current command or event came + /// from (every window when there is none). A window's value takes + /// precedence over the global one. `key` is 1-64 of `A-Za-z0-9_-`; at most + /// 100 keys; they go away when the plugin stops. + set-context: func(key: string, value: context-value) -> result<_, error>; + /// Removes the key `set-context` set in the same scope. + remove-context: func(key: string); /// Copies `text` to the system clipboard. Requires `clipboard`. copy-to-clipboard: func(text: string) -> result<_, error>; /// Opens an `http://` or `https://` URL in the user's default browser. open-url: func(url: string) -> result<_, error>; + /// Shows a list in the window the current command or event came from and + /// waits for the key of the chosen item, or `none` when the user dismisses + /// it. Needs no capability. Returns `invalid` outside a command or event of + /// a window (including in editor providers). Waiting doesn't count against + /// the call's budget. + pick: func(title: string, items: list) -> result, error>; + /// Like `pick`, with any number of items chosen. + pick-many: func(title: string, items: list) -> result>, error>; + /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. + /// Same rules as `pick`. + input: func(prompt: string, value: option) -> result, error>; + /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on + /// Cancel or Escape. Same rules as `pick`. + confirm: func(title: string, body: string) -> result; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/sdk/rust/openidea-plugin/tests/api/released/indexing.wit b/sdk/rust/openidea-plugin/tests/api/released/indexing.wit new file mode 100644 index 000000000..e9edc27d2 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api/released/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.2.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/sdk/rust/openidea-plugin/tests/api/released/io.wit b/sdk/rust/openidea-plugin/tests/api/released/io.wit index b93ea079e..89bbaadbf 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/io.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/io.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Problems in files, shown in the editor and the Problems view. interface diagnostics { diff --git a/sdk/rust/openidea-plugin/tests/api/released/plugin.wit b/sdk/rust/openidea-plugin/tests/api/released/plugin.wit index e9fc0ac85..12d2dc71b 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/plugin.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/plugin.wit @@ -7,7 +7,7 @@ /// or processes beyond WASI's clocks and randomness; everything else goes /// through the imports below, gated by the capabilities granted in /// `plugin.toml` (a missing one returns `error::denied`). -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; world plugin { import log; @@ -50,6 +50,12 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +71,8 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +} diff --git a/sdk/rust/openidea-plugin/tests/api/released/providers.wit b/sdk/rust/openidea-plugin/tests/api/released/providers.wit index 17c1123a8..00cb62a73 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/providers.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/providers.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Editor features a plugin provides for the languages it declares under /// `[[contributes.providers]]` (the host calls only declared kinds). They run diff --git a/sdk/rust/openidea-plugin/tests/api/released/types.wit b/sdk/rust/openidea-plugin/tests/api/released/types.wit index de35cadf6..9a59769a8 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/types.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/types.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Shared types. Positions are 0-based lines and UTF-16 code unit columns, /// as in the editor and LSP. diff --git a/sdk/rust/openidea-plugin/tests/api/released/views.wit b/sdk/rust/openidea-plugin/tests/api/released/views.wit index 8a67e222e..945b600f9 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/views.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/views.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; /// Declarative views: tool windows declared /// under `[[contributes.views]]` and custom editors under @@ -127,6 +127,9 @@ interface views { /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. refresh: func(view: string); + /// Brings one of the plugin's own views to the front in the window the + /// current command or event came from; `invalid` outside one. + reveal: func(view: string) -> result<_, error>; /// Sends a message to the script of a view's `html` body. post-message: func(view: string, message: string) -> result<_, error>; /// Opens a terminal tab in the window the call came from, running diff --git a/sdk/rust/openidea-plugin/tests/api/released/workspace.wit b/sdk/rust/openidea-plugin/tests/api/released/workspace.wit index 180040471..aa7214fb1 100644 --- a/sdk/rust/openidea-plugin/tests/api/released/workspace.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/workspace.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.2.0; interface workspace { use types.{error}; @@ -59,6 +59,11 @@ interface invocation { project: func() -> option; /// The window's focused editor. editor: func() -> option; + /// The files and folders the command was run on (a multi-selection in + /// a file tree, for instance), as absolute paths. + /// Empty when it was not run on any, or when the menu was for a tab + /// without a file (a virtual document). + selected-paths: func() -> list; } } diff --git a/sdk/rust/openidea-plugin/tests/wit_coverage.rs b/sdk/rust/openidea-plugin/tests/wit_coverage.rs index e3ce3db4b..01805561a 100644 --- a/sdk/rust/openidea-plugin/tests/wit_coverage.rs +++ b/sdk/rust/openidea-plugin/tests/wit_coverage.rs @@ -51,3 +51,32 @@ fn every_imported_function_has_an_sdk_wrapper() { assert!(checked > 30, "only {checked} functions found: is the world still called `plugin`?"); assert!(missing.is_empty(), "plugin API functions without an SDK wrapper:\n {}", missing.join("\n ")); } + +#[test] +fn every_optional_export_can_be_registered() { + let mut resolve = Resolve::default(); + let (pkg, _) = resolve.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("wit")).unwrap(); + let source = sdk_source(); + let base = &resolve.worlds[resolve.packages[pkg].worlds["plugin"]]; + let mut missing = Vec::new(); + let mut checked = 0; + for (name, &id) in &resolve.packages[pkg].worlds { + if !name.starts_with("plugin-") { + continue; + } + for (key, item) in &resolve.worlds[id].exports { + let WorldItem::Interface { id, .. } = item else { continue }; + if base.exports.contains_key(key) { + continue; + } + let module = resolve.interfaces[*id].name.as_deref().unwrap().replace('-', "_"); + let guest = format!("exports::openidea::plugin::{module}::Guest for"); + checked += 1; + if !source.contains(&guest) { + missing.push(format!("{name}: {module}")); + } + } + } + assert!(checked >= 5, "only {checked} optional exports found"); + assert!(missing.is_empty(), "optional exports register_plugin! can't export:\n {}", missing.join("\n ")); +} diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 7da95eac5..92175d555 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Workbench events, delivered in batches to `event-handler.handle`. interface events { diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit index 808377432..67d7c6060 100644 --- a/sdk/rust/openidea-plugin/wit/exports.wit +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; interface lifecycle { use types.{context, error}; diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index c7ac7f916..cda36d5d1 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Writes to the IDE log, tagged with the plugin id. interface log { @@ -17,16 +17,54 @@ interface ui { command: option, } + record pick-item { + /// Returned when the item is chosen. + key: string, + label: string, + description: option, + } + + /// A context key's value, as `when` clauses compare it. + variant context-value { + flag(bool), + /// A finite number. + number(f64), + /// At most 1024 bytes. + text(string), + } + /// Shows a notification balloon in the window the current command or /// event came from (every window when there is none). show-notification: func(level: notification-level, title: string, body: string); /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); + /// Sets the plugin's context key `key`, which `when` clauses test as + /// `plugin..`, in the window the current command or event came + /// from (every window when there is none). A window's value takes + /// precedence over the global one. `key` is 1-64 of `A-Za-z0-9_-`; at most + /// 100 keys; they go away when the plugin stops. + set-context: func(key: string, value: context-value) -> result<_, error>; + /// Removes the key `set-context` set in the same scope. + remove-context: func(key: string); /// Copies `text` to the system clipboard. Requires `clipboard`. copy-to-clipboard: func(text: string) -> result<_, error>; /// Opens an `http://` or `https://` URL in the user's default browser. open-url: func(url: string) -> result<_, error>; + /// Shows a list in the window the current command or event came from and + /// waits for the key of the chosen item, or `none` when the user dismisses + /// it. Needs no capability. Returns `invalid` outside a command or event of + /// a window (including in editor providers). Waiting doesn't count against + /// the call's budget. + pick: func(title: string, items: list) -> result, error>; + /// Like `pick`, with any number of items chosen. + pick-many: func(title: string, items: list) -> result>, error>; + /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. + /// Same rules as `pick`. + input: func(prompt: string, value: option) -> result, error>; + /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on + /// Cancel or Escape. Same rules as `pick`. + confirm: func(title: string, body: string) -> result; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/sdk/rust/openidea-plugin/wit/indexing.wit b/sdk/rust/openidea-plugin/wit/indexing.wit new file mode 100644 index 000000000..4ee08d64b --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.3.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit index b93ea079e..97111a625 100644 --- a/sdk/rust/openidea-plugin/wit/io.wit +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Problems in files, shown in the editor and the Problems view. interface diagnostics { diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index e9fc0ac85..559abf97e 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -7,7 +7,7 @@ /// or processes beyond WASI's clocks and randomness; everything else goes /// through the imports below, gated by the capabilities granted in /// `plugin.toml` (a missing one returns `error::denied`). -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; world plugin { import log; @@ -50,6 +50,24 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + +world plugin-semantic-tokens { + include plugin; + export semantic-tokens-provider; +} + +/// A plugin whose views show in the Services tool window +/// (`location = "services"`): their nodes' detail pane. +world plugin-services { + include plugin; + export service-provider; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +83,17 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +} + +world semantic-tokens { + export semantic-tokens-provider; +} + +/// Only the `service-provider` export, generated next to `plugin` like `syntax`. +world services { + export service-provider; +} diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit index 17c1123a8..2140eb742 100644 --- a/sdk/rust/openidea-plugin/wit/providers.wit +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Editor features a plugin provides for the languages it declares under /// `[[contributes.providers]]` (the host calls only declared kinds). They run diff --git a/sdk/rust/openidea-plugin/wit/semantic.wit b/sdk/rust/openidea-plugin/wit/semantic.wit new file mode 100644 index 000000000..99bf90d18 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/semantic.wit @@ -0,0 +1,20 @@ +package openidea:plugin@1.3.0; + +interface semantic-tokens-provider { + use types.{range}; + + record document { + path: string, + language: string, + version: u64, + text: string, + } + + record semantic-token { + range: range, + kind: string, + modifiers: list, + } + + semantic-tokens: func(doc: document) -> list; +} diff --git a/sdk/rust/openidea-plugin/wit/services.wit b/sdk/rust/openidea-plugin/wit/services.wit new file mode 100644 index 000000000..17d7eafa5 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/services.wit @@ -0,0 +1,50 @@ +package openidea:plugin@1.3.0; + +/// What the pane beside the Services tree shows for a plugin's node. +interface service-types { + /// A switch above a log; when on, `args` are appended and the program + /// starts again. + record log-toggle { + id: string, + label: string, + args: list, + } + + /// A program the IDE runs, its output followed in a log (ANSI stripped, + /// the last 5000 lines) until it exits or the pane closes. Needs the + /// plugin's `process:` grant; runs in the project root. + record process-log { + program: string, + args: list, + toggles: list, + } + + /// The body of a tab. + variant pane { + /// A centred message: an empty state or why there is nothing to show. + message(string), + /// Read-only text the user can select and copy, at most 1 MiB. + text(string), + log(process-log), + } + + /// A tab of a node's pane; the IDE switches tabs without calling the plugin. + record tab { + id: string, + label: string, + body: pane, + } +} + +/// The detail pane of the nodes of a plugin's `location = "services"` views. +/// The nodes themselves, the toolbar and their events come from +/// `view-provider` (`render` / `handle` of the view). +interface service-provider { + use types.{error}; + use invocation.{invocation}; + use service-types.{tab}; + + /// The tabs of node `key` of view `view` (at most 8), rendered when the + /// node is selected and again after `views.refresh(view)`. + detail: func(view: string, key: string, ctx: borrow) -> result, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/types.wit b/sdk/rust/openidea-plugin/wit/types.wit index de35cadf6..8114a2db0 100644 --- a/sdk/rust/openidea-plugin/wit/types.wit +++ b/sdk/rust/openidea-plugin/wit/types.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Shared types. Positions are 0-based lines and UTF-16 code unit columns, /// as in the editor and LSP. diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index 8a67e222e..6fa5e51c9 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; /// Declarative views: tool windows declared /// under `[[contributes.views]]` and custom editors under @@ -127,6 +127,9 @@ interface views { /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. refresh: func(view: string); + /// Brings one of the plugin's own views to the front in the window the + /// current command or event came from; `invalid` outside one. + reveal: func(view: string) -> result<_, error>; /// Sends a message to the script of a view's `html` body. post-message: func(view: string, message: string) -> result<_, error>; /// Opens a terminal tab in the window the call came from, running diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index 180040471..f1d21fad2 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.1.0; +package openidea:plugin@1.3.0; interface workspace { use types.{error}; @@ -59,6 +59,11 @@ interface invocation { project: func() -> option; /// The window's focused editor. editor: func() -> option; + /// The files and folders the command was run on (a multi-selection in + /// a file tree, for instance), as absolute paths. + /// Empty when it was not run on any, or when the menu was for a tab + /// without a file (a virtual document). + selected-paths: func() -> list; } } diff --git a/smoke/debug.test.ts b/smoke/debug.test.ts index 1e336b0e6..632304c4f 100644 --- a/smoke/debug.test.ts +++ b/smoke/debug.test.ts @@ -105,11 +105,13 @@ beforeAll(() => { }); afterAll(() => deactivate?.()); -afterEach(() => { +afterEach(async () => { resetRuns(); disconnect?.(); - server?.close(); - if (base) rmSync(base, { recursive: true, force: true }); + // `close` kills the server and what it started (debug adapters, language servers) + // and waits until they're gone, so the removal below can't race their writes. + await server?.close(); + if (base) rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }); async function open(c: Case): Promise { diff --git a/smoke/run.test.ts b/smoke/run.test.ts index 22dcce330..7a46ec00e 100644 --- a/smoke/run.test.ts +++ b/smoke/run.test.ts @@ -128,12 +128,14 @@ let server: Server | null = null; let disconnect: (() => void) | null = null; let base = ""; -afterEach(() => { +afterEach(async () => { // As switching projects does: run ids restart with each server. resetRuns(); disconnect?.(); - server?.close(); - if (base) rmSync(base, { recursive: true, force: true }); + await server?.close(); + // A language server's own process (jdtls's JVM) can outlive the server's for a moment: + // deleting its data directory has to wait for it. + if (base) rmSync(base, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 }); }); async function open(c: Case): Promise { @@ -200,7 +202,10 @@ async function smoke(language: string) { } // Tests, with the intentionally failing one switched on: one failure, located, the rest passed. - const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config))); + // Switched on in the saved configuration, as reruns launch the configuration as it is saved now. + const failing = c.tests.failing(config(c.tests.config)); + useRun.setState((s) => ({ configs: s.configs.map((x) => (x.id === failing.id ? failing : x)) })); + const { id, output } = await runToEnd(failing); const tests = runtime(id)!.tests!; const log = `\n--- output ---\n${output.slice(-80).join("\n")}`; expect(tests.counts(), log).toMatchObject({ passed: c.tests.passed, failed: 1 }); diff --git a/smoke/server.ts b/smoke/server.ts index 241e8130a..aa867c127 100644 --- a/smoke/server.ts +++ b/smoke/server.ts @@ -2,7 +2,7 @@ * `openidea-server --stdio`, spoken to as the desktop app does in remote development: * JSON-RPC with `Content-Length` framing over the server's stdin / stdout. */ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { type ChildProcessWithoutNullStreams, execFile, spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -23,6 +23,28 @@ interface Pending { reject: (error: unknown) => void; } +/** `taskkill /T /F`: the tree rooted at `pid` (see `Server.close`). Always resolves. */ +function treeKill(pid: number | undefined): Promise { + return new Promise((resolve) => { + if (pid === undefined) return resolve(); + execFile("taskkill", ["/pid", String(pid), "/T", "/F"], () => resolve()); + }); +} + +/** Resolves once no process group `pid` exists anymore (signal 0 only checks). */ +async function waitForGroupExit(pid: number, timeoutMs = 5000): Promise { + const start = Date.now(); + for (;;) { + try { + process.kill(-pid, 0); + } catch { + return; + } + if (Date.now() - start > timeoutMs) return; + await new Promise((r) => setTimeout(r, 50)); + } +} + export class Server { private nextId = 1; private readonly pending = new Map(); @@ -52,6 +74,9 @@ export class Server { const child = spawn(serverBinary(), ["--stdio"], { env: { ...process.env, OPENIDEA_SERVER_HOME: home, ...env }, stdio: ["pipe", "pipe", "pipe"], + // Its own process group: the language servers it starts (jdtls's JVM) stay in + // it, so `close` can reap them together with the server (see below). + detached: true, }); const server = new Server(child); await server.request("initialize", { version: PROTOCOL_VERSION }); @@ -78,9 +103,36 @@ export class Server { } } - close() { - this.child.stdin.end(); - this.child.kill(); + /** + * Kills the server and everything it started; resolves once they're all gone. + * The server alone is not enough: a language server's own process (jdtls's JVM) + * outlives it and keeps writing under the test's home directory, so deleting that + * directory afterwards raced its writes (`ENOTEMPTY` on teardown). The server runs + * detached (its own process group), so the group is only it and its children. + */ + async close(): Promise { + if (this.child.exitCode === null && !this.child.killed) { + const exited = new Promise((resolve) => this.child.once("exit", () => resolve())); + this.child.stdin.end(); + if (process.platform === "win32") { + // `child.kill()` stops only the server; take the whole tree while it is + // still enumerable, then wait for the exit below. + await treeKill(this.child.pid); + } else { + this.child.kill(); + } + await exited; + } + if (process.platform !== "win32" && this.child.pid !== undefined) { + // Reap what the server started: `SIGKILL` to the group, then wait until it is + // gone (termination is asynchronous; files held open can't be deleted meanwhile). + try { + process.kill(-this.child.pid, "SIGKILL"); + } catch { + // Nothing left running. + } + await waitForGroupExit(this.child.pid); + } } private request(method: string, params: unknown): Promise { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e78c99f0..15d00456c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,8 +82,7 @@ block2 = "0.6" # Plugin secrets in the login keychain. security-framework = "3.7" objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSResponder", "NSScreen", "NSView", "NSWindow"] } -objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } diff --git a/src-tauri/src/application_focus.rs b/src-tauri/src/application_focus.rs index 292370580..be929ce70 100644 --- a/src-tauri/src/application_focus.rs +++ b/src-tauri/src/application_focus.rs @@ -66,7 +66,7 @@ pub fn observe(app: &tauri::AppHandle) { } } -fn application_active(app: &tauri::AppHandle) -> bool { +pub(crate) fn application_active(app: &tauri::AppHandle) -> bool { // A settings window, utility window or native dialog is still this application. #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f26c37bf8..561665ee5 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -225,6 +225,18 @@ pub fn agent_cancel( agent::cancel(&state.host, window.label(), task, subagent) } +/// Stops one of the task's background tasks. +#[tauri::command] +#[specta::specta] +pub async fn agent_stop_background( + window: Window, + state: State<'_, AppState>, + task: String, + background: String, +) -> IdeResult<()> { + agent::stop_background(&state.host, window.label(), task, background).await +} + /// Answers a permission request; `option: null` cancels it. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index afed18d58..a817273da 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -85,10 +85,8 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); - let grouped = window.state::().groups.lock().group(window.label()).is_some(); - if grouped { - crate::window_groups::persist(window.app_handle()).await?; - } + // Every project window is part of the session reopened at the next start. + crate::window_groups::persist(window.app_handle()).await?; Ok(info) } @@ -131,7 +129,8 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) Ok(project_info(&project)) } -#[tauri::command] +// Not on the main thread: closing stops the project's language servers, runs and file watcher. +#[tauri::command(async)] #[specta::specta] pub fn close_project(window: Window, state: State<'_, AppState>) { crate::windows::close_auxiliary_windows(window.app_handle(), window.label()); @@ -315,6 +314,13 @@ pub async fn project_trust_set( Ok(()) } +#[tauri::command] +#[specta::specta] +pub async fn project_trust_add_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { + state.trust.add_location(&path)?; + state.host.refresh_project_trust().await +} + #[tauri::command] #[specta::specta] pub async fn project_trust_remove_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index fd157cad1..2e07f0f71 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -2,9 +2,10 @@ use crate::state::AppState; use ide_build::compile::{BuildEvent, BuildKind, BuildResult}; +use ide_build::maven::PomGoto; use ide_build::pom_edit::ArtifactKey; use ide_build::{BuildModel, BuildTool, SyncProgress}; -use ide_core::{IdeResult, Location, RequestId}; +use ide_core::{IdeResult, Location, Pos, RequestId}; #[allow(unused_imports)] pub use ide_host::ops::build::*; use tauri::ipc::Channel; @@ -114,6 +115,20 @@ pub async fn maven_dependency_declaration( ide_host::ops::build::maven_dependency_declaration(&state.host, window.label(), pom, dependency).await } +/// The go-to-declaration target at `pos` of a Maven POM: the definition of the +/// `${property}` at the caret, or the `` / `` +/// entry providing the dependency's / plugin's version, along the parent chain. +#[tauri::command] +#[specta::specta] +pub async fn maven_goto_declaration( + window: Window, + state: State<'_, AppState>, + pom: String, + pos: Pos, +) -> IdeResult> { + ide_host::ops::build::maven_goto_declaration(&state.host, window.label(), pom, pos).await +} + /// Adds an `` of `exclusion` to the declaration of the direct dependency /// `dependency` of the Maven module `pom`, through the open document when there is one /// (saved). Returns the declaration; nothing changes when it already excludes it. diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 522300641..4e7080c98 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -92,6 +92,13 @@ pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, t ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await } +/// Types `text` into the session's program (a `runInTerminal` program). +#[tauri::command] +#[specta::specta] +pub async fn debug_send_input(state: State<'_, AppState>, session: DebugSessionId, text: String) -> IdeResult<()> { + ide_host::ops::debug::debug_send_input(&state.host, "", session, text).await +} + #[tauri::command] #[specta::specta] pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 1db210fa4..14a51a69f 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -80,6 +80,23 @@ pub async fn lsp_goto( ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await } +/// Go to Implementation of the symbol at `pos`. Implementations found before a +/// slower search for the rest arrive on `on_partial`; the result has them all. +/// Cancel with `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn lsp_goto_implementation( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + pos: Pos, + on_partial: tauri::ipc::Channel>, +) -> IdeResult> { + let on_partial = super::sender(on_partial); + ide_host::ops::lsp::lsp_goto_implementation(&state.host, window.label(), request, doc, pos, on_partial).await +} + /// Implementations of the symbols at `positions`, one list per position (the /// editor's "is implemented / overridden" gutter markers). Cancel with /// `cancel_request(request)`. @@ -119,9 +136,13 @@ pub async fn lsp_resolve_class_file( /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols -/// leave it off. Cancel with `cancel_request(request)`. +/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK +/// are not searched; with it, those found in the sources arrive on `on_partial` before +/// the slower search for the rest, and the result has them all. Cancel with +/// `cancel_request(request)`. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn lsp_references( window: Window, state: State<'_, AppState>, @@ -130,7 +151,10 @@ pub async fn lsp_references( pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, + on_partial: tauri::ipc::Channel, ) -> IdeResult { + let on_partial = super::sender(on_partial); ide_host::ops::lsp::lsp_references( &state.host, window.label(), @@ -139,6 +163,8 @@ pub async fn lsp_references( pos, include_declaration, project_wide, + libraries, + on_partial, ) .await } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8a33b8ffd..447cd5052 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -75,6 +75,7 @@ pub fn builder() -> tauri_specta::Builder { app::project_trust_status, app::project_trust_list, app::project_trust_set, + app::project_trust_add_location, app::project_trust_remove_location, workspace::workspace_create, workspace::workspace_attach, @@ -120,6 +121,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_rollback, agent::agent_probe, agent::agent_cancel, + agent::agent_stop_background, agent::agent_respond, agent::agent_archive, agent::agent_unarchive, @@ -219,6 +221,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_signature_help, lsp::lsp_goto, lsp::lsp_implementations, + lsp::lsp_goto_implementation, lsp::lsp_virtual_document, lsp::lsp_resolve_class_file, lsp::lsp_references, @@ -277,8 +280,12 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_checkout, vcs::vcs_delete_branch, vcs::vcs_restore_branch, + vcs::vcs_delete_tag, + vcs::vcs_restore_tag, vcs::vcs_create_branch, vcs::vcs_undo_commit, + vcs::vcs_reword, + vcs::vcs_restore_head, vcs::vcs_rename_branch, vcs::vcs_merge, vcs::vcs_rebase, @@ -309,6 +316,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_compare_scope, vcs::vcs_compare_paths, vcs::vcs_diff_revisions, + vcs::vcs_diff_numstat, + vcs::vcs_has_hook, vcs::vcs_merge_base, vcs::vcs_create_patch, vcs::vcs_patch_preview, @@ -366,6 +375,7 @@ pub fn builder() -> tauri_specta::Builder { build::gradle_published_catalog, build::maven_dependency_declaration, build::maven_exclude_dependency, + build::maven_goto_declaration, build::build_run, java::java_resolve_launch, java::java_debug_server, @@ -380,6 +390,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_completions, debug::debug_resume, debug::debug_pause, + debug::debug_send_input, debug::debug_hot_reload, debug::debug_hot_restart, debug::debug_step, @@ -397,7 +408,10 @@ pub fn builder() -> tauri_specta::Builder { tools::jdk_download, sdk::sdk_detect, sdk::sdk_python_effective, + sdk::sdk_php_effective, + sdk::sdk_php_xdebug, sdk::sdk_php_language_level, + sdk::sdk_php_composer_install, sdk::sdk_project_libraries, sdk::sdk_locate_tools, sdk::sdk_python_create_venv, @@ -413,6 +427,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_open_deployment, window::window_cycle, window::window_toggle_fullscreen, + window::window_flash_taskbar, window::window_set_titlebar_height, window_drag::window_start_drag, window::window_take_launch, @@ -469,13 +484,22 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_post_events, plugin::plugin_event_kinds, plugin::plugin_status_items, + plugin::plugin_context_keys, plugin::plugin_cancel_task, + plugin::plugin_answer_prompt, plugin::plugin_view_render, plugin::plugin_view_event, + plugin::plugin_service_detail, + plugin::plugin_service_log, plugin::plugin_web_page, plugin::plugin_web_save_file, plugin::plugin_web_read_file, plugin::plugin_line_markers, + plugin::plugin_navigation_files, + plugin::plugin_navigation_invalidate, + plugin::plugin_navigation_index, + plugin::plugin_navigation_goto, + plugin::plugin_navigation_markers, plugin::plugin_market, plugin::plugin_market_url, plugin::plugin_set_market_url, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index bb565af4f..33598f630 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -8,8 +8,8 @@ use ide_core::{IdeError, IdeResult}; use ide_extension::ActivationEvent; use ide_host::plugins::intellij::{self, InstalledIntellijTheme, IntellijThemeSource}; use ide_host::plugins::{ - ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PackageFile, PluginEditor, - PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, + ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PackageFile, PluginContextKey, + PluginEditor, PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent, }; use ide_plugin::{Invocation, Limits}; use parking_lot::Mutex; @@ -325,6 +325,7 @@ pub async fn plugin_run_command( command_id: String, arg: Option, editor: Option, + paths: Option>, ) -> IdeResult> { let host = state.plugins.clone(); let project = state.project(window.label()).ok(); @@ -332,6 +333,7 @@ pub async fn plugin_run_command( client: Some(window.label().to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), + selected_paths: paths.unwrap_or_default(), }; blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await } @@ -369,6 +371,13 @@ pub fn plugin_status_items(state: State<'_, AppState>) -> Vec .collect() } +/// Plugin context keys in effect for this window (later changes arrive as `contextChanged`). +#[tauri::command] +#[specta::specta] +pub fn plugin_context_keys(window: Window, state: State<'_, AppState>) -> Vec { + state.plugins.context_keys(Some(window.label())) +} + /// Cancels a plugin task (`taskStarted` with `cancellable`). #[tauri::command] #[specta::specta] @@ -376,6 +385,14 @@ pub fn plugin_cancel_task(state: State<'_, AppState>, task_id: String) -> bool { state.plugins.cancel_task(&task_id) } +/// Reports the user's answer to a plugin's `promptRequested`: the chosen keys +/// (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed. +#[tauri::command] +#[specta::specta] +pub fn plugin_answer_prompt(state: State<'_, AppState>, prompt_id: String, answer: Option>) -> bool { + state.plugins.answer_prompt(&prompt_id, answer) +} + /// Gutter markers of plugin `line-markers` providers for an open document. #[tauri::command] #[specta::specta] @@ -420,6 +437,54 @@ pub async fn plugin_view_event( ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc, editor).await } +/// The Services pane of node `key` of a plugin's `location = "services"` view. +#[tauri::command] +#[specta::specta] +pub async fn plugin_service_detail( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + key: String, +) -> IdeResult> { + let plugins = state.plugins.clone(); + ide_host::plugins::views::service_detail(&state.host, plugins, window.label(), plugin_id, view, key).await +} + +/// Runs the program of the log tab `tab` of node `key`'s Services pane (with +/// the toggles in `on`), streaming its output until it exits or `request` is +/// cancelled; returns the exit code. +#[tauri::command] +#[specta::specta] +#[allow(clippy::too_many_arguments)] +pub async fn plugin_service_log( + window: Window, + state: State<'_, AppState>, + request: ide_core::RequestId, + plugin_id: String, + view: String, + key: String, + tab: String, + on: Vec, + on_line: Channel, +) -> IdeResult> { + let plugins = state.plugins.clone(); + let on_line = super::sender(on_line); + ide_host::plugins::views::service_log( + &state.host, + plugins, + window.label(), + plugin_id, + view, + key, + tab, + on, + request, + on_line, + ) + .await +} + /// The page of a plugin's web view or custom editor (`web = …`). #[tauri::command] #[specta::specta] @@ -633,3 +698,67 @@ pub fn plugin_set_theme( pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option) -> IdeResult<()> { state.plugins.set_theme_frame(color.as_deref()) } + +use ide_host::plugins::index_ops::NavigationFiles; + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_files(window: Window, state: State<'_, AppState>) -> IdeResult { + ide_host::plugins::index_ops::plugin_navigation_files(&state.host, state.plugins.clone(), window.label()).await +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_navigation_invalidate( + window: Window, + state: State<'_, AppState>, + paths: Option>, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_invalidate( + &state.host, + state.plugins.clone(), + window.label(), + paths, + ) +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_index( + window: Window, + state: State<'_, AppState>, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_index( + &state.host, + state.plugins.clone(), + window.label(), + paths, + request, + ) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_goto( + window: Window, + state: State<'_, AppState>, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_goto(&state.host, state.plugins.clone(), window.label(), doc, pos) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_markers( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_markers(&state.host, state.plugins.clone(), window.label(), paths) + .await +} diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index db2692f5d..a8053f8c4 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -20,8 +20,32 @@ pub async fn sdk_detect(window: Window, state: State<'_, AppState>, kind: SdkKin /// The interpreter the project uses: the configured one, else its virtualenv. #[tauri::command] #[specta::specta] -pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeResult> { - ide_host::ops::sdk::sdk_python_effective(&state.host, window.label()) +pub fn sdk_python_effective( + window: Window, + state: State<'_, AppState>, + near: Option, +) -> IdeResult> { + ide_host::ops::sdk::sdk_python_effective(&state.host, window.label(), near) +} + +/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. +#[tauri::command] +#[specta::specta] +pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::sdk::sdk_php_effective(&state.host, window.label()) +} + +/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the +/// project root). +#[tauri::command] +#[specta::specta] +pub async fn sdk_php_xdebug( + window: Window, + state: State<'_, AppState>, + php: PathBuf, + cwd: Option, +) -> IdeResult { + ide_host::ops::sdk::sdk_php_xdebug(&state.host, window.label(), php, cwd).await } /// The PHP language level of the project `path` belongs to: the effective one and Auto's. @@ -35,6 +59,17 @@ pub async fn sdk_php_language_level( ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await } +/// The Composer project `path` belongs to, when its dependencies need `composer install`. +#[tauri::command] +#[specta::specta] +pub async fn sdk_php_composer_install( + window: Window, + state: State<'_, AppState>, + path: PathBuf, +) -> IdeResult> { + ide_host::ops::sdk::sdk_php_composer_install(&state.host, window.label(), path).await +} + /// SDK / library source folders shown under "External Libraries" in the Project view, /// with the Maven / Gradle dependency jars of the build model. #[tauri::command] @@ -58,12 +93,13 @@ pub async fn sdk_locate_tools( ide_host::ops::sdk::sdk_locate_tools(&state.host, window.label(), names, near).await } -/// `base -m venv location`; output lines stream through `on_output`. +/// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`. #[tauri::command] #[specta::specta] pub async fn sdk_python_create_venv( window: Window, state: State<'_, AppState>, + tool: ide_sdk::python::VenvTool, base: PathBuf, location: PathBuf, request: RequestId, @@ -72,6 +108,7 @@ pub async fn sdk_python_create_venv( ide_host::ops::sdk::sdk_python_create_venv( &state.host, window.label(), + tool, base, location, request, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index ccb6c016b..29cc34b92 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -285,6 +285,33 @@ pub async fn vcs_undo_commit( ide_host::ops::vcs::vcs_undo_commit(&state.host, window.label(), repo, commit).await } +/// Edit Commit Message: rewrites the commit and its descendants without touching the +/// working tree. +#[tauri::command] +#[specta::specta] +pub async fn vcs_reword( + window: Window, + state: State<'_, AppState>, + repo: Option, + commit: String, + message: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_reword(&state.host, window.label(), repo, commit, message).await +} + +/// Undo of a history rewrite (Edit Commit Message, Drop, Squash). +#[tauri::command] +#[specta::specta] +pub async fn vcs_restore_head( + window: Window, + state: State<'_, AppState>, + repo: Option, + before: String, + after: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_restore_head(&state.host, window.label(), repo, before, after).await +} + #[tauri::command] #[specta::specta] pub async fn vcs_delete_branch( @@ -296,6 +323,30 @@ pub async fn vcs_delete_branch( ide_host::ops::vcs::vcs_delete_branch(&state.host, window.label(), repo, branch).await } +/// Tag › Delete: the result is what "Restore" needs. +#[tauri::command] +#[specta::specta] +pub async fn vcs_delete_tag( + window: Window, + state: State<'_, AppState>, + repo: Option, + name: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_delete_tag(&state.host, window.label(), repo, name).await +} + +/// "Restore" of the tag-deleted notification. +#[tauri::command] +#[specta::specta] +pub async fn vcs_restore_tag( + window: Window, + state: State<'_, AppState>, + repo: Option, + deleted: ide_vcs::DeletedTag, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_restore_tag(&state.host, window.label(), repo, deleted).await +} + /// "Restore" of the branch-deleted notification. #[tauri::command] #[specta::specta] @@ -631,6 +682,33 @@ pub async fn vcs_diff_revisions( ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await } +/// Added and deleted lines between two revisions (Git Log › lines added and deleted). +/// `paths` limits the diff to those repository-relative paths; empty diffs every file. +#[tauri::command] +#[specta::specta] +pub async fn vcs_diff_numstat( + window: Window, + state: State<'_, AppState>, + repo: Option, + from: String, + to: String, + paths: Vec, +) -> IdeResult> { + ide_host::ops::vcs::vcs_diff_numstat(&state.host, window.label(), repo, from, to, paths).await +} + +/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). +#[tauri::command] +#[specta::specta] +pub async fn vcs_has_hook( + window: Window, + state: State<'_, AppState>, + repo: Option, + name: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_has_hook(&state.host, window.label(), repo, name).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index ad0f8c115..fb1534d85 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -127,6 +127,21 @@ pub fn window_toggle_fullscreen(window: WebviewWindow) -> IdeResult { crate::window_groups::toggle_fullscreen(&window) } +/// Flash (`true`) or stop flashing (`false`) this window's taskbar button, like IDEA when a +/// breakpoint is hit while the window is in the background (macOS bounces the dock icon, +/// Linux sets the urgency hint). A focused window is never flashed: the user is already +/// looking at it, and re-flashing it would steal attention for nothing. +#[tauri::command] +#[specta::specta] +pub fn window_flash_taskbar(window: WebviewWindow, flash: bool) -> IdeResult<()> { + if flash && window.is_focused().unwrap_or(false) { + return Ok(()); + } + window + .request_user_attention(flash.then_some(tauri::UserAttentionType::Critical)) + .map_err(|e| IdeError::internal(e.to_string())) +} + /// Height of the web title bar in native logical pixels (CSS height × IDE zoom). #[tauri::command] #[specta::specta] @@ -214,7 +229,9 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) { }); } -/// Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it. +/// Sets the menu's labels / shortcut text and rebuilds it when they changed. +/// Focusing another project window re-pushes the same shared menu; an identical +/// config must not call `set_menu`, which redraws the macOS menu bar. #[tauri::command] #[specta::specta] pub fn menu_configure( @@ -223,8 +240,9 @@ pub fn menu_configure( state: State<'_, AppState>, config: MenuConfig, ) { - *menu_state.config.lock() = config; - menu::install(&app, &menu_state, &state.recent.list()); + if menu::store_config(&menu_state, config) { + menu::install(&app, &menu_state, &state.recent.list()); + } } /// Rebuilds the menu (e.g. after the recent-projects list changed). @@ -330,36 +348,74 @@ fn update_endpoint_env() -> Option { /// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by /// default, and "Check for Updates…" is hidden then. pub fn updates_configured(app: &AppHandle) -> bool { - let in_config = app - .config() + !config_endpoints(app).is_empty() || update_endpoint_env().is_some() +} + +/// Where updates are downloaded from (System Settings › Updates). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum UpdateSource { + /// The GitHub releases named in tauri.conf.json. + #[default] + Github, + /// The CNB mirror of the same releases, for networks where GitHub is slow. + Cnb, +} + +const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json"; + +fn config_endpoints(app: &AppHandle) -> Vec { + app.config() .plugins .0 .get("updater") .and_then(|u| u.get("endpoints")) .and_then(|e| e.as_array()) - .is_some_and(|e| !e.is_empty()); - in_config || update_endpoint_env().is_some() + .map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect()) + .unwrap_or_default() } -fn updater(app: &AppHandle) -> IdeResult> { +/// The endpoints tried in order: `source` first, the other source when it fails or has +/// no manifest. Bundles are signed, so either source's manifest and downloads verify. +/// No official endpoint (a build without updates) means no mirror either. +fn source_endpoints(official: Vec, source: UpdateSource) -> Vec { + if official.is_empty() { + return official; + } + let mirror = vec![CNB_ENDPOINT.to_string()]; + match source { + UpdateSource::Github => [official, mirror].concat(), + UpdateSource::Cnb => [mirror, official].concat(), + } +} + +fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult> { use tauri_plugin_updater::UpdaterExt; if !updates_configured(app) { return Ok(None); } - let env = update_endpoint_env(); + let endpoints = match update_endpoint_env() { + Some(url) => vec![url], + None => source_endpoints(config_endpoints(app), source), + }; + let endpoints = endpoints + .iter() + .map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}")))) + .collect::>>()?; let proxy = ide_core::proxy_settings::proxy(); - let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy)); - if let Some(url) = env { - let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?; - builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?; - } - builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string())) + app.updater_builder() + .configure_client(move |b| ide_http::client::with_proxy(b, &proxy)) + .endpoints(endpoints) + .map_err(|e| IdeError::invalid(e.to_string()))? + .build() + .map(Some) + .map_err(|e| IdeError::unavailable(e.to_string())) } #[tauri::command] #[specta::specta] -pub async fn app_check_update(app: AppHandle) -> IdeResult { - let Some(updater) = updater(&app)? else { +pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult { + let Some(updater) = updater(&app, source)? else { return Ok(UpdateCheck { enabled: false, update: None }); }; let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?; @@ -379,12 +435,12 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult { /// not reported: the user didn't ask for this check. #[tauri::command] #[specta::specta] -pub async fn app_startup_update_check(app: AppHandle) -> Option { +pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option { static DONE: AtomicBool = AtomicBool::new(false); if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) { return None; } - match app_check_update(app).await { + match app_check_update(app, source).await { Ok(check) => check.update, Err(e) => { tracing::warn!("startup update check failed: {e}"); @@ -452,6 +508,7 @@ impl Drop for InstallingGuard { pub async fn app_install_update( app: AppHandle, state: State<'_, AppState>, + source: UpdateSource, request: RequestId, on_progress: Channel, ) -> IdeResult<()> { @@ -459,7 +516,7 @@ pub async fn app_install_update( InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?; let token = state.host.begin_request(request); let result = async { - let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?; + let updater = updater(&app, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?; let update = tokio::select! { r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?, _ = token.cancelled() => return Err(IdeError::cancelled()), @@ -522,6 +579,15 @@ mod tests { assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some()); } + #[test] + fn chosen_source_is_tried_first_and_the_other_after_it() { + let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string(); + assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]); + assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]); + // A build without an update endpoint stays without one. + assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty()); + } + #[test] fn only_one_install_at_a_time() { let first = InstallingGuard::acquire().expect("first install starts"); diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index ef2576f85..2fbdc44a9 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -1,5 +1,5 @@ use ide_core::{EventSink, IdeEvent}; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager, UserAttentionType}; /// Name of the single backend → UI event. Payload: [`IdeEvent`]. pub const IDE_EVENT: &str = "ide://event"; @@ -38,8 +38,31 @@ pub struct TauriSink { pub window: Option, } +/// A debug session stopped on a breakpoint: bounce/flash the taskbar icon +/// (IDEA) unless the user is already looking at an OpenIDEA window. The focus +/// check and the request both touch the windowing system, so they run on the +/// main thread; `emit` itself can come from any debug worker thread. +fn flash_taskbar_unless_focused(app: &AppHandle) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if crate::application_focus::application_active(&handle) { + return; + } + for window in handle.webview_windows().values() { + let _ = window.request_user_attention(Some(UserAttentionType::Informational)); + } + }); +} + impl EventSink for TauriSink { fn emit(&self, event: IdeEvent) { + if matches!( + &event, + IdeEvent::Debug { event: ide_core::event::DebugEvent::Stopped { reason, .. }, .. } + if matches!(reason.as_str(), "breakpoint" | "function breakpoint") + ) { + flash_taskbar_unless_focused(&self.app); + } let result = match &self.window { Some(label) => { for target in crate::windows::document_windows(&self.app, label) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f7188edc..bee4317f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ mod dev; mod events; mod jump_list; #[cfg(any(target_os = "linux", test))] +mod linux_webkit; +#[cfg(any(target_os = "linux", test))] mod linux_window_state; mod logging; #[cfg(target_os = "macos")] @@ -68,6 +70,8 @@ pub fn run() { // Before the first window: one taskbar identity for every launch path. jump_list::set_app_id(&context.config().identifier); logging::init(&context.config().identifier); + #[cfg(target_os = "linux")] + linux_webkit::configure(); let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. @@ -95,13 +99,14 @@ pub fn run() { #[cfg(target_os = "linux")] let app = app.plugin(linux_window_state::plugin()); #[cfg(not(target_os = "linux"))] - let app = app.plugin( - tauri_plugin_window_state::Builder::default() - .with_state_flags(window_state_flags()) - // One remembered geometry per kind of auxiliary window (all projects' settings windows share one). - .map_label(windows::state_key) - .build(), - ); + let window_state = tauri_plugin_window_state::Builder::default() + .with_state_flags(window_state_flags()) + // One remembered geometry per kind of auxiliary window (all projects' settings windows share one). + .map_label(windows::state_key); + #[cfg(target_os = "macos")] + let window_state = window_state.with_filter(windows::keeps_window_state); + #[cfg(not(target_os = "linux"))] + let app = app.plugin(window_state.build()); let app = app // Inert until an endpoint is configured (see `commands::window::app_check_update`). .plugin(tauri_plugin_updater::Builder::new().build()); @@ -158,11 +163,19 @@ pub fn run() { builder.mount_events(app); let state = AppState::new(app.handle().clone())?; let windows = WindowsState::default(); - if let Ok(cwd) = std::env::current_dir() - && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) - { - windows::remember_cli_position(&windows, &std::env::args().collect::>(), &path); - windows.set_launch("main", windows::path_launch(path)); + if let Ok(cwd) = std::env::current_dir() { + let args = std::env::args().collect::>(); + match windows::cli_launch(&args, &cwd) { + Some(windows::CliLaunch::Path(path)) => { + windows::remember_cli_position(&windows, &args, &path); + windows.set_launch("main", windows::path_launch(path)); + } + // `openidea diff a.txt b.txt`: the main window opens the pair in the diff viewer. + Some(windows::CliLaunch::Diff(diff)) => { + windows.set_launch("main", windows::Launch { diff: Some(Box::new(diff)), ..Default::default() }); + } + None => {} + } } let menu_state = menu::MenuState::default(); menu::install(app.handle(), &menu_state, &state.recent.list()); @@ -170,6 +183,8 @@ pub fn run() { app.manage(windows); app.manage(application_focus::ApplicationFocus::default()); #[cfg(target_os = "macos")] + macos::intercept_system_quit(app.handle()); + #[cfg(target_os = "macos")] application_focus::observe(app.handle()); app.manage(menu_state); if let Some(paths) = setup_opens.lock().take() { @@ -194,16 +209,12 @@ pub fn run() { window_groups::frame_changed(window.app_handle(), window.label()); } tauri::WindowEvent::Destroyed => { - #[cfg(target_os = "macos")] - { - titlebar::forget(window); - macos::forget_fullscreen(window.label()); - } if windows::auxiliary_parent(window.label()).is_none() { window_groups::destroyed(window.app_handle(), window.label()); windows::close_auxiliary_windows(window.app_handle(), window.label()); if let Some(state) = window.try_state::() { state.close_project(window.label()); + state.plugins.forget_client(window.label()); } } if let Some(w) = window.try_state::() { @@ -231,6 +242,7 @@ pub fn run() { let _ = &early_opens; if let tauri::RunEvent::Exit = event { if let Some(state) = app.try_state::() { + state.host.stop_processes_on_exit(); state.host.clear_workspace_edit_history_on_exit(); state.host.shutdown_notebooks_on_exit(); } @@ -245,12 +257,25 @@ pub fn run() { #[cfg(not(target_os = "linux"))] fn window_state_flags() -> tauri_plugin_window_state::StateFlags { use tauri_plugin_window_state::StateFlags; - StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN + #[cfg(target_os = "windows")] + { + // Start at the configured size, centered, instead of restoring geometry after the + // window is visible (Windows briefly showed the default position before that move). + StateFlags::MAXIMIZED | StateFlags::FULLSCREEN + } + #[cfg(not(target_os = "windows"))] + { + StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN + } } /// Routes IDE menu items to the focused window's frontend (`executeAction`). fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { - let id = event.id().as_ref(); + dispatch_menu_action(app, event.id().as_ref()); +} + +/// Runs menu item `id` as if it were chosen: in the focused window's frontend, or here without one. +pub(crate) fn dispatch_menu_action(app: &tauri::AppHandle, id: &str) { let Some(route) = menu::route(&menu::spec(menu::Os::current()), id) else { return }; match windows::target_window(app) { Some(w) => { diff --git a/src-tauri/src/linux_webkit.rs b/src-tauri/src/linux_webkit.rs new file mode 100644 index 000000000..9e20f6f09 --- /dev/null +++ b/src-tauri/src/linux_webkit.rs @@ -0,0 +1,51 @@ +//! WebKitGTK's DMA-BUF renderer fails on Wayland with the NVIDIA driver: the window +//! never shows and GDK logs "Error 71 (Protocol error) dispatching to Wayland display". +//! On that combination the renderer is turned off before the first web view is created. + +const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; + +/// Run before other threads start (`set_var`). +#[cfg(target_os = "linux")] +pub fn configure() { + let nvidia = ["/proc/driver/nvidia/version", "/sys/module/nvidia"].iter().any(|p| std::path::Path::new(p).exists()); + if disable_dmabuf(|k| std::env::var(k).ok(), nvidia) { + tracing::info!("Wayland with the NVIDIA driver: {DISABLE_DMABUF}=1"); + unsafe { std::env::set_var(DISABLE_DMABUF, "1") }; + } +} + +/// A Wayland session on the NVIDIA driver, unless the user already set the variable. +fn disable_dmabuf(var: impl Fn(&str) -> Option, nvidia: bool) -> bool { + if !nvidia || var(DISABLE_DMABUF).is_some() { + return false; + } + let wayland_display = var("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()); + // GDK tries the backends of `GDK_BACKEND` in order. + let gdk_wayland = + var("GDK_BACKEND").is_none_or(|b| matches!(b.split(',').next().map(str::trim), Some("wayland" | "*" | ""))); + wayland_display && gdk_wayland +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decide(vars: &[(&str, &str)], nvidia: bool) -> bool { + disable_dmabuf(|k| vars.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string()), nvidia) + } + + #[test] + fn turns_the_dmabuf_renderer_off_on_wayland_with_nvidia_only() { + assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0")], true)); + assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "wayland,x11")], true)); + assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0")], false)); + assert!(!decide(&[("DISPLAY", ":0")], true)); + assert!(!decide(&[("WAYLAND_DISPLAY", "")], true)); + assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "x11")], true)); + } + + #[test] + fn keeps_the_users_own_setting() { + assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), (DISABLE_DMABUF, "0")], true)); + } +} diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index dcb8bc5f5..3d32a8de1 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -7,8 +7,9 @@ use objc2::{ rc::Retained, runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel}, }; -use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; -use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; +use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference}; +use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect}; +use std::cell::Cell; use tauri::{AppHandle, Manager, WebviewWindow}; fn tabbing_preference(app: &AppHandle) -> IdeResult { @@ -46,9 +47,8 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult(); host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some() }); - // A project-tab group is fullscreen through simple fullscreen, which the native flag misses. - let fullscreen = - active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false) || is_simple_fullscreen(w.label())); + // A group member other than its fullscreen host is not itself in native fullscreen. + let fullscreen = active.as_ref().is_some_and(|w| crate::window_groups::in_fullscreen(app, w)); let light_edit = launch.project.is_none() && launch.remote.is_none(); Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit) .then_some(active) @@ -65,32 +65,103 @@ impl Drop for FullscreenObserver { } thread_local! { - static FULLSCREEN_TRANSFERS: std::cell::RefCell> = std::cell::RefCell::default(); + /// Fullscreen hosts of window groups, watched for leaving fullscreen. + static FULLSCREEN_HOSTS: std::cell::RefCell> = std::cell::RefCell::default(); } -pub fn forget_fullscreen(label: &str) { - FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label)); +/// Stops watching `label`; true when it hosted a fullscreen window group. +pub fn forget_fullscreen_host(label: &str) -> bool { + FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.remove(label)).is_some() } -/// AppKit cannot enter another window's fullscreen Space until the old one has exited. -pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { - let source = from.label().to_owned(); - let to = to.clone(); - from.with_webview(move |webview| unsafe { - let native = &*webview.ns_window().cast::(); - forget_fullscreen(&source); - let label = source.clone(); - let block = block2::RcBlock::new(move |_| { - forget_fullscreen(&label); - let app = to.app_handle(); - let selected = app.state::().groups.lock().group(to.label()).map(|g| g.selected.clone()); - if let Some(selected) = selected { - // Exiting fullscreen can show the old window again; reapply group visibility. - let _ = crate::window_groups::select(app, &selected, &selected); - if selected == to.label() { - let _ = to.set_fullscreen(true); - } +/// Runs `f` on the main thread before returning, so it takes effect before what the caller does next. +fn on_main(window: &WebviewWindow, f: impl FnOnce() -> R + Send + 'static) -> tauri::Result { + if MainThreadMarker::new().is_some() { + return Ok(f()); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + window.run_on_main_thread(move || { + let _ = tx.send(f()); + })?; + rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage) +} + +/// The native `toggleFullScreen:`, bypassing the group handling installed on the window class. +pub fn native_toggle_fullscreen(window: &WebviewWindow) -> tauri::Result<()> { + let target = window.clone(); + on_main(window, move || { + let (Ok(pointer), Some(native)) = (target.ns_window(), NATIVE_TOGGLE_FULLSCREEN.get()) else { return }; + unsafe { native(pointer.cast(), objc2::sel!(toggleFullScreen:), std::ptr::null_mut()) }; + }) +} + +/// A window group in fullscreen shares its host's Space: every other member joins it as an +/// auxiliary window over the host's frame, without a frame, window buttons or dragging, like the host. +/// Without a host the window is an ordinary one again. +pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWindow>) -> tauri::Result<()> { + use objc2_app_kit::{NSWindowButton, NSWindowCollectionBehavior as B}; + let target = window.clone(); + let host = host.cloned(); + on_main(window, move || { + let Ok(pointer) = target.ns_window() else { return }; + let native = unsafe { &*pointer.cast::() }; + let frame = + host.and_then(|host| host.ns_window().ok()).map(|host| unsafe { &*host.cast::() }.frame()); + let mut behavior = native.collectionBehavior(); + if frame.is_some() { + behavior.remove(B::FullScreenPrimary); + behavior.insert(B::FullScreenAuxiliary | B::MoveToActiveSpace); + } else { + behavior.remove(B::FullScreenAuxiliary | B::MoveToActiveSpace); + behavior.insert(B::FullScreenPrimary); + } + native.setCollectionBehavior(behavior); + // Without a title bar the window has square corners and no frame, like the host. + let mut style = native.styleMask(); + style.set(objc2_app_kit::NSWindowStyleMask::Titled, frame.is_none()); + native.setStyleMask(style); + native.setMovable(frame.is_none()); + // Shown over the host it must appear at once, as a tab does, not zoom in like a new window. + native.setAnimationBehavior(if frame.is_some() { + objc2_app_kit::NSWindowAnimationBehavior::None + } else { + objc2_app_kit::NSWindowAnimationBehavior::Default + }); + for kind in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] { + if let Some(button) = native.standardWindowButton(kind) { + button.setHidden(frame.is_some()); } + } + if let Some(frame) = frame { + native.setFrame_display(frame, true); + } + }) +} + +/// Puts a group member shown in its host's fullscreen Space in front of the host, which AppKit +/// otherwise keeps on top of the other windows in its Space. +pub fn raise_over_host(window: &WebviewWindow, host: &WebviewWindow) -> tauri::Result<()> { + let target = window.clone(); + let host = host.clone(); + on_main(window, move || { + let (Ok(pointer), Ok(host)) = (target.ns_window(), host.ns_window()) else { return }; + let native = unsafe { &*pointer.cast::() }; + let host = unsafe { &*host.cast::() }; + native.orderWindow_relativeTo(objc2_app_kit::NSWindowOrderingMode::Above, host.windowNumber()); + }) +} + +/// Watches the group's fullscreen `host` and runs `left` once it is back on the desktop. +pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'static) -> tauri::Result<()> { + let label = host.label().to_owned(); + let target = host.clone(); + on_main(host, move || unsafe { + let Ok(pointer) = target.ns_window() else { return }; + let native = &*pointer.cast::(); + let key = label.clone(); + let block = block2::RcBlock::new(move |_| { + forget_fullscreen_host(&key); + left(); }); let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( Some(objc2_app_kit::NSWindowDidExitFullScreenNotification), @@ -98,103 +169,97 @@ pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::R None, &block, ); - FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer))); - native.toggleFullScreen(None); + FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer))); }) } -/// Windows in simple fullscreen. Grouped project windows take turns in one Space; simple -/// fullscreen covers the screen without a Space of its own, so switching projects is a plain -/// show/hide instead of leaving one fullscreen Space and entering another. -static SIMPLE_FULLSCREEN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +/// An observer on the workspace's notification center, removed when dropped. +struct WorkspaceObserver(Retained, Retained>); -pub fn is_simple_fullscreen(label: &str) -> bool { - SIMPLE_FULLSCREEN.lock().unwrap().iter().any(|l| l == label) -} - -pub fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> tauri::Result<()> { - if !on { - // Before the window goes back to its own frame. - let target = window.clone(); - window.run_on_main_thread(move || keep_window_buttons(&target, false))?; +impl Drop for WorkspaceObserver { + fn drop(&mut self) { + let observer: &ProtocolObject = &self.1; + unsafe { self.0.removeObserver(observer.as_ref()) }; } - window.set_simple_fullscreen(on)?; - let any = { - let mut labels = SIMPLE_FULLSCREEN.lock().unwrap(); - labels.retain(|l| l != window.label()); - if on { - labels.push(window.label().to_owned()); - } - !labels.is_empty() - }; - let target = window.clone(); - window.run_on_main_thread(move || { - present(any); - if on { - keep_window_buttons(&target, true); - } - }) +} + +/// Work waiting for the active Space to change, with the observer that runs it. +struct SpaceChange { + _observer: WorkspaceObserver, + then: Box, } thread_local! { - /// Minimum sizes of windows in simple fullscreen, restored when they leave it. - static MIN_SIZES: std::cell::RefCell> = - std::cell::RefCell::default(); + static SPACE_CHANGE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } -/// Simple fullscreen drops the title bar and resizing; ours is transparent over the web toolbar, -/// so keep it and its buttons, the green one (enabled only for a resizable window) leading back -/// out of fullscreen. A minimum size of the whole screen keeps the edges from being dragged. -fn keep_window_buttons(window: &WebviewWindow, on: bool) { - use objc2_app_kit::NSWindowStyleMask; - let Ok(pointer) = window.ns_window() else { return }; - let native = unsafe { &*pointer.cast::() }; - let label = window.label().to_owned(); - if !on { - if let Some(size) = MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label)) { - native.setMinSize(size); +/// Runs `then` on the main thread once the active Space has changed, or after `timeout` when it +/// changed before this was called. AppKit drops a fullscreen request made while a fullscreen +/// Space is being torn down. +pub fn after_space_change(app: &AppHandle, timeout: std::time::Duration, then: impl FnOnce() + Send + 'static) { + fn run() { + if let Some(pending) = SPACE_CHANGE.with_borrow_mut(Option::take) { + (pending.then)(); } - return; } - let Some(screen) = native.screen() else { return }; - let frame = screen.frame(); - MIN_SIZES.with_borrow_mut(|sizes| { - sizes.entry(label).or_insert_with(|| native.minSize()); + let _ = app.run_on_main_thread(move || { + run(); + let block = block2::RcBlock::new(|_| run()); + let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter(); + let observer = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification), + None, + None, + &block, + ) + }; + let observer = WorkspaceObserver(center, observer); + SPACE_CHANGE + .with_borrow_mut(|pending| *pending = Some(SpaceChange { _observer: observer, then: Box::new(then) })); + }); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(timeout).await; + let _ = app.run_on_main_thread(run); }); - native.setMinSize(frame.size); - native.setStyleMask(native.styleMask() | NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable); - native.setFrame_display(frame, true); } -/// Forgets a closed window; true when it was in simple fullscreen. -pub fn forget_simple_fullscreen(app: &AppHandle, label: &str) -> bool { - let (was, any) = { - let mut labels = SIMPLE_FULLSCREEN.lock().unwrap(); - let before = labels.len(); - labels.retain(|l| l != label); - (labels.len() != before, !labels.is_empty()) - }; - if was { - let label = label.to_owned(); - let _ = app.run_on_main_thread(move || { - MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label)); - present(any); - }); - } - was -} +type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize; -/// Each window restores the presentation options it found on entering, which is wrong once -/// fullscreen has passed between windows; derive them from the windows in fullscreen instead. -fn present(fullscreen: bool) { - use objc2_app_kit::{NSApplication, NSApplicationPresentationOptions}; +/// `NSApplicationTerminateReply`. +const TERMINATE_CANCEL: usize = 0; +const TERMINATE_NOW: usize = 1; + +/// The Dock's Quit, the system's app menu and a logout ask the application delegate whether it +/// may terminate; tao's delegate doesn't answer, so AppKit terminated at once, past the exit, +/// running-process and unsaved-changes prompts. Answer it: cancel, and quit like Cmd+Q does. +pub fn intercept_system_quit(app: &AppHandle) { + let _ = APP.set(app.clone()); let Some(mtm) = MainThreadMarker::new() else { return }; - let options = if fullscreen { - NSApplicationPresentationOptions::AutoHideDock | NSApplicationPresentationOptions::AutoHideMenuBar - } else { - NSApplicationPresentationOptions::Default - }; - NSApplication::sharedApplication(mtm).setPresentationOptions(options); + let application = objc2_app_kit::NSApplication::sharedApplication(mtm); + unsafe { + let delegate: *mut AnyObject = objc2::msg_send![&*application, delegate]; + if delegate.is_null() { + return; + } + let class = objc2::ffi::object_getClass(delegate.cast()) as *mut AnyClass; + let imp: Imp = std::mem::transmute(should_terminate as ShouldTerminate); + let added = objc2::ffi::class_addMethod(class, objc2::sel!(applicationShouldTerminate:), imp, c"Q@:@".as_ptr()); + if !added.as_bool() { + tracing::warn!("could not handle applicationShouldTerminate:"); + } + } +} + +unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) -> usize { + let Some(app) = APP.get() else { return TERMINATE_NOW }; + // Our own quit exits through the event loop; a system quit during it goes ahead. + if app.try_state::().is_none_or(|w| w.quitting()) { + return TERMINATE_NOW; + } + crate::dispatch_menu_action(app, "app.quit"); + TERMINATE_CANCEL } type ToggleFullScreen = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject); @@ -203,7 +268,7 @@ static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock = std::sy static APP: std::sync::OnceLock = std::sync::OnceLock::new(); /// The green window button, the Globe-F shortcut and the system's Enter Full Screen item all -/// send `toggleFullScreen:`; a grouped window answers it with simple fullscreen instead. +/// send `toggleFullScreen:`; a grouped window answers it for the whole group. pub fn intercept_toggle_fullscreen(window: &WebviewWindow) { let _ = APP.set(window.app_handle().clone()); let _ = window.with_webview(|webview| unsafe { @@ -218,11 +283,125 @@ pub fn intercept_toggle_fullscreen(window: &WebviewWindow) { let _ = NATIVE_TOGGLE_FULLSCREEN.set(native); let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen); objc2::ffi::class_replaceMethod(class, selector, replacement, types); + + let selector = objc2::sel!(constrainFrameRect:toScreen:); + let Some(method) = (*class).instance_method(selector) else { return }; + let native: ConstrainFrameRect = std::mem::transmute(method.implementation()); + let types = objc2::ffi::method_getTypeEncoding(method); + let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native); + let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect); + objc2::ffi::class_replaceMethod(class, selector, replacement, types); }); } +type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect; + +static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock = std::sync::OnceLock::new(); + +thread_local! { + /// Native window whose next frame assignment must keep the rectangle it was given. + static EXACT_FRAME: Cell = const { Cell::new(0) }; +} + +struct ExactFrame(usize); + +impl ExactFrame { + fn begin(native: usize) -> Self { + EXACT_FRAME.with(|slot| slot.set(native)); + Self(native) + } +} + +impl Drop for ExactFrame { + fn drop(&mut self) { + EXACT_FRAME.with(|slot| { + if slot.get() == self.0 { + slot.set(0); + } + }); + } +} + +/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns; +/// tao's position and size setters only schedule the change. +pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + apply_window_frame(from, to, false) +} + +/// Copies the frame and orders `to` front before AppKit can animate or constrain it. +/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place. +pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + apply_window_frame(from, to, true) +} + +fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { + if MainThreadMarker::new().is_some() { + return apply_window_frame_on_main(from, to, show); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let from = from.clone(); + let to = to.clone(); + to.clone().run_on_main_thread(move || { + let _ = tx.send(apply_window_frame_on_main(&from, &to, show)); + })?; + rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))? +} + +fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { + let source_pointer = from.ns_window()?; + let target_pointer = to.ns_window()?; + unsafe { + let source = &*source_pointer.cast::(); + let target = &*target_pointer.cast::(); + let frame = source.frame(); + let current = target.frame(); + let differs = frame.origin.x != current.origin.x + || frame.origin.y != current.origin.y + || frame.size.width != current.size.width + || frame.size.height != current.size.height; + if !differs && !show { + return Ok(()); + } + // Zoom and ordering use the window's animation behavior. `animate: false` alone does not + // stop a zoomed window from sliding to the new frame as it is shown. + let behavior = target.animationBehavior(); + target.setAnimationBehavior(NSWindowAnimationBehavior::None); + let result = (|| { + let _exact = ExactFrame::begin(target_pointer as usize); + if differs { + target.setFrame_display_animate(frame, false, false); + } + if show { + to.show()?; + to.set_focus()?; + } + Ok(()) + })(); + target.setAnimationBehavior(behavior); + result + } +} + +/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the +/// camera housing, whenever the window is placed, shown or resized. A group-tab frame copy keeps +/// the rectangle it was given, so the window about to be shown is not shifted. +unsafe extern "C-unwind" fn constrain_frame_rect( + this: *mut AnyObject, + selector: Sel, + frame: NSRect, + screen: *mut AnyObject, +) -> NSRect { + if EXACT_FRAME.with(|slot| slot.get() == this as usize) { + return frame; + } + match NATIVE_CONSTRAIN_FRAME_RECT.get() { + Some(native) => unsafe { native(this, selector, frame, screen) }, + None => frame, + } +} + unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) { - if let Some(window) = APP.get().and_then(|app| simple_fullscreen_window(app, this)) { + if let Some(window) = APP.get().and_then(|app| grouped_window(app, this)) { if let Err(error) = crate::window_groups::toggle_fullscreen(&window) { tracing::warn!(message = error.message, "cannot toggle fullscreen"); } @@ -233,17 +412,15 @@ unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Se } } -/// `native` as a grouped project window (or one still in simple fullscreen) that is not in -/// native fullscreen; one in native fullscreen leaves it the usual way. -fn simple_fullscreen_window(app: &AppHandle, native: *mut AnyObject) -> Option { - let mask: objc2_app_kit::NSWindowStyleMask = unsafe { objc2::msg_send![native, styleMask] }; - if mask.contains(objc2_app_kit::NSWindowStyleMask::FullScreen) { - return None; - } - let window = app - .webview_windows() +fn project_window(app: &AppHandle, native: *mut AnyObject) -> Option { + app.webview_windows() .into_values() - .find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::() == native))?; + .find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::() == native)) +} + +/// `native` as a project window in a window group. +fn grouped_window(app: &AppHandle, native: *mut AnyObject) -> Option { + let window = project_window(app, native)?; let grouped = app.state::().groups.lock().group(window.label()).is_some(); - (grouped || is_simple_fullscreen(window.label())).then_some(window) + grouped.then_some(window) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 8b9fc95ae..efebf4d34 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -895,7 +895,7 @@ pub fn action_ids(spec: &[Entry]) -> Vec<&'static str> { } /// Localised labels and displayed shortcuts, pushed by the frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct MenuConfig { pub locale: String, @@ -961,6 +961,17 @@ pub struct MenuState { pub disabled: Mutex>, } +/// Stores `next` when it differs from the config last used to build the menu. +/// Callers must not replace the menu on `false`: `set_menu` redraws the macOS menu bar. +pub fn store_config(state: &MenuState, next: MenuConfig) -> bool { + let mut current = state.config.lock(); + if *current == next { + return false; + } + *current = next; + true +} + /// The action an item id stands for, if it is an IDE action item. fn item_action(id: &str) -> Option<&str> { if id.starts_with(RECENT_PREFIX) || id == "recent.none" || id.starts_with("submenu:") { @@ -1267,6 +1278,16 @@ mod tests { } } + #[test] + fn unchanged_menu_config_is_not_rebuilt() { + let state = MenuState::default(); + let mut config = MenuConfig { locale: "en".into(), ..MenuConfig::default() }; + assert!(store_config(&state, config.clone())); + assert!(!store_config(&state, config.clone())); + config.labels.insert("menu.file".into(), "File".into()); + assert!(store_config(&state, config)); + } + #[test] fn recent_items_route_to_open_recent_with_path() { let spec = spec(Os::Mac); diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index fdf68376b..9ab69d0d2 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -1,106 +1,30 @@ -//! Keep the native window buttons aligned with the web toolbar, in AppKit points. +//! Size the native title bar around the window buttons to match the web toolbar. +//! +//! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so +//! nothing moves them behind its back and they stay still during live resize. -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::rc::Rc; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSToolbar, NSWindow, NSWindowToolbarStyle}; -use block2::RcBlock; -use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask}; -use objc2_core_foundation::{ - CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes, -}; -use objc2_foundation::NSPoint; - -/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their -/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so -/// correcting the frames just before it keeps AppKit's positions off screen. -const BEFORE_COMMIT: CFIndex = 1_999_999; - -struct Titlebar { - height: Rc>, - spacing: f64, - observer: CFRetained, -} - -impl Drop for Titlebar { - fn drop(&mut self) { - self.observer.invalidate(); - } -} - -thread_local! { - static TITLEBARS: RefCell> = RefCell::default(); -} +/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. +const COMPACT_MAX_HEIGHT: f64 = 45.0; pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { - let label = window.label().to_owned(); - // with_webview runs on the AppKit thread; the observer is also delivered there. + // with_webview runs on the AppKit thread. window.with_webview(move |webview| unsafe { + let Some(mtm) = MainThreadMarker::new() else { return }; let native = &*webview.ns_window().cast::(); - let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return }; - let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; - let Some(run_loop) = CFRunLoop::main() else { return }; - let spacing = minimize.frame().origin.x - close.frame().origin.x; - TITLEBARS.with_borrow_mut(|bars| { - if let Some(bar) = bars.get(&label) { - bar.height.set(height); - center_buttons(native, height, bar.spacing); - return; - } - let shared = Rc::new(Cell::new(height)); - let current = shared.clone(); - let pointer = native as *const NSWindow; - let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| { - center_buttons(&*pointer, current.get(), spacing) - }); - let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags; - let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block)) - else { - return; - }; - run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes); - center_buttons(native, height, spacing); - bars.insert(label, Titlebar { height: shared, spacing, observer }); - }); + if native.toolbar().is_none() { + let toolbar = NSToolbar::new(mtm); + native.setToolbar(Some(&toolbar)); + } + let style = if height < COMPACT_MAX_HEIGHT { + NSWindowToolbarStyle::UnifiedCompact + } else { + NSWindowToolbarStyle::Unified + }; + if native.toolbarStyle() != style { + native.setToolbarStyle(style); + } }) } - -pub fn forget(window: &tauri::Window) { - let label = window.label().to_owned(); - let _ = window.run_on_main_thread(move || { - TITLEBARS.with_borrow_mut(|bars| bars.remove(&label)); - }); -} - -unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) { - // Fullscreen uses AppKit's own revealable title bar. - if window.styleMask().contains(NSWindowStyleMask::FullScreen) { - return; - } - let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return }; - let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return }; - let Some(content) = window.contentView() else { return }; - let mut frame = container.frame(); - let top = frame.origin.y + frame.size.height; - if (frame.size.height - height).abs() > 0.01 { - frame.size.height = height; - frame.origin.y = top - height; - container.setFrame(frame); - } - let bounds = content.bounds(); - let y = bounds.origin.y + if content.isFlipped() { height / 2.0 } else { bounds.size.height - height / 2.0 }; - for (index, kind) in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] - .into_iter() - .enumerate() - { - let Some(button) = window.standardWindowButton(kind) else { continue }; - let Some(parent) = (unsafe { button.superview() }) else { continue }; - let center = parent.convertPoint_fromView(NSPoint::new(0.0, y), Some(&content)); - let frame = button.frame(); - let x = 14.0 + index as f64 * spacing; - let target = center.y - frame.size.height / 2.0; - if (frame.origin.x - x).abs() > 0.01 || (frame.origin.y - target).abs() > 0.01 { - button.setFrameOrigin(NSPoint::new(x, target)); - } - } -} diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 44e70c2d2..0601fb32f 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -16,6 +16,9 @@ use tauri::{AppHandle, Manager, WebviewWindow}; const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50); /// Wait for a drag/resize to stop before copying its frame to the hidden peers. const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100); +/// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen. +#[cfg(target_os = "macos")] +const SPACE_TEARDOWN: Duration = Duration::from_millis(1500); /// Coalesces the move/resize events a drag emits. static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -56,30 +59,71 @@ fn changed(app: &AppHandle) { app.state::().sink.emit(IdeEvent::WindowGroupsChanged); } -/// Whether `to` already presents `from`'s frame, so moving it would only make it flicker. -fn same_frame(from: &WebviewWindow, to: &WebviewWindow) -> bool { - let (Ok(from_max), Ok(to_max)) = (from.is_maximized(), to.is_maximized()) else { return false }; - let (Ok(from_full), Ok(to_full)) = (from.is_fullscreen(), to.is_fullscreen()) else { return false }; - if from_max != to_max || from_full != to_full { - return false; +/// Outer frame in physical pixels. A maximized window is represented by the rectangle it +/// occupies, not by the maximized flag: that flag can match while the rectangles do not. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct OuterFrame { + x: i32, + y: i32, + width: u32, + height: u32, +} + +/// Where a group member can be placed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameHost { + /// The client can move and resize the window. + Desktop, + /// Wayland does not let clients place top-level windows. + #[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] + Wayland, + /// Native fullscreen owns a Space. A different frame is transferred, not assigned. + NativeFullscreen, +} + +/// What to write before the target window is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameWrite { + /// Already the same rectangle, or this host must not be placed. + Leave, + TransferFullscreen, + Assign(OuterFrame), +} + +/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen +/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible. +fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite { + match host { + FrameHost::Wayland => FrameWrite::Leave, + FrameHost::NativeFullscreen if from == to => FrameWrite::Leave, + FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen, + FrameHost::Desktop if from == to => FrameWrite::Leave, + FrameHost::Desktop => FrameWrite::Assign(from), } - let same_monitor = match (from.current_monitor(), to.current_monitor()) { - (Ok(Some(a)), Ok(Some(b))) => a.position() == b.position() && a.size() == b.size(), - // An unmapped window may not report a monitor; treat only equal states as matching. - (Ok(None), Ok(None)) | (Err(_), Err(_)) => true, - _ => false, - }; - if !same_monitor { - return false; +} + +fn outer_frame(window: &WebviewWindow) -> IdeResult { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + let position = window.outer_position().map_err(err)?; + let size = window.outer_size().map_err(err)?; + Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height }) +} + +fn frame_host(from: &WebviewWindow) -> IdeResult { + #[cfg(target_os = "linux")] + if crate::linux_window_state::is_wayland() { + return Ok(FrameHost::Wayland); } - from_full - || from_max - || (from.outer_position().ok() == to.outer_position().ok() && from.inner_size().ok() == to.inner_size().ok()) + if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? { + return Ok(FrameHost::NativeFullscreen); + } + Ok(FrameHost::Desktop) } /// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on /// the monitor it last occupied; maximizing or going fullscreen there would make the window -/// visibly jump across displays when its tab is selected. +/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead. +#[cfg(not(target_os = "macos"))] fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { // Wayland does not let clients place top-level windows; maximizing stays on that output. #[cfg(target_os = "linux")] @@ -93,47 +137,92 @@ fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { Ok(()) } +/// Gives `to` the source window's outer frame before `to` is shown. +/// +/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return +/// before the frame changes, while `show` orders the window front immediately, so the old +/// rectangle appears and then jumps. The frame is written synchronously instead. fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { - if same_frame(from, to) { + let host = frame_host(from)?; + if host == FrameHost::Wayland { return Ok(()); } - let err = |e: tauri::Error| IdeError::internal(e.to_string()); - let fullscreen = from.is_fullscreen().map_err(err)?; - if fullscreen { - #[cfg(target_os = "macos")] - crate::macos::transfer_fullscreen(from, to).map_err(err)?; - #[cfg(not(target_os = "macos"))] - { - to.set_fullscreen(false).map_err(err)?; - place_on_monitor(from, to)?; - from.set_fullscreen(false).map_err(err)?; - to.set_fullscreen(true).map_err(err)?; - } - return Ok(()); + // Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is + // the one `show` will present, so compare that instead of skipping a real difference. + #[cfg(target_os = "macos")] + if host == FrameHost::Desktop { + return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())); } - to.set_fullscreen(false).map_err(err)?; - let maximized = from.is_maximized().map_err(err)?; - if !maximized { - to.unmaximize().map_err(err)?; - to.set_position(from.outer_position().map_err(err)?).map_err(err)?; - // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. - #[cfg(target_os = "linux")] - let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; - #[cfg(not(target_os = "linux"))] - let size = from.inner_size().map_err(err)?; - #[cfg(target_os = "linux")] - crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); - to.set_size(size).map_err(err)?; - } else { - // Maximize the target on the source's monitor, not on the monitor it happened to - // occupy while hidden. - to.unmaximize().map_err(err)?; + match frame_write(host, outer_frame(from)?, outer_frame(to)?) { + FrameWrite::Leave => Ok(()), + FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to), + FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame), + } +} + +fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + // A macOS group in fullscreen shares its host's Space, so no member copies the host's frame. + #[cfg(target_os = "macos")] + let _ = (from, to, err); + #[cfg(not(target_os = "macos"))] + { + to.set_fullscreen(false).map_err(err)?; place_on_monitor(from, to)?; - to.maximize().map_err(err)?; + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; } Ok(()) } +fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> { + #[cfg(target_os = "macos")] + { + crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())) + } + #[cfg(not(target_os = "macos"))] + { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + to.set_fullscreen(false).map_err(err)?; + if from.is_maximized().map_err(err)? { + // Maximize on the source's monitor. The rectangles differed, so the maximized flags + // alone are not enough; a hidden peer would otherwise appear at its old size. + to.unmaximize().map_err(err)?; + place_on_monitor(from, to)?; + to.maximize().map_err(err)?; + } else { + to.unmaximize().map_err(err)?; + to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] + let size = from.inner_size().map_err(err)?; + #[cfg(target_os = "linux")] + crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); + to.set_size(size).map_err(err)?; + } + Ok(()) + } +} + +/// Shows `to` in place of `from`, at `from`'s frame. +fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + // Order the target front in the same AppKit turn as the frame write. A separate `show` runs + // after that turn and constrains the titled window, which is the jump. Native fullscreen is + // handed over by `copy_frame` instead. + #[cfg(target_os = "macos")] + if !from.is_fullscreen().map_err(err)? { + to.unminimize().map_err(err)?; + return crate::macos::present_window_frame(from, to).map_err(err); + } + copy_frame(from, to)?; + to.unminimize().map_err(err)?; + to.show().map_err(err)?; + to.set_focus().map_err(err) +} + /// Keeps hidden group members at the selected window's frame. Selecting a tab then only /// changes which window is visible instead of moving or resizing one on screen. fn sync_member_frames(app: &AppHandle, selected: &str) { @@ -142,7 +231,10 @@ fn sync_member_frames(app: &AppHandle, selected: &str) { return; } let Some(from) = app.get_webview_window(selected) else { return }; - if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) || simple_fullscreen(selected) { + if from.is_fullscreen().unwrap_or(false) + || from.is_minimized().unwrap_or(false) + || fullscreen_host(app, &group.members).is_some() + { return; } for member in group.members.iter().filter(|member| member.as_str() != selected) { @@ -178,41 +270,53 @@ pub fn frame_changed(app: &AppHandle, label: &str) { }); } -fn simple_fullscreen(label: &str) -> bool { +/// The member of `members` in native fullscreen. On macOS the other members of its group join +/// its Space; elsewhere each member of a fullscreen group is fullscreen itself. +fn fullscreen_host(app: &AppHandle, members: &[String]) -> Option { #[cfg(target_os = "macos")] - return crate::macos::is_simple_fullscreen(label); + return members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false)); #[cfg(not(target_os = "macos"))] { - let _ = label; - false + let _ = (app, members); + None } } +/// The fullscreen host of `label`'s group, when that is another window. #[cfg(target_os = "macos")] -fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> IdeResult<()> { - crate::macos::set_simple_fullscreen(window, on).map_err(|e| IdeError::internal(e.to_string())) +pub fn fullscreen_host_of(app: &AppHandle, label: &str) -> Option { + let members = app.state::().groups.lock().group(label).map(|g| g.members.clone())?; + fullscreen_host(app, &members).filter(|host| host.label() != label) } -/// Fullscreen for `window`. On macOS a grouped window uses simple fullscreen, for every member -/// at once: the group stays in one Space and each member keeps the screen's size, so switching -/// projects is only showing one window and hiding the others. +/// Whether `window` is shown fullscreen: itself, or as a member of a fullscreen group. +#[cfg(target_os = "macos")] +pub fn in_fullscreen(app: &AppHandle, window: &WebviewWindow) -> bool { + if window.is_fullscreen().unwrap_or(false) { + return true; + } + let members = app.state::().groups.lock().group(window.label()).map(|g| g.members.clone()); + members.is_some_and(|members| fullscreen_host(app, &members).is_some()) +} + +/// Fullscreen for `window`. On macOS a group shares one fullscreen Space: the selected member +/// enters native fullscreen and the others join its Space as auxiliary windows over it, so +/// switching projects only shows one window and hides the others. pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { let err = |e: tauri::Error| IdeError::internal(e.to_string()); #[cfg(target_os = "macos")] { - let simple = simple_fullscreen(window.label()); let app = window.app_handle(); - let members = app.state::().groups.lock().group(window.label()).map(|g| g.members.clone()); - if simple || (members.is_some() && !window.is_fullscreen().map_err(err)?) { - set_simple_fullscreen(window, !simple)?; - for member in members.iter().flatten().filter(|m| *m != window.label()) { - if let Some(other) = app.get_webview_window(member) - && simple_fullscreen(member) == simple - { - set_simple_fullscreen(&other, !simple)?; - } + let group = app.state::().groups.lock().group(window.label()).cloned(); + if let Some(group) = group { + if let Some(host) = fullscreen_host(app, &group.members) { + // The host's watch brings the members back with it. + crate::macos::native_toggle_fullscreen(&host).map_err(err)?; + return Ok(false); } - return Ok(!simple); + let host = app.get_webview_window(&group.selected).unwrap_or_else(|| window.clone()); + host_fullscreen(app, &host)?; + return Ok(true); } } let on = !window.is_fullscreen().map_err(err)?; @@ -220,28 +324,81 @@ pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { Ok(on) } +/// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped. +#[cfg(target_os = "macos")] +fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + let members = app.state::().groups.lock().group(host.label()).map(|g| g.members.clone()); + crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?; + for member in members.iter().flatten().filter(|m| *m != host.label()) { + if let Some(other) = app.get_webview_window(member) { + crate::macos::set_fullscreen_auxiliary(&other, Some(host)).map_err(err)?; + } + } + if members.is_some() { + watch_host(app, host)?; + } + host.show().map_err(err)?; + crate::macos::native_toggle_fullscreen(host).map_err(err) +} + +#[cfg(target_os = "macos")] +fn watch_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + let app = app.clone(); + let label = host.label().to_owned(); + crate::macos::watch_fullscreen_host(host, move || left_fullscreen(&app, &label)) + .map_err(|e| IdeError::internal(e.to_string())) +} + +/// The group's fullscreen host is back on the desktop: the members leave its Space with it at +/// its frame, and the selected one is shown. +#[cfg(target_os = "macos")] +fn left_fullscreen(app: &AppHandle, host: &str) { + let Some(group) = app.state::().groups.lock().group(host).cloned() else { return }; + let Some(host) = app.get_webview_window(host) else { return }; + for member in group.members.iter().filter(|m| *m != host.label()) { + if let Some(window) = app.get_webview_window(member) { + let _ = crate::macos::set_fullscreen_auxiliary(&window, None); + let _ = copy_frame(&host, &window); + } + } + let _ = select(app, &group.selected, &group.selected); +} + pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?; let state = app.state::(); let previous = state.groups.lock().select(caller, target)?; - // In a fullscreen group the target already covers the screen, or does so before it is shown. - let simple = previous.selected != target && simple_fullscreen(&previous.selected); + // In a fullscreen group the target joins the host's Space, covering its screen. + let host = fullscreen_host(app, &previous.members); let result = (|| { #[cfg(target_os = "macos")] - if simple && !simple_fullscreen(target) { - set_simple_fullscreen(&to, true)?; + if let Some(host) = &host { + // Also a window that was fullscreen alone before it was grouped. + watch_host(app, host)?; + if host.label() != target { + crate::macos::set_fullscreen_auxiliary(&to, Some(host)) + .map_err(|e| IdeError::internal(e.to_string()))?; + } } - if !simple + if host.is_none() && previous.selected != target && let Some(from) = app.get_webview_window(&previous.selected) { - copy_frame(&from, &to)?; + show_at_frame(&from, &to)?; + } else { + to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; + to.show().map_err(|e| IdeError::internal(e.to_string()))?; + #[cfg(target_os = "macos")] + if let Some(host) = host.as_ref().filter(|h| h.label() != target) { + crate::macos::raise_over_host(&to, host).map_err(|e| IdeError::internal(e.to_string()))?; + } + to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; } - to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; - to.show().map_err(|e| IdeError::internal(e.to_string()))?; - to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; for member in &previous.members { + // The host stays in its Space behind the selected member; hiding it would end the Space. if member != target + && host.as_ref().is_none_or(|h| h.label() != member) && let Some(window) = app.get_webview_window(member) { window.hide().map_err(|e| IdeError::internal(e.to_string()))?; @@ -296,10 +453,6 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { // Native fullscreen puts each window in a separate Space; leave it before grouping. for label in &members { if let Some(window) = app.get_webview_window(label) { - #[cfg(target_os = "macos")] - if label != caller && simple_fullscreen(label) { - set_simple_fullscreen(&window, false)?; - } window.set_fullscreen(false).map_err(|e| IdeError::internal(e.to_string()))?; } } @@ -309,23 +462,23 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { let state = app.state::(); + #[cfg(target_os = "macos")] + let host = { + let members = state.groups.lock().group(caller).map(|g| g.members.clone()).unwrap_or_default(); + fullscreen_host(app, &members) + }; let next = state.groups.lock().detach(caller); + #[cfg(target_os = "macos")] + if let Some(host) = host { + detach_fullscreen(app, caller, &host, next.as_deref())?; + changed(app); + return Ok(()); + } if let Some(next) = next { - // The tab that stays keeps the fullscreen; the detached window opens beside it. - #[cfg(target_os = "macos")] - let simple = simple_fullscreen(caller); if let Some(window) = app.get_webview_window(&next) { - #[cfg(target_os = "macos")] - if simple && !simple_fullscreen(&next) { - set_simple_fullscreen(&window, true)?; - } window.show().map_err(|e| IdeError::internal(e.to_string()))?; } if let Some(window) = app.get_webview_window(caller) { - #[cfg(target_os = "macos")] - if simple { - set_simple_fullscreen(&window, false)?; - } if let Ok(position) = window.outer_position() { let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32)); } @@ -336,17 +489,60 @@ pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { Ok(()) } +/// A tab moved out of a fullscreen group stays fullscreen in a Space of its own, as a macOS +/// tab moved to a new window does; the tabs left keep theirs. +#[cfg(target_os = "macos")] +fn detach_fullscreen(app: &AppHandle, caller: &str, host: &WebviewWindow, next: Option<&str>) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + let next = next.and_then(|label| app.get_webview_window(label)); + let grouped = |label: &str| app.state::().groups.lock().group(label).is_some(); + if host.label() == caller { + // The host keeps its Space; the tabs left need a host of their own. + crate::macos::forget_fullscreen_host(caller); + if let Some(next) = &next { + host_fullscreen(app, next)?; + } + return Ok(()); + } + if let Some(next) = &next { + if grouped(next.label()) { + select(app, next.label(), next.label())?; + } else { + // The group dissolved; its last window is fullscreen alone. + crate::macos::forget_fullscreen_host(next.label()); + crate::macos::set_fullscreen_auxiliary(next, None).map_err(err)?; + } + } + if let Some(window) = app.get_webview_window(caller) { + host_fullscreen(app, &window)?; + } + Ok(()) +} + pub fn destroyed(app: &AppHandle, caller: &str) { let Some(state) = app.try_state::() else { return }; #[cfg(target_os = "macos")] - let simple = crate::macos::forget_simple_fullscreen(app, caller); + let hosted = crate::macos::forget_fullscreen_host(caller); let next = state.groups.lock().detach(caller); if !state.quitting() { if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) { + // The Space ended with its host; the tabs left take a new one once it is gone. #[cfg(target_os = "macos")] - if simple && !simple_fullscreen(window.label()) { - let _ = set_simple_fullscreen(&window, true); + if hosted { + let host_app = app.clone(); + crate::macos::after_space_change(app, SPACE_TEARDOWN, move || { + if let Err(error) = host_fullscreen(&host_app, &window) { + tracing::warn!("cannot keep window group fullscreen: {}", error.message); + } + windows::focus(&window); + }); + } else { + windows::focus(&window); + if let Some(host) = fullscreen_host_of(app, window.label()) { + let _ = crate::macos::raise_over_host(&window, &host); + } } + #[cfg(not(target_os = "macos"))] windows::focus(&window); } let app = app.clone(); @@ -433,44 +629,36 @@ fn close_next(app: &AppHandle) { } } -#[derive(Serialize, Deserialize)] -struct SavedGroup { - projects: Vec, - selected: usize, +/// Window labels in creation order: `main`, then `project-1`, `project-2`, …. +fn labels_in_order(app: &AppHandle) -> Vec { + let mut labels: Vec = app.webview_windows().into_keys().collect(); + labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::().ok()), l.clone())); + labels } +/// Saves the open project windows (groups and single windows) for "Reopen projects on startup". +/// Nothing is written while quitting, so the windows open at quit are the ones reopened. pub async fn persist(app: &AppHandle) -> IdeResult<()> { let state = app.state::(); - let _write = state.groups_persist.lock().await; + let mut previous = state.groups_persist.lock().await; if state.quitting() { return Ok(()); } - let groups = state.groups.lock().groups().to_vec(); let host = app.state::(); - let saved: Vec<_> = groups - .iter() - .filter_map(|group| { - let projects: Vec<_> = group - .members - .iter() - .filter_map(|label| { - host.project(label) - .ok() - .map(|p| p.root.clone()) - .or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into)) - }) - .collect(); - (projects.len() == group.members.len()).then(|| SavedGroup { - projects, - selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0), - }) - }) - .collect(); - // A restored member may have taken its launch but not finished opening yet. - if saved.len() != groups.len() { - return Ok(()); - } let path = host.config_dir.join("window-groups.json"); + if previous.is_none() { + *previous = Some(read_session(path.clone()).await); + } + let groups = state.groups.lock().groups().to_vec(); + let windows = labels_in_order(app); + let project_of = |label: &str| match host.project(label) { + Ok(p) => (!p.light).then(|| p.root.clone()), + // A restored window may have taken its launch but not finished opening yet. + Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into), + }; + let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else { + return Ok(()); + }; tauri::async_runtime::spawn_blocking(move || { let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?; ide_vfs::write_atomic(&path, &bytes).map(|_| ()) @@ -479,6 +667,22 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> { .map_err(|e| IdeError::internal(e.to_string()))? } +/// The saved session; empty when there is none or it can't be read. +async fn read_session(path: PathBuf) -> Vec { + let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(format!("{}: {e}", path.display())), + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r); + read.unwrap_or_else(|e| { + tracing::warn!("cannot read the saved windows: {e}"); + Vec::new() + }) +} + pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { if caller != "main" { return Ok(()); @@ -489,42 +693,96 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) { return Ok(()); } - let path = host.config_dir.join("window-groups.json"); - let saved: Vec = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> { - match std::fs::read(path) { - Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(e) => Err(e.into()), + let saved = { + let mut previous = state.groups_persist.lock().await; + if previous.is_none() { + *previous = Some(read_session(host.config_dir.join("window-groups.json")).await); } - }) - .await - .map_err(|e| IdeError::internal(e.to_string()))??; - if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) { + previous.clone().unwrap_or_default() + }; + // The caller's own group first (it already shows one of its projects), then every other + // group and single window that was open at quit. Missing folders are skipped. + let order = saved + .iter() + .filter(|g| g.projects.contains(&project.root)) + .chain(saved.iter().filter(|g| !g.projects.contains(&project.root))); + for group in order { let mut labels = Vec::new(); - let mut selected = caller.to_owned(); + let mut selected = None; for path in &group.projects { let label = if path == &project.root { Some(caller.to_owned()) } else if path.is_dir() { let window = match windows::find_project_window(app, path) { - Some(w) => w, - None => windows::create_restored_window(app, path.clone())?, + Some(w) => Ok(w), + None => windows::create_restored_window(app, path.clone()), }; - Some(window.label().to_owned()) + match window { + Ok(w) => Some(w.label().to_owned()), + Err(e) => { + tracing::warn!("cannot reopen {}: {e}", path.display()); + None + } + } } else { None }; if let Some(label) = label { if group.projects.get(group.selected) == Some(path) { - selected = label.clone(); + selected = Some(label.clone()); } labels.push(label); } } if labels.len() > 1 { - state.groups.lock().merge(labels, caller)?; - select(app, caller, &selected)?; + let selected = selected.unwrap_or_else(|| labels[0].clone()); + state.groups.lock().merge(labels, &selected)?; + select(app, &selected, &selected)?; } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{FrameHost, FrameWrite, OuterFrame, frame_write}; + + fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame { + OuterFrame { x, y, width, height } + } + + #[test] + fn matching_outer_rects_are_left_alone() { + let frame = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave); + } + + #[test] + fn a_different_outer_rect_is_assigned_before_the_window_is_shown() { + let from = frame(100, 80, 1200, 800); + let to = frame(40, 40, 900, 600); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn maximized_windows_with_different_rects_still_assign() { + let from = frame(0, 25, 1728, 1055); + let to = frame(0, 25, 1512, 900); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn wayland_does_not_place_a_window() { + let from = frame(0, 0, 1200, 800); + let to = frame(10, 10, 800, 600); + assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave); + } + + #[test] + fn native_fullscreen_transfers_only_when_the_rects_differ() { + let from = frame(0, 0, 1728, 1117); + let other = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 2bcaa63bd..6ba3b1051 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -57,6 +57,9 @@ pub struct Launch { /// Comparison windows: the revisions to compare (boxed: most launches have none). #[serde(default)] pub comparison: Option>, + /// `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none). + #[serde(default)] + pub diff: Option>, /// Deployment windows: the server and local selection to compare. #[serde(default)] pub deployment: Option, @@ -93,6 +96,13 @@ pub struct ComparisonLaunch { pub repo: Option, } +/// The two files of a command-line diff (`openidea diff a.txt b.txt`), both sides editable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct DiffFilesLaunch { + pub left: ide_core::IpcPath, + pub right: ide_core::IpcPath, +} + /// A caret position for a file opened from the command line: 0-based line and column. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -120,7 +130,9 @@ pub fn wsl_launch(path: &Path) -> Option { pub struct WindowsState { pub groups: Mutex, pub groups_restored: AtomicBool, - pub groups_persist: tokio::sync::Mutex<()>, + /// Serialises writes of the saved session. Holds the previous run's session once read: it is + /// read before this run first overwrites the file, so restoring still sees it. + pub groups_persist: tokio::sync::Mutex>>, launches: Mutex>, /// Caret positions asked for on the command line, by canonical file path, until a window takes the file. positions: Mutex>, @@ -274,6 +286,18 @@ pub fn settings_label(parent: &str) -> String { format!("{}-{parent}", AuxKind::Settings.name()) } +/// Windows being built to join a window group, which takes their frame from the group. +#[cfg(target_os = "macos")] +static JOINING_GROUP: Mutex> = Mutex::new(Vec::new()); + +/// Whether the window-state plugin restores and tracks `label`'s geometry. A window joining a +/// group takes the group's frame instead; restoring a fullscreen state its label kept from an +/// earlier window would give it a fullscreen Space of its own. +#[cfg(target_os = "macos")] +pub fn keeps_window_state(label: &str) -> bool { + !JOINING_GROUP.lock().iter().any(|l| l == label) +} + /// The window state key: one geometry per auxiliary kind, else the label itself. pub fn state_key(label: &str) -> &str { auxiliary(label).map_or(label, |(kind, _)| kind.name()) @@ -342,6 +366,9 @@ pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option Option { @@ -360,6 +387,50 @@ pub fn cli_path(args: &[String], cwd: &Path) -> Option { None } +/// What the command line asks to open (arguments after the binary, resolved against `cwd`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliLaunch { + /// `openidea diff a.txt b.txt`: the two files side by side in the diff viewer. + Diff(DiffFilesLaunch), + /// A project folder or a file (`openidea /path/to/project`). + Path(PathBuf), +} + +/// What the command line asks to open; see [`CliLaunch`]. +pub fn cli_launch(args: &[String], cwd: &Path) -> Option { + if args.get(1).map(String::as_str) == Some(DIFF_ARG) { + return match cli_diff_paths(args, cwd).as_slice() { + [left, right, ..] => { + Some(CliLaunch::Diff(DiffFilesLaunch { left: left.clone().into(), right: right.clone().into() })) + } + // One file is no pair: open it like any path. + [one] => Some(CliLaunch::Path(one.clone())), + // `openidea diff` with no files: like starting without arguments. + [] => None, + }; + } + cli_path(args, cwd).map(CliLaunch::Path) +} + +/// The file paths of a `diff` subcommand (everything after it that is not a flag or a flag's +/// value), resolved against `cwd`. +fn cli_diff_paths(args: &[String], cwd: &Path) -> Vec { + let mut paths = Vec::new(); + let mut rest = args.iter().skip(2); + while let Some(a) = rest.next() { + if VALUE_FLAGS.contains(&a.as_str()) { + rest.next(); + continue; + } + if a.starts_with('-') || a.is_empty() { + continue; + } + let p = PathBuf::from(a); + paths.push(if p.is_absolute() { p } else { cwd.join(p) }); + } + paths +} + /// `--line ` and `--column ` (1-based, also as `--line=`) as a 0-based position; /// without `--line` there is none, without `--column` it is the line's start. pub fn cli_position(args: &[String]) -> Option<(u32, u32)> { @@ -480,6 +551,9 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< config.visible = false; } let state = app.try_state::(); + if let Some(state) = &state { + state.plugins.reopen_client(&config.label); + } let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. let plugin_frame = state.as_ref().and_then(|s| s.plugins.startup_frame()).and_then(|c| hex_color(&c)); @@ -607,7 +681,21 @@ fn create_window_for( config.center = false; } app.state::().set_launch(&label, launch); - let window = build_project_window(app, config)?; + #[cfg(target_os = "macos")] + if tab_target.is_some() { + JOINING_GROUP.lock().push(label.clone()); + // Selecting it in the group shows it; shown before, a window opened over a fullscreen + // group lands on the desktop and takes the screen there. + config.visible = false; + } + let window = build_project_window(app, config); + // Plugins hear of the window from the main thread's queue; forget it after them. + #[cfg(target_os = "macos")] + if tab_target.is_some() { + let joined = label.clone(); + let _ = app.run_on_main_thread(move || JOINING_GROUP.lock().retain(|l| *l != joined)); + } + let window = window?; #[cfg(target_os = "macos")] crate::macos::intercept_toggle_fullscreen(&window); #[cfg(target_os = "macos")] @@ -1033,8 +1121,9 @@ pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { return; } } - match cli_path(&args, Path::new(&cwd)) { - Some(path) => { + match cli_launch(&args, Path::new(&cwd)) { + Some(CliLaunch::Diff(diff)) => open_diff_files(app, diff), + Some(CliLaunch::Path(path)) => { remember_cli_position(&app.state::(), &args, &path); open_path(app, &path) } @@ -1048,6 +1137,27 @@ pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { } } +/// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is +/// looking at (a project window, never an auxiliary one), else opens a new window for it. +fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { + let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() }; + let target = target_window(app) + .filter(|window| auxiliary_parent(window.label()).is_none()) + .or_else(|| project_window_labels(app).into_iter().find_map(|label| app.get_webview_window(&label))); + if let Some(window) = target { + app.state::().set_launch(window.label(), launch); + let _ = app.emit_to(window.label(), LAUNCH_EVENT, ()); + focus(&window); + } else { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = create_window_for(&app, None, launch, false) { + tracing::warn!("cannot open window: {}", e.message); + } + }); + } +} + pub fn cycle(app: &AppHandle, current: &str, forward: bool) { let windows = app.webview_windows(); let sorted = project_window_labels(app); @@ -1449,6 +1559,39 @@ mod tests { ); } + #[test] + fn cli_launch_parses_the_diff_subcommand() { + let cwd = Path::new("/work"); + // Two paths: a diff pair; relative paths resolve against the cwd. + assert_eq!( + cli_launch(&s(&["openidea", "diff", "a.txt", "b.txt"]), cwd), + Some(CliLaunch::Diff(DiffFilesLaunch { left: "/work/a.txt".into(), right: "/work/b.txt".into() })) + ); + // Absolute paths stay absolute; a path with spaces is one argument (the shell unquotes it + // before the arguments reach us). + assert_eq!( + cli_launch(&s(&["openidea", "diff", "/abs/my a.txt", "my b.txt"]), cwd), + Some(CliLaunch::Diff(DiffFilesLaunch { left: "/abs/my a.txt".into(), right: "/work/my b.txt".into() })) + ); + // Flags and their values are not file paths. + assert_eq!( + cli_launch(&s(&["openidea", "diff", "--line", "3", "a.txt", "b.txt"]), cwd), + Some(CliLaunch::Diff(DiffFilesLaunch { left: "/work/a.txt".into(), right: "/work/b.txt".into() })) + ); + // One file is no pair: it opens like any path. + assert_eq!( + cli_launch(&s(&["openidea", "diff", "a.txt"]), cwd), + Some(CliLaunch::Path(PathBuf::from("/work/a.txt"))) + ); + // No files at all: like starting without arguments. + assert_eq!(cli_launch(&s(&["openidea", "diff"]), cwd), None); + // Only a leading `diff` is the subcommand; the rest still opens a path. + assert_eq!( + cli_launch(&s(&["openidea", "proj", "diff", "a.txt"]), cwd), + Some(CliLaunch::Path(PathBuf::from("/work/proj"))) + ); + } + #[test] fn cli_position_is_one_based_on_the_command_line() { assert_eq!(cli_position(&s(&["openidea", "--line", "120", "--column", "8", "f"])), Some((119, 7))); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e20d316de..687908a3d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenIDEA", - "version": "0.1.37", + "version": "0.1.38", "identifier": "ai.l0ng.openidea", "build": { "beforeDevCommand": "pnpm dev", @@ -26,7 +26,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost" + "csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost" } }, "bundle": { diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 03e50cdfe..c51f9225f 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -7,6 +7,7 @@ "title": "OpenIDEA", "width": 1440, "height": 900, + "center": true, "minWidth": 800, "minHeight": 500, "decorations": false, diff --git a/src/bindings.ts b/src/bindings.ts index 39dfd5848..6e0904ec4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -28,6 +28,7 @@ export const commands = { projectTrustStatus: (path: string) => typedError(__TAURI_INVOKE("project_trust_status", { path })), projectTrustList: () => typedError(__TAURI_INVOKE("project_trust_list")), projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })), + projectTrustAddLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_add_location", { path })), projectTrustRemoveLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_remove_location", { path })), workspaceCreate: (path: string, name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_create", { path, name, projects })), workspaceAttach: (path: string) => typedError(__TAURI_INVOKE("workspace_attach", { path })), @@ -154,6 +155,8 @@ export const commands = { agentProbe: (provider: string, refresh: boolean) => typedError(__TAURI_INVOKE("agent_probe", { provider, refresh })), /** Stops the task's current turn, or with `subagent` only that subagent's work. */ agentCancel: (task: string, subagent: string | null) => typedError(__TAURI_INVOKE("agent_cancel", { task, subagent })), + /** Stops one of the task's background tasks. */ + agentStopBackground: (task: string, background: string) => typedError(__TAURI_INVOKE("agent_stop_background", { task, background })), /** Answers a permission request; `option: null` cancels it. */ agentRespond: (task: string, permission: number, option: string | null) => typedError(__TAURI_INVOKE("agent_respond", { task, permission, option })), agentArchive: (task: string) => typedError(__TAURI_INVOKE("agent_archive", { task })), @@ -409,6 +412,12 @@ export const commands = { * `cancel_request(request)`. */ lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), + /** + * Go to Implementation of the symbol at `pos`. Implementations found before a + * slower search for the rest arrive on `on_partial`; the result has them all. + * Cancel with `cancel_request(request)`. + */ + lspGotoImplementation: (request: RequestId, doc: DocId, pos: Pos, onPartial: Channel) => typedError(__TAURI_INVOKE("lsp_goto_implementation", { request, doc, pos, onPartial })), /** * Contents of a read-only language-server document, including JDT library * classes and disk class files. Cached per project. @@ -418,9 +427,12 @@ export const commands = { /** * References of the symbol at `pos`. `project_wide` also searches project files * outside the language server's analysis scope; bulk scans over many symbols - * leave it off. Cancel with `cancel_request(request)`. + * leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK + * are not searched; with it, those found in the sources arrive on `on_partial` before + * the slower search for the rest, and the result has them all. Cancel with + * `cancel_request(request)`. */ - lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })), + lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean, libraries: boolean, onPartial: Channel) => typedError(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide, libraries, onPartial })), lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })), lspWorkspaceSymbols: (request: RequestId, query: string) => typedError(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })), lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), @@ -517,6 +529,10 @@ export const commands = { vcsDeleteBranch: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_delete_branch", { repo, branch })), /** "Restore" of the branch-deleted notification. */ vcsRestoreBranch: (repo: string | null, deleted: DeletedBranch) => typedError(__TAURI_INVOKE("vcs_restore_branch", { repo, deleted })), + /** Tag › Delete: the result is what "Restore" needs. */ + vcsDeleteTag: (repo: string | null, name: string) => typedError(__TAURI_INVOKE("vcs_delete_tag", { repo, name })), + /** "Restore" of the tag-deleted notification. */ + vcsRestoreTag: (repo: string | null, deleted: DeletedTag) => typedError(__TAURI_INVOKE("vcs_restore_tag", { repo, deleted })), /** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */ vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })), /** @@ -524,6 +540,13 @@ export const commands = { * Returns the undone commit's message. */ vcsUndoCommit: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_undo_commit", { repo, commit })), + /** + * Edit Commit Message: rewrites the commit and its descendants without touching the + * working tree. + */ + vcsReword: (repo: string | null, commit: string, message: string) => typedError(__TAURI_INVOKE("vcs_reword", { repo, commit, message })), + /** Undo of a history rewrite (Edit Commit Message, Drop, Squash). */ + vcsRestoreHead: (repo: string | null, before: string, after: string) => typedError(__TAURI_INVOKE("vcs_restore_head", { repo, before, after })), vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })), vcsMerge: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { repo, branch })), vcsRebase: (repo: string | null, onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { repo, onto })), @@ -577,6 +600,13 @@ export const commands = { vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError(__TAURI_INVOKE("vcs_compare_paths", { paths, revision, repositories, request })), /** Files that differ between two revisions (the "N files updated" of Update Project). */ vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })), + /** + * Added and deleted lines between two revisions (Git Log › lines added and deleted). + * `paths` limits the diff to those repository-relative paths; empty diffs every file. + */ + vcsDiffNumstat: (repo: string | null, from: string, to: string, paths: string[]) => typedError(__TAURI_INVOKE("vcs_diff_numstat", { repo, from, to, paths })), + /** Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). */ + vcsHasHook: (repo: string | null, name: string) => typedError(__TAURI_INVOKE("vcs_has_hook", { repo, name })), /** The best common ancestor of `a` and `b` (`git merge-base`), if any. */ vcsMergeBase: (repo: string | null, a: string, b: string) => typedError(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })), vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { repo, source })), @@ -700,6 +730,23 @@ export const commands = { * (saved). Returns the declaration; nothing changes when it already excludes it. */ mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })), + /** + * The go-to-declaration target at `pos` of a Maven POM: the definition of the + * `${property}` at the caret, or the `` / `` + * entry providing the dependency's / plugin's version, along the parent chain. + */ + mavenGotoDeclaration: (pom: string, pos: Pos) => typedError<{ + /** + * Absolute path of the file with the declaration: the POM itself, or a parent + * POM of the local repository when the definition is inherited from one. + */ + path: string, + range: Range, + /** What was found. */ + kind: PomGotoKind, + /** The declaration as named for the user (`example.version`, `com.example:example-lib`). */ + label: string, +} | null, IdeError>(__TAURI_INVOKE("maven_goto_declaration", { pom, pos })), /** * Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the * module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable @@ -730,6 +777,8 @@ export const commands = { debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })), debugResume: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_resume", { session, threadId })), debugPause: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_pause", { session, threadId })), + /** Types `text` into the session's program (a `runInTerminal` program). */ + debugSendInput: (session: DebugSessionId, text: string) => typedError(__TAURI_INVOKE("debug_send_input", { session, text })), debugHotReload: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_reload", { session })), debugHotRestart: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_restart", { session })), debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError(__TAURI_INVOKE("debug_step", { session, threadId, kind })), @@ -766,9 +815,21 @@ export const commands = { /** Installed interpreters / toolchains of `kind`; project-local ones first. */ sdkDetect: (kind: SdkKind) => typedError(__TAURI_INVOKE("sdk_detect", { kind })), /** The interpreter the project uses: the configured one, else its virtualenv. */ - sdkPythonEffective: () => typedError(__TAURI_INVOKE("sdk_python_effective")), + sdkPythonEffective: (near: string | null) => typedError(__TAURI_INVOKE("sdk_python_effective", { near })), + /** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */ + sdkPhpEffective: () => typedError(__TAURI_INVOKE("sdk_php_effective")), + /** + * The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the + * project root). + */ + sdkPhpXdebug: (php: string, cwd: string | null) => typedError(__TAURI_INVOKE("sdk_php_xdebug", { php, cwd })), /** The PHP language level of the project `path` belongs to: the effective one and Auto's. */ sdkPhpLanguageLevel: (path: string) => typedError(__TAURI_INVOKE("sdk_php_language_level", { path })), + /** The Composer project `path` belongs to, when its dependencies need `composer install`. */ + sdkPhpComposerInstall: (path: string) => typedError<{ + root: IpcPath, + reason: ComposerInstall, +} | null, IdeError>(__TAURI_INVOKE("sdk_php_composer_install", { path })), /** * SDK / library source folders shown under "External Libraries" in the Project view, * with the Maven / Gradle dependency jars of the build model. @@ -776,8 +837,8 @@ export const commands = { sdkProjectLibraries: () => typedError(__TAURI_INVOKE("sdk_project_libraries")), /** Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path. */ sdkLocateTools: (names: string[], near: string | null) => typedError(__TAURI_INVOKE("sdk_locate_tools", { names, near })), - /** `base -m venv location`; output lines stream through `on_output`. */ - sdkPythonCreateVenv: (base: string, location: string, request: RequestId, onOutput: Channel) => typedError(__TAURI_INVOKE("sdk_python_create_venv", { base, location, request, onOutput })), + /** Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`. */ + sdkPythonCreateVenv: (tool: VenvTool, base: string, location: string, request: RequestId, onOutput: Channel) => typedError(__TAURI_INVOKE("sdk_python_create_venv", { tool, base, location, request, onOutput })), sdkPythonPackages: (python: string, request: RequestId) => typedError(__TAURI_INVOKE("sdk_python_packages", { python, request })), /** Installs (`uninstall = false`) or removes a package; output streams through `on_output`. */ sdkPythonPip: (python: string, requirement: string, uninstall: boolean, upgrade: boolean, request: RequestId, onOutput: Channel) => typedError(__TAURI_INVOKE("sdk_python_pip", { python, requirement, uninstall, upgrade, request, onOutput })), @@ -826,6 +887,13 @@ export const commands = { /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), + /** + * Flash (`true`) or stop flashing (`false`) this window's taskbar button, like IDEA when a + * breakpoint is hit while the window is in the background (macOS bounces the dock icon, + * Linux sets the urgency hint). A focused window is never flashed: the user is already + * looking at it, and re-flashing it would steal attention for nothing. + */ + windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), /** @@ -854,6 +922,8 @@ export const commands = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ comparison?: ComparisonLaunch | null, + /** `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none). */ + diff?: DiffFilesLaunch | null, /** Deployment windows: the server and local selection to compare. */ deployment?: DeploymentLaunch | null, } | null>("window_take_launch"), @@ -864,7 +934,11 @@ export const commands = { appQuit: () => __TAURI_INVOKE("app_quit"), /** A window vetoed the quit (user cancelled its unsaved-changes prompt). */ appCancelQuit: () => __TAURI_INVOKE("app_cancel_quit"), - /** Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it. */ + /** + * Sets the menu's labels / shortcut text and rebuilds it when they changed. + * Focusing another project window re-pushes the same shared menu; an identical + * config must not call `set_menu`, which redraws the macOS menu bar. + */ menuConfigure: (config: MenuConfig) => __TAURI_INVOKE("menu_configure", { config }), /** Rebuilds the menu (e.g. after the recent-projects list changed). */ menuRefresh: () => __TAURI_INVOKE("menu_refresh"), @@ -877,24 +951,24 @@ export const commands = { menuSetDisabled: (ids: string[]) => __TAURI_INVOKE("menu_set_disabled", { ids }), appLogLocation: () => typedError(__TAURI_INVOKE("app_log_location")), appBuildInfo: () => __TAURI_INVOKE("app_build_info"), - appCheckUpdate: () => typedError(__TAURI_INVOKE("app_check_update")), + appCheckUpdate: (source: UpdateSource) => typedError(__TAURI_INVOKE("app_check_update", { source })), /** * The background check made once per process when the first window starts; * later calls (other windows) and debug builds get `None`. Failures are logged, * not reported: the user didn't ask for this check. */ - appStartupUpdateCheck: () => __TAURI_INVOKE<{ + appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{ version: string, currentVersion: string, notes: string | null, date: string | null, -} | null>("app_startup_update_check"), +} | null>("app_startup_update_check", { source }), /** * Downloads and installs the available update, streaming progress to `on_progress`. * Cancellable through `request` until the download completes; the UI then restarts * with `app_restart`. One install runs at a time. */ - appInstallUpdate: (request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { request, onProgress })), + appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { source, request, onProgress })), debugExceptionFilters: () => typedError(__TAURI_INVOKE("debug_exception_filters")), debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })), debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })), @@ -982,15 +1056,22 @@ export const commands = { path: string, language: string, selections: Range[], -} | null) => typedError(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor })), +} | null, paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths })), /** Workbench events from this window (only the kinds of `plugin_event_kinds`). */ pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE("plugin_post_events", { events }), /** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */ pluginEventKinds: () => __TAURI_INVOKE("plugin_event_kinds"), /** Every plugin status bar item (later changes arrive as `statusItemChanged`). */ pluginStatusItems: () => __TAURI_INVOKE("plugin_status_items"), + /** Plugin context keys in effect for this window (later changes arrive as `contextChanged`). */ + pluginContextKeys: () => __TAURI_INVOKE("plugin_context_keys"), /** Cancels a plugin task (`taskStarted` with `cancellable`). */ pluginCancelTask: (taskId: string) => __TAURI_INVOKE("plugin_cancel_task", { taskId }), + /** + * Reports the user's answer to a plugin's `promptRequested`: the chosen keys + * (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed. + */ + pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE("plugin_answer_prompt", { promptId, answer }), /** * Content of a plugin view (`doc`: the document of a custom editor; `editor`: for * a tool window, the window's active editor, as commands get it). @@ -1009,6 +1090,14 @@ export const commands = { language: string, selections: Range[], } | null) => typedError(__TAURI_INVOKE("plugin_view_event", { pluginId, view, event, doc, editor })), + /** The Services pane of node `key` of a plugin's `location = "services"` view. */ + pluginServiceDetail: (pluginId: string, view: string, key: string) => typedError(__TAURI_INVOKE("plugin_service_detail", { pluginId, view, key })), + /** + * Runs the program of the log tab `tab` of node `key`'s Services pane (with + * the toggles in `on`), streaming its output until it exits or `request` is + * cancelled; returns the exit code. + */ + pluginServiceLog: (request: RequestId, pluginId: string, view: string, key: string, tab: string, on: string[], onLine: Channel) => typedError(__TAURI_INVOKE("plugin_service_log", { request, pluginId, view, key, tab, on, onLine })), /** The page of a plugin's web view or custom editor (`web = …`). */ pluginWebPage: (pluginId: string, view: string) => typedError(__TAURI_INVOKE("plugin_web_page", { pluginId, view })), /** @@ -1023,6 +1112,14 @@ export const commands = { pluginWebReadFile: (path: string) => typedError(__TAURI_INVOKE("plugin_web_read_file", { path })), /** Gutter markers of plugin `line-markers` providers for an open document. */ pluginLineMarkers: (doc: DocId) => typedError(__TAURI_INVOKE("plugin_line_markers", { doc })), + pluginNavigationFiles: () => typedError(__TAURI_INVOKE("plugin_navigation_files")), + pluginNavigationInvalidate: (paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_navigation_invalidate", { paths })), + pluginNavigationIndex: (paths: string[], request: RequestId) => typedError(__TAURI_INVOKE("plugin_navigation_index", { paths, request })), + pluginNavigationGoto: (doc: DocId, pos: Pos) => typedError<{ + range: Range, + targets: Location[], +} | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), + pluginNavigationMarkers: (paths: string[]) => typedError(__TAURI_INVOKE("plugin_navigation_markers", { paths })), /** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */ pluginMarket: (refresh: boolean) => typedError(__TAURI_INVOKE("plugin_market", { refresh })), pluginMarketUrl: () => __TAURI_INVOKE("plugin_market_url"), @@ -1292,6 +1389,16 @@ export type ActiveTheme = { theme: ThemeDef, }; +/** A task the agent runs in the background while the conversation goes on. */ +export type AgentBackgroundTask = { + id: string, + /** What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…). */ + kind: string, + description: string, + /** When the client first saw it (ms since the epoch). */ + startedMs: number | null, +}; + /** * What a task's agent can do beyond a plain conversation, as it reported when its * session started. @@ -1305,6 +1412,8 @@ export type AgentCapabilities = { rollback?: boolean, /** A message can join the running turn instead of waiting for it. */ steer?: boolean, + /** It reports its background tasks and stops them one by one. */ + backgroundTasks?: boolean, }; /** A file the task changed, relative to [`AgentTask::base_commit`]. */ @@ -1401,6 +1510,11 @@ endedMs: number | null; cancellable: boolean } | /** A message between sessions in a subagent's conversation: its task (`incoming`) or its report. */ { type: "message"; incoming: boolean; text: string } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[]; +/** + * Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object + * of question to answer instead of an option. + */ +questions?: AgentQuestion[]; /** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */ chosen: string | null } | { type: "error"; message: string }; @@ -1497,6 +1611,20 @@ export type AgentProvider = { source: AgentSource, }; +/** A question the agent asks with `AskUserQuestion`, answered by picking options or typing. */ +export type AgentQuestion = { + header?: string, + question: string, + multiSelect?: boolean, + options: AgentQuestionOption[], +}; + +export type AgentQuestionOption = { + label: string, + description?: string, + preview?: string | null, +}; + export type AgentQueued = { id: string, prompt: AgentPrompt, @@ -1595,6 +1723,8 @@ export type AgentTask = { * by its next output or any status change. */ retry?: AgentRetry | null, + /** What the agent runs in the background (shells, subagents, monitors), oldest first. */ + background?: AgentBackgroundTask[], }; export type AgentToolDiff = { @@ -1692,6 +1822,8 @@ export type AppearanceSettings = { redGreenVision?: boolean, differentToolWindowBackground?: boolean, compactMode?: boolean, + /** IDEA "Use smaller indents in trees": tool window trees indent less per level. */ + smallTreeIndents?: boolean, fullPathInTitle?: boolean, projectColorsInToolbar?: boolean, dndWithAltOnly?: boolean, @@ -1980,6 +2112,7 @@ export type CatalogPlugin = { authors: string[], homepage: string | null, changeNotes: string | null, + icon: PluginIcon | null, /** Package directory; install with `install_from_dir(path)`. */ path: IpcPath, hasCode: boolean, @@ -2053,6 +2186,8 @@ export type CheckoutOptions = { export type CheckoutOutcome = { kind: "done" } | /** These local changes (repository-relative paths) would be overwritten. */ { kind: "localChanges"; files: string[] } | +/** These untracked files (repository-relative paths) would be overwritten. */ +{ kind: "untrackedFiles"; files: string[] } | /** Local branch `local` has commits that remote branch `remote` does not. */ { kind: "localBranchAhead"; local: string; remote: string } | /** @@ -2066,6 +2201,8 @@ export type ClassifiedReference = { access?: ReferenceAccess | null, location: Location, kind: ReferenceKind, + /** The usage's line, when the server has it at hand (a library class's). */ + text?: string | null, }; export type ClearCachesResult = { @@ -2197,6 +2334,10 @@ export type CommitOptions = { * entries of the index change; afterwards their index entries match the new commit. */ partial?: PartialCommitFile[], + /** Files committed with their working-tree content, staged or not. */ + stage?: IpcPath[], + /** Staged files left out of the commit; they stay staged afterwards. */ + exclude?: IpcPath[], }; /** Version Control › Commit: commit checks and commit-message inspections. */ @@ -2371,6 +2512,19 @@ export type ComposeOp = "start" | "stop" | "restart" | /** Stops and removes the project's containers and networks. */ "down"; +/** Why `composer install` is due in a Composer project. */ +export type ComposerInstall = +/** No `vendor/autoload.php`. */ +"missing" | +/** A package of `composer.lock` is not installed, or at another version. */ +"outdated"; + +/** The Composer project `path` belongs to, when its dependencies need `composer install`. */ +export type ComposerInstallState = { + root: IpcPath, + reason: ComposerInstall, +}; + /** The user's answer on an install or consent page. */ export type Consent = { /** @@ -2426,6 +2580,8 @@ export type CoverageReportSpec = { kind: "lcov"; runner: string; path: IpcPath; base: IpcPath } | /** `go test -coverprofile`; import paths under `module` map to `module_dir`. */ { kind: "goProfile"; path: IpcPath; module: string; moduleDir: IpcPath } | +/** SimpleCov's `.resultset.json`; relative paths resolve against `base`. */ +{ kind: "simpleCov"; path: IpcPath; base: IpcPath } | /** Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. */ { kind: "jacoco"; dir: IpcPath } | /** The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o `). */ @@ -2601,7 +2757,9 @@ export type DbDriver = "sqlite" | "postgres" | /** MySQL and MariaDB. */ "mysql" | /** Microsoft SQL Server. */ -"sqlServer" | "clickhouse"; +"sqlServer" | "clickhouse" | +/** Oracle Database (via Oracle Instant Client). */ +"oracle"; /** `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. */ export type DbForeignKey = { @@ -2660,6 +2818,8 @@ export type DbOptions = { autoDisconnectSecs?: number | null, /** PostgreSQL: show only the configured database, its schemas right under the data source. */ singleDatabase?: boolean, + /** Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path). */ + instantClient?: string, }; /** One operation of a plan; `parent` indexes the plan's node list (roots: `None`). */ @@ -2828,7 +2988,12 @@ export type DebugEvent = * First event of every session (only build output of a root session's * `cargo` step can precede it). `parent` is set for adapter-started child sessions. */ -{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } | +{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | +/** + * The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it + * in the debug console reaches its input. + */ +{ kind: "programInput" } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } | /** * java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes * changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`. @@ -2869,6 +3034,13 @@ export type DeletedBranch = { unmerged: boolean, }; +/** A deleted tag, enough to restore it (Restore in the tag-deleted notification). */ +export type DeletedTag = { + name: string, + /** What the tag pointed at: its tag object (annotated tags) or commit. */ + target: string, +}; + export type DependencyUpdate = { latest: string, replacement: string, @@ -2967,6 +3139,12 @@ export type Diagnostic = { server?: string | null, }; +/** The two files of a command-line diff (`openidea diff a.txt b.txt`), both sides editable. */ +export type DiffFilesLaunch = { + left: IpcPath, + right: IpcPath, +}; + export type DirectoryCompareEntry = { path: string, status: DirectoryCompareStatus, @@ -3482,12 +3660,14 @@ export type ExtensionCount = { }; /** A formatter that runs outside the language servers, with its own settings group. */ -export type ExternalFormatter = "prettier" | "oxfmt"; +export type ExternalFormatter = "prettier" | "oxfmt" | "ruff"; -/** An external formatter (Prettier, oxfmt) and the languages it formats. */ +/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */ export type ExternalFormatterSettings = { - /** Empty path: project node_modules, managed installation, then PATH. */ + /** Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. */ path?: string, + /** Ruff only: directory containing its configuration, or a configuration file. */ + configurationDirectory?: string, languages?: string[], /** Prefer this formatter even when a language server offers formatting. */ prefer?: boolean, @@ -3574,6 +3754,25 @@ export type FileIdentifiers = { occurrences: IdentifierOccurrence[], }; +/** + * Added and deleted lines of one file between two revisions (`git diff --numstat`). + * A rename keeps the counts of the content change; a pure rename is `0`/`0`. + */ +export type FileLineStats = { + /** Path after the change (the only path, when the file was not renamed). */ + path: string, + oldPath: string | null, + added: number, + removed: number, +}; + +/** The resolved gutter markers of one file. */ +export type FileMarkers = { + path: string, + version: number | null, + markers: NavigationResult[], +}; + export type FileMatch = { path: IpcPath, /** Path relative to the project root, as displayed. */ @@ -3767,6 +3966,8 @@ export type GeneralSettings = { powerSaveMode?: boolean, /** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */ projectName?: string, + /** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */ + updateSource?: string, }; /** A repository of the signed-in account (owned, collaborator, or organization member). */ @@ -3836,9 +4037,11 @@ export type GitExecutableInfo = { export type GitOperation = /** Continue the merge / rebase / cherry-pick / revert in progress. */ -{ op: "continue" } | { op: "abort" } | { op: "skip" } | -/** Commits oldest first. */ -{ op: "cherryPick"; commits: string[] } | +{ op: "continue" } | +/** Stage and Retry: stage the changes of tracked files, then continue. */ +{ op: "stageAndContinue" } | { op: "abort" } | { op: "skip" } | +/** Commits oldest first; `record_origin` adds "(cherry picked from commit …)". */ +{ op: "cherryPick"; commits: string[]; recordOrigin?: boolean } | /** Commits newest first. */ { op: "revert"; commits: string[] } | { op: "reset"; commit: string; mode: ResetMode } | /** Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. */ @@ -3989,6 +4192,12 @@ export type GradleTask = { description: string | null, }; +/** HEAD before and after a history rewrite, for its Undo. */ +export type HeadChange = { + before: string, + after: string, +}; + export type HierarchyChoice = { id: string, makeAbstract: boolean, @@ -4304,6 +4513,11 @@ export type IndexEntry = { engine?: string, /** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */ categories?: string[], + /** + * The manifest's icon, sanitised by whoever wrote the index and again by + * the IDE before showing it. + */ + icon?: PluginIcon | null, }; export type IndexStatus = { @@ -4796,6 +5010,7 @@ export type LanguagesSettings = { typescript?: TypeScriptSettings, prettier?: ExternalFormatterSettings, oxfmt?: ExternalFormatterSettings, + ruff?: ExternalFormatterSettings, java?: JavaSettings, ruby?: RubySettings, markdown?: MarkdownSettings, @@ -4820,6 +5035,8 @@ export type Launch = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ comparison?: ComparisonLaunch | null, + /** `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none). */ + diff?: DiffFilesLaunch | null, /** Deployment windows: the server and local selection to compare. */ deployment?: DeploymentLaunch | null, }; @@ -5202,6 +5419,11 @@ export type MavenSettings = { * `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on. */ mvndHome?: string, + /** + * Thread count of Maven builds (`-T`): a number of threads (`4`) or a core + * multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module). + */ + threads?: string, }; export type MemoryCategory = @@ -5287,6 +5509,17 @@ export type MethodStat = { total: number | null, }; +export type NavigationFiles = { + paths: IpcPath[], + indexing: boolean, + extensions: string[], +}; + +export type NavigationResult = { + range: Range, + targets: Location[], +}; + export type NewAgentTask = { /** Validated by the host; defaults to the window project root. */ projectRoot?: IpcPath | null, @@ -5514,6 +5747,35 @@ export type PhpSettings = { * `composer.json` (`wordpress`). */ stubs?: string[], + /** + * PHPStan configuration file for checking saved files; empty = the `phpstan.neon`, + * `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute, + * `~/`, or relative to the project. + */ + phpstanConfig?: string, +}; + +/** A PHP interpreter's version and the Xdebug it loads. */ +export type PhpXdebug = { + php: IpcPath, + /** `PHP_VERSION`. */ + phpVersion: string, + /** The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets. */ + xdebug: string | null, +}; + +/** An entry of a plugin's `pick` or `pick-many`. */ +export type PickItem = { + key: string, + label: string, + description: string | null, +}; + +/** A plugin's context key for `when` clauses (`value` is JSON text). */ +export type PluginContextKey = { + pluginId: string, + key: string, + value: string, }; /** The focused editor when a plugin command is invoked. */ @@ -5527,6 +5789,8 @@ export type PluginEditor = { export type PluginEvent = /** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ { type: "commandsChanged" } | +/** Cached document associations were disposed (reload, disable or failure). */ +{ type: "documentIndexesChanged" } | /** * The marketplace offers newer versions of installed plugins (announced * once per set of versions). @@ -5534,6 +5798,11 @@ export type PluginEvent = { type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | /** A plugin's status bar item was set (`item`) or removed. */ { type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } | +/** + * A plugin's context key was set (`value`: JSON text) or removed, in the + * emitted window (every window when the client is `None`). + */ +{ type: "contextChanged"; pluginId: string; key: string; value: string | null } | /** A plugin began a task: show it with the background tasks. */ { type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean } | { type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null } | { type: "taskEnded"; pluginId: string; taskId: string } | /** @@ -5560,7 +5829,22 @@ export type PluginEvent = /** Copy `text` to the system clipboard. */ { type: "copyToClipboard"; pluginId: string; text: string } | /** Open `url` (`http(s)://`) in the system browser. */ -{ type: "openUrl"; pluginId: string; url: string }; +{ type: "openUrl"; pluginId: string; url: string } | +/** + * A command waits for the user's answer: show `prompt`, then report it with + * `plugin_answer_prompt(prompt_id, …)`. + */ +{ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt } | +/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */ +{ type: "promptClosed"; promptId: string } | +/** Bring the tool window of the plugin's view `view` to the front. */ +{ type: "revealView"; pluginId: string; view: string }; + +/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */ +export type PluginIcon = { + light: string, + dark: string | null, +}; export type PluginInfo = { id: string, @@ -5570,6 +5854,7 @@ export type PluginInfo = { authors: string[], homepage: string | null, changeNotes: string | null, + icon: PluginIcon | null, source: PluginSource, /** Package directory. */ path: IpcPath, @@ -5593,6 +5878,12 @@ export type PluginInfo = { previousVersion: string | null, }; +/** A line a view's `process` body printed. */ +export type PluginOutputLine = { + stderr: boolean, + text: string, +}; + /** A secret a plugin declares, and whether the user stored it. */ export type PluginSecretState = { name: string, @@ -5658,6 +5949,29 @@ export type PluginsSnapshot = { ui: UiContribution[], }; +/** What a go-to-declaration in a POM found, and where. */ +export type PomGoto = { + /** + * Absolute path of the file with the declaration: the POM itself, or a parent + * POM of the local repository when the definition is inherited from one. + */ + path: string, + range: Range, + /** What was found. */ + kind: PomGotoKind, + /** The declaration as named for the user (`example.version`, `com.example:example-lib`). */ + label: string, +}; + +/** The kind of declaration a POM go-to-declaration found. */ +export type PomGotoKind = +/** A `` entry of the POM or one of its parents. */ +"property" | +/** A `` entry providing the dependency's version. */ +"managedDependency" | +/** A `` entry providing the plugin's version. */ +"managedPlugin"; + /** A 0-based line / UTF-16 column position. */ export type Pos = { line: number, @@ -5890,6 +6204,9 @@ export type ProjectStructureSettings = { excludeFiles?: string, }; +/** What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). */ +export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null } | { kind: "confirm"; title: string; message: string }; + export type ProxyKind = "http" | "socks"; export type ProxyMode = @@ -6045,6 +6362,8 @@ export type References = { classified?: ClassifiedReference[], locations: Location[], incomplete: ReferencesIncomplete | null, + /** The dependencies and the JDK were not searched (asked for without `libraries`). */ + librariesSkipped?: boolean, }; /** Why a references search may have missed usages. */ @@ -6124,9 +6443,15 @@ export type RemoteLaunch = { path: string, }; -export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; +export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | +/** Update Project › Reset to the Remote Branch. */ +{ op: "resetToUpstream" } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; /** Push dialog › Push tags. */ -tags?: PushTags | null } | +tags?: PushTags | null; +/** Plain `--force` (Force push anyway). */ +force?: boolean; +/** Push dialog › Run Git hooks unchecked (`--no-verify`). */ +no_verify?: boolean } | /** Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). */ { op: "deleteBranch"; branch: string } | /** @@ -6134,7 +6459,13 @@ tags?: PushTags | null } | * remote branch `target`; `remote` overrides the upstream's remote (Push dialog's * remote picker). */ -{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null; remote?: string | null } | +{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null; remote?: string | null; force?: boolean; +/** Record the target as upstream (Push dialog › Set upstream). */ +set_upstream?: boolean; no_verify?: boolean } | +/** Push Tag › Push to `remote`. */ +{ op: "pushTag"; tag: string; remote: string } | +/** Deletes tag `tag` on `remote` (Delete on Remote). */ +{ op: "deleteRemoteTag"; tag: string; remote: string } | /** Fast-forwards local `branch` (not checked out) to its upstream. */ { op: "updateBranch"; branch: string } | /** Pull dialog: `branches` of `remote` (none = the configured merge branch). */ @@ -6329,7 +6660,10 @@ export type RunConfig_Serialize = { export type RunExtras = RunExtras_Serialize | RunExtras_Deserialize; export type RunExtras_Deserialize = { - /** Module supplying a JVM application's classpath; empty means the project root. */ + /** + * Owning module, independent of the working directory; also supplies a JVM application's classpath. + * Empty means the project root. + */ module?: string | null, /** Values of the configuration type's own form fields (the command line derives from them). */ params?: { [key in string]: string }, @@ -6368,7 +6702,10 @@ export type RunExtras_Deserialize = { }; export type RunExtras_Serialize = { - /** Module supplying a JVM application's classpath; empty means the project root. */ + /** + * Owning module, independent of the working directory; also supplies a JVM application's classpath. + * Empty means the project root. + */ module?: string | null, /** Values of the configuration type's own form fields (the command line derives from them). */ params?: { [key in string]: string }, @@ -6457,22 +6794,57 @@ export type RunSettings = { terminationTimeoutMs?: number, }; -/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */ +/** + * Build, Execution, Deployment › Run Targets: run configurations on another machine + * or in a container. + */ export type RunTarget = { + kind?: RunTargetKind, id?: string, name?: string, - /** [`SshConfiguration::id`]. */ + /** [`SshConfiguration::id`] (SSH). */ sshConfig?: string, - /** Remote folder the project is synchronised to and run from. */ + /** Remote folder the project is synchronised to and run from (SSH). */ projectRoot?: string, - /** Upload changed project files before each run. */ + /** Upload changed project files before each run (SSH). */ syncBeforeRun?: boolean, /** Interpreters on the target; empty = the one on the remote `PATH`. */ python?: string, node?: string, java?: string, + /** + * Id of a Docker connection of the Services tool window (Docker, Compose); + * empty = the CLI's own daemon. + */ + dockerConnection?: string, + /** Image to run (Docker). */ + image?: string, + /** Compose files, relative to the project; empty = the project's default one (Compose). */ + composeFiles?: string[], + /** Service to run (Compose). */ + service?: string, + /** + * Where the project is in the container; empty = `/workspaces/` (Docker), + * the service's bind mount of the project (Compose). + */ + containerRoot?: string, + /** Published ports, as `docker run --publish` takes them (`8000:8000`). */ + ports?: string[], + /** More options for `docker run` / `docker compose run`, shell-like quoted. */ + runOptions?: string, + /** Run as the image's user instead of the project folder's owner (Linux). */ + imageUser?: boolean, }; +/** Where a run target runs configurations. */ +export type RunTargetKind = +/** Another machine over SSH, the project synchronised to its folder. */ +"ssh" | +/** A new container of an image (`docker run --rm`), the project bind-mounted. */ +"docker" | +/** A new container of a Compose service (`docker compose run --rm`). */ +"compose"; + export type RustSettings = { /** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */ toolchain?: string, @@ -6598,6 +6970,32 @@ export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" */ "misconfigured"; +export type ServiceLog = { + program: string, + args: string[], + toggles: ServiceLogToggle[], +}; + +/** A switch above the log; when on, `args` are appended and the program restarts. */ +export type ServiceLogToggle = { + id: string, + label: string, + args: string[], +}; + +export type ServicePane = { kind: "message"; value: string } | +/** Read-only text that can be selected and copied. */ +{ kind: "text"; value: string } | +/** A program the IDE runs (with the plugin's `process:` grant), followed in a log. */ +{ kind: "log"; value: ServiceLog }; + +/** A tab of the Services pane of a plugin's node (`service-provider.detail`). */ +export type ServiceTab = { + id: string, + label: string, + body: ServicePane, +}; + /** Exception filters of one session (see [`DebugManager::exception_filters`]). */ export type SessionExceptionFilters = { session: DebugSessionId, @@ -6840,6 +7238,8 @@ export type SmartKeysSettings = { /** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */ reformatOnPaste?: string, smartIndent?: boolean, + /** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */ + insertDocStub?: boolean, /** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */ backspaceUnindent?: string, }; @@ -6876,7 +7276,10 @@ export type SpellingSettings = { }; export type SqlStatement = { - /** UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). */ + /** + * UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except + * after an Oracle PL/SQL block, whose `END;` needs it). + */ from: number, to: number, text: string, @@ -7419,6 +7822,13 @@ export type UpdateProgress = /** Download done; the new version is being written over this one. */ { type: "installing" }; +/** Where updates are downloaded from (System Settings › Updates). */ +export type UpdateSource = +/** The GitHub releases named in tauri.conf.json. */ +"github" | +/** The CNB mirror of the same releases, for networks where GitHub is slow. */ +"cnb"; + export type Variable = { name: string, value: string, @@ -7467,6 +7877,17 @@ export type VcsSettings = { issueNavigation?: IssueLink[], }; +/** The tool that creates a new Python environment in "Add Python Interpreter". */ +export type VenvTool = +/** `base -m venv `. */ +"venv" | +/** `uv venv --python ` (uv must be on PATH). */ +"uv" | +/** `poetry env use ` in the project, then `poetry env info --path`. */ +"poetry" | +/** `conda create -p -y` (conda must be on PATH). */ +"conda"; + /** What a view or custom editor shows. */ export type View = { toolbar: ViewButton[], diff --git a/src/editor/api.ts b/src/editor/api.ts index 15f7c20f5..cc9332941 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -279,8 +279,12 @@ export interface ScrollbarMark { to?: number; /** CSS colour, normally a token such as `var(--danger)`. */ color: string; - /** `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences). */ - kind?: "line" | "thin"; + /** + * `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences); + * `full`: the lane's whole width, from the top of the first line to the top of the last (IDEA's + * diff and merge error stripe, so a one-line change is a min-height dash). + */ + kind?: "line" | "thin" | "full"; /** Shown on hover. */ tooltip?: string; /** Higher wins when marks overlap. Default 0. */ diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 4574a17e7..e6410539e 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -62,7 +62,7 @@ import { isColumnMode, toggleColumnMode } from "./cm/columnMode"; import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion"; import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras"; import { cyclicExpandWord } from "./wordCompletion"; -import { copyReferenceToClipboard, qualifiedReference, referenceOffset } from "./copyReference"; +import { copyReferenceToClipboard, declaredName, qualifiedReference, referenceOffset } from "./copyReference"; import { declarationChain } from "./view/outline"; import { recordClipboard } from "./clipboardHistory"; import { useDocMeta, useOutline } from "./model/document"; @@ -439,7 +439,7 @@ async function resolveReference(view: EditorView, slot: EditorSlot): Promise item.name), + chain: localChain.map(declaredName), line: position.number - 1, }); } else { @@ -462,7 +462,7 @@ async function resolveReference(view: EditorView, slot: EditorSlot): Promise item.name), + chain: chain.map(declaredName), line: pos.line, }); } diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 8c1cfbef5..6a687012e 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -21,6 +21,7 @@ import { type SyntaxHighlights, type TextChange, } from "@/platform/ipc"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { EditorState } from "@codemirror/state"; import { customRegions } from "./cm/regions"; @@ -60,6 +61,11 @@ export interface DocBackend { onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void; /** Files changed on disk (used to refresh nothing but closed-file state). */ onFsChanged?(handler: (e: { removed: string[] }) => void): () => void; + /** + * Every OpenIDEA window lost focus to another application. Also seen while focus is in a frame + * (a plugin's web view), which hides the window's own `blur`. + */ + onApplicationDeactivated?(handler: () => void): () => void; /** * An open document's language changed (Settings › File Types, Override File * Type), or its own mode (`DocumentInfo.uiMode`). @@ -86,6 +92,11 @@ export const ipcBackend: DocBackend = { onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)), onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)), onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)), + onApplicationDeactivated: (h) => { + if (!isTauri()) return () => {}; + const listener = getCurrentWebviewWindow().listen("ide://application-deactivated", () => h()); + return () => void listener.then((unlisten) => unlisten()); + }, onLanguage: (h) => onIdeEvent("docLanguage", (e) => h(e)), }; @@ -115,6 +126,7 @@ const EXT_LANG: Record = { kt: "kotlin", kts: "kotlin", java: "java", + cs: "csharp", py: "python", gd: "gdscript", go: "go", diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index f787dd2d0..271225542 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -22,10 +22,10 @@ import { type Tree, type TreeFragment, } from "@lezer/common"; +import { JS_TAGS } from "../jsTags"; import { parser } from "./parser"; -// @lezer/javascript styles `.` as a deref operator, but not `?.`. -const jsParser = javascriptLanguage.parser.configure({ props: [styleTags({ "?.": t.derefOperator })] }); +const jsParser = javascriptLanguage.parser.configure({ props: [styleTags(JS_TAGS)] }); const exprParser = jsParser.configure({ top: "SingleExpression" }); const baseParser = parser.configure({ diff --git a/src/editor/core/cm/astro/astro.grammar b/src/editor/core/cm/astro/astro.grammar new file mode 100644 index 000000000..875bc9f0f --- /dev/null +++ b/src/editor/core/cm/astro/astro.grammar @@ -0,0 +1,10 @@ +// Astro components: the frontmatter, a TypeScript script between `---` fences, +// then the template. `index.ts` parses the script as TypeScript and the +// template as HTML with `{…}` expressions. Regenerate with +// `pnpm gen:astro-grammar`. + +@top Document { Frontmatter? Template? } + +Frontmatter { FrontmatterFence FrontmatterScript? FrontmatterFence? } + +@external tokens component from "./tokens" { FrontmatterFence, FrontmatterScript, Template } diff --git a/src/editor/core/cm/astro/astro.test.ts b/src/editor/core/cm/astro/astro.test.ts new file mode 100644 index 000000000..36708d2fe --- /dev/null +++ b/src/editor/core/cm/astro/astro.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { EditorState } from "@codemirror/state"; +import { indentUnit } from "@codemirror/language"; +import { buildParserFile } from "@lezer/generator"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "../languages"; +import { pasteReindentFilter } from "../prefs"; +import { astro, astroLanguage } from "./index"; + +const highlighter = tagHighlighter([ + { tag: t.processingInstruction, class: "fence" }, + { tag: t.definitionKeyword, class: "def-kw" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.typeName, class: "type" }, + { tag: t.function(t.variableName), class: "call" }, + { tag: t.variableName, class: "var" }, + { tag: t.propertyName, class: "prop" }, + { tag: t.attributeName, class: "attr" }, + { tag: t.attributeValue, class: "value" }, + { tag: t.string, class: "str" }, + { tag: t.operator, class: "op" }, + { tag: t.brace, class: "brace" }, + { tag: t.tagName, class: "tag" }, + { tag: t.comment, class: "comment" }, + { tag: t.invalid, class: "invalid" }, +]); + +/** `text:class` of the styled tokens of `doc`. */ +function tokens(doc: string): string[] { + const out: string[] = []; + highlightTree(astroLanguage.parser.parse(doc), highlighter, (from, to, cls) => + out.push(`${doc.slice(from, to)}:${cls}`), + ); + return out; +} + +/** A layout with what made HTML mode misread Astro components. */ +const LAYOUT = `--- +import '@/styles/global.css' +import Header from '@/components/Header.astro' + +interface Props { + title: string + visibility?: 'public' | 'private' +} + +const { title, visibility = 'private' } = Astro.props +// The canonical URL drops the trailing slash, as expects. +const pageUrl = new URL(Astro.url.pathname.replace(/(.)\\/$/u, '$1'), Astro.url).href +const image = { path: '/og.png', width: 1200 } +--- + + + + + + {visibility === 'private' && } + {visibility === 'public' && ( + <> + + + + )} + {title} + + +
    + + + + + + + +`; + +describe("Astro components", () => { + it("parses the frontmatter as TypeScript, markup in its comments included", () => { + const doc = "---\ninterface P { title: string }\nconst a = 'x' // on \n---\n

    {a}

    "; + const out = tokens(doc); + expect(out).toEqual( + expect.arrayContaining([ + "---:fence", + "interface:def-kw", + "string:type", + "const:def-kw", + "'x':str", + "// on :comment", + "p:tag", + "a:var", + ]), + ); + expect(out).not.toContain("html:tag"); + }); + + it("parses expressions in text, fragments and markup inside them included", () => { + const out = tokens( + "\n {v === 'p' && }\n {ok && (\n <>\n {n}\n \n )}\n", + ); + expect(out).toEqual( + expect.arrayContaining(["v:var", "===:op", "'p':str", "&&:op", "meta:tag", "name:attr", "n:var"]), + ); + expect(out.filter((s) => s.endsWith(":invalid"))).toEqual([]); + }); + + it("parses expressions in tags, with spaces, shorthands and spreads", () => { + const out = tokens( + '\n', + ); + expect(out).toEqual(expect.arrayContaining(["content:attr", "new:keyword", "img:var", "title:var", "rest:var"])); + expect(out).not.toContain("URL(img.path,:attr"); + expect(out).toContain('"{literal}":value'); + }); + + it("parses scripts as TypeScript and leaves styles to CSS", () => { + const out = tokens("\n"); + expect(out).toEqual(expect.arrayContaining(["const:def-kw", "number:type", "script:tag", "style:tag"])); + }); + + it("keeps a well-indented component unchanged when it is pasted", () => { + // CodeMirror parses for at most 20 ms per transaction and indents with the partial tree. + // A frozen clock lets a slow CI runner parse the whole paste. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const state = EditorState.create({ + extensions: [astro(), indentUnit.of(" "), EditorState.tabSize.of(2), pasteReindentFilter("indentEachLine")], + }); + const pasted = state.update({ changes: { from: 0, insert: LAYOUT }, userEvent: "input.paste" }).state; + expect(pasted.doc.toString()).toBe(LAYOUT); + } finally { + vi.useRealTimers(); + } + }); + + it("is generated from astro.grammar", () => { + const dir = import.meta.dirname; + const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "astro.grammar"), "utf8"), { + fileName: "src/editor/core/cm/astro/astro.grammar", + moduleStyle: "es", + typeScript: true, + }); + expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8")); + expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")); + }); + + it("is the Astro mode", async () => { + const desc = findLanguageDescription("Astro", "/site/src/pages/index.astro"); + expect((await loadSupport(desc!))?.language).toBe(astroLanguage); + }); +}); diff --git a/src/editor/core/cm/astro/index.ts b/src/editor/core/cm/astro/index.ts new file mode 100644 index 000000000..b8bd6886a --- /dev/null +++ b/src/editor/core/cm/astro/index.ts @@ -0,0 +1,134 @@ +/** + * Astro components: a TypeScript frontmatter between `---` fences + * (`astro.grammar`), then an HTML template whose `{…}` expressions, in text + * and in tags, are TSX. `