Compare commits

..
Author SHA1 Message Date
l0ng-ai 0024a1c705 feat(plugins): keep a plugin's signer across updates and announce updates
- Index format: fields are only added; an IDE reading a newer `format`
  still applies its revocations and `moved`, lists no plugins and asks to
  be updated, instead of failing to read the index.
- Key continuity: an installed plugin updates silently only to a package
  signed with the same key, or a key it endorsed (`KeyEntry.endorsement`,
  `index --previous-key`, `openidea-plugin endorse`). Another signer, or an
  unsigned package replacing a signed one, is shown on the install page and
  needs the user's confirmation (`Consent.signerChange`).
- Official key: release builds carry the official publisher's public key
  (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from PLUGIN_SIGNING_KEY);
  `openidea.*` plugins must be signed with it or a key it endorsed,
  whatever the index lists.
- Updates: the daily marketplace check announces new versions once, with
  Update All (also in the gear menu and as an action). An update that asks
  for nothing new installs with the grants the installed version has.
- Index entries need `engine`; `categories` beyond the first three are
  ignored instead of rejecting the manifest.
2026-11-04 17:47:46 +08:00
l0ng-ai fa7710cd4a fix(plugins): translate the AI category in Chinese 2026-11-04 17:46:51 +08:00
l0ng-ai a7cd2e80de fix(plugins): full-width category filter and aligned incompatibility note 2026-11-04 17:46:51 +08:00
l0ng-ai 2273c9a292 feat(plugins): let a marketplace index point to where it moved
An index with `moved` sends the IDE to the index at that URL (relative or
absolute, up to three hops; an https index can only move to https). Downloads
resolve against the index actually read, so a marketplace can change hosts
while released IDEs keep the address they were built with.
2026-11-04 17:46:51 +08:00
l0ng-ai 53aa2d5704 feat(plugins): file marketplace plugins under categories
`plugin.toml` takes `categories` (up to 3 of languages, themes, icon-themes,
linters, vcs, ai, frameworks, tools, other). A plugin that declares none is
filed under what its contributions imply (themes, icon themes, languages),
else under other; categories an IDE doesn't know are ignored, and `check`
warns about them. Index entries carry the categories, and Settings › Plugins
› Available filters by them and shows them in a plugin's details. The
official plugins declare theirs.
2026-11-04 17:46:51 +08:00
l0ng-ai af0200e2e7 feat(plugins): offer the newest plugin version this IDE can load
Index entries record the plugin API each version needs (`engine`: the
manifest's `engines.openidea` narrowed to the API its component was built
for), and an index may list several versions of a plugin. Settings › Plugins
offers the newest version this IDE can load; with none, the plugin is shown
as incompatible with the API it needs, and downloading it is refused.

`openidea-plugin index` keeps, of each plugin, the newest version per API
requirement, so users of an older IDE keep the last version built for them;
`--download-base` makes downloads absolute, and the release workflow points
them at the release that published them. Scratch directories are now unique
per call.
2026-11-04 17:46:51 +08:00
l0ng-ai 812d27e8cf feat(plugins): list community plugins from a registry in the marketplace
The marketplace index can now carry plugins of other publishers, published
from their authors' repositories through the l0ng-ai/openidea-plugins
registry.

- `openidea-plugin index` with a key replaces only that publisher's plugins
  (it used to drop every other entry); without a key it indexes each package
  against the publishers the index already lists, checking the key and the
  id prefix instead of only the signature itself.
- `openidea-plugin merge` takes another index's publishers, plugins and
  revocations in place of every publisher but `--keep`; the other index can't
  list or revoke the kept publishers' plugins.
- `openidea-plugin check` accepts a `.oiplugin` file.
- Releases merge the registry's index into plugins-index.json and attach the
  Linux x64 tool; plugins-index.yml refreshes the community plugins in the
  latest release hourly or when the registry signals a change.
- plugins/README.md and CONTRIBUTING.md describe official and community
  plugins.
2026-11-04 17:46:51 +08:00
502 changed files with 2762 additions and 37579 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
<!-- If this resolves an issue: Closes #<n> -->
Closes #
## What and why
-110
View File
@@ -1,110 +0,0 @@
name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths:
- "crates/**"
- "languages/**"
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
VTSLS: 0.3.0
TYPESCRIPT: 6.0.3
BASEDPYRIGHT: 1.40.2
RUFF: 0.17.3
GOPLS: v0.24.0
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
jobs:
smoke:
name: Language smoke (${{ matrix.language }})
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
- uses: Swatinem/rust-cache@v2
with:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
- if: matrix.language == 'python'
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
- if: matrix.language == 'go'
uses: actions/setup-go@v5
with: { go-version: "1.27", cache: false }
- if: matrix.language == 'go'
run: go install "golang.org/x/tools/gopls@$GOPLS"
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
| tar xz -C "$RUNNER_TEMP/jdtls"
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
- if: matrix.language == 'kotlin'
uses: gradle/actions/setup-gradle@v4
with: { gradle-version: "${{ env.GRADLE }}" }
- if: matrix.language == 'kotlin'
run: |
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
+9
View File
@@ -24,9 +24,18 @@ jobs:
const pr = context.payload.pull_request;
const LABEL = 'needs-info';
const MARKER = '<!-- pr-check -->';
// Docs, CI and pure refactors may skip the linked issue.
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
const problems = [];
const linksIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
}
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
if (!verified || !verified[1].trim()) {
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
+5 -12
View File
@@ -18,12 +18,9 @@ Visual style: dark, low-contrast greys, one cool accent
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
exporting `activate()` registered in `src/features/index.ts`.
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `crates/ide-host` — the `openidea-server` binary for remote development.
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
`templates/` — plugin starter templates.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
@@ -49,8 +46,8 @@ Visual style: dark, low-contrast greys, one cool accent
## Issues & PRs
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
@@ -67,10 +64,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
this worktree's running MCP bridges and actual ports.
- `pnpm build` — typecheck + bundle frontend.
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
+4 -2
View File
@@ -16,7 +16,8 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
## Pull requests
- One problem per PR. If it resolves an issue, link it with `Closes #123`.
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
Docs, CI and pure refactors may skip the issue.
- Reproduce the problem first, or write a test that fails before the fix.
- Fix the cause, and check whether the same cause breaks anything nearby.
- Keep the diff focused. Unrelated refactors go in their own PR.
@@ -35,4 +36,5 @@ and the plugin API. Community plugins live in their authors' repositories and
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
see [`plugins/README.md`](plugins/README.md) § 8.1.
A check on each PR adds `needs-info` when the verification section is missing, and removes it once it's filled in.
A check on each PR adds `needs-info` when the issue link or the verification
section is missing, and removes it once they're filled in.
Generated
+36 -40
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4116,7 +4116,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4130,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4148,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4174,7 +4174,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"serde",
@@ -4187,7 +4187,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4207,7 +4207,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4219,7 +4219,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4232,7 +4232,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4311,7 +4311,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4333,7 +4333,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"encoding_rs",
"futures",
@@ -4341,7 +4341,6 @@ dependencies = [
"parking_lot",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"tokio",
"tokio-util",
@@ -4350,7 +4349,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4406,7 +4405,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dunce",
"futures",
@@ -4432,7 +4431,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"futures",
"ide-core",
@@ -4455,7 +4454,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"blake3",
"ide-core",
@@ -4475,7 +4474,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4488,7 +4487,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"globset",
"ide-core",
@@ -4505,7 +4504,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4528,7 +4527,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dunce",
"ide-core",
@@ -4547,7 +4546,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"criterion",
"globset",
@@ -4567,7 +4566,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4580,7 +4579,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4599,7 +4598,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-language",
@@ -4614,7 +4613,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"libc",
@@ -4627,7 +4626,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"criterion",
"ide-core",
@@ -4639,7 +4638,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"flate2",
"hex",
@@ -4660,16 +4659,14 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"gix",
"ide-core",
"ignore",
"libc",
"redb",
"regex-lite",
"serde",
"serde_json",
@@ -4681,7 +4678,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"chardetng",
"dunce",
@@ -4703,7 +4700,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"ide-core",
"open",
@@ -4719,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
@@ -4737,7 +4734,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
"zip",
]
@@ -6192,7 +6188,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.36"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.36"
version = "0.1.35"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-12
View File
@@ -14,8 +14,6 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -47,16 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Plugins
Plugins add actions, completion, quick fixes, tool windows, custom editors,
themes, file icons and languages. They run as sandboxed WebAssembly components;
themes, icon themes and languages need no code at all.
Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+3 -12
View File
@@ -2,12 +2,12 @@
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
`plugins/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)
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.
Source tag, patch commits and build instructions: [java/lombok/README.md](java/lombok/README.md).
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
@@ -52,15 +52,6 @@ Their original copyright headers and Apache-2.0 licensing are retained.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform VCS log graph algorithms
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
graph layout and LinearBek merge linearization algorithms from
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
+5 -13
View File
@@ -58,8 +58,6 @@ pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
/// How long deleting a task waits for its agent to exit before removing the worktree.
const EXIT_WAIT: Duration = Duration::from_secs(5);
/// The agent tasks of one project.
pub struct AgentManager {
@@ -841,7 +839,7 @@ impl AgentManager {
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
let live = task.kill();
task.kill();
let (isolation, cwd, root) = {
let s = task.state.lock();
(
@@ -853,11 +851,6 @@ impl AgentManager {
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
// On Windows the agent's processes lock the folder (their working directory,
// open files) until they have exited.
if let Some(peer) = live {
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
}
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
}
@@ -1612,12 +1605,11 @@ impl Task {
}
}
/// Stops the agent; returns its connection, to wait for its process to exit.
fn kill(&self) -> Option<acp::Conn> {
fn kill(&self) {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
Some(live.peer)
if let Some(live) = self.live.lock().take() {
live.peer.kill();
}
}
}
-111
View File
@@ -1109,114 +1109,3 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
assert!(live(&mgr, &task.id));
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")]
async fn deleting_a_task_waits_for_its_agent_to_exit_before_removing_the_worktree() {
for no_load in [false, true] {
delete_running_agent(no_load).await;
}
}
async fn delete_running_agent(no_load: bool) {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
let agent = Arc::new(FakeAgent { no_load, ..FakeAgent::default() });
let pids: Arc<Mutex<Vec<u32>>> = Arc::default();
let (fake, started) = (agent.clone(), pids.clone());
// The fake agent, served on behalf of a real process that exits only when killed.
let connector: Connector = Arc::new(move |_spec, cwd, client, _stderr| {
#[cfg(unix)]
let mut cmd = {
let mut cmd = tokio::process::Command::new("sleep");
cmd.arg("30");
cmd
};
#[cfg(windows)]
let mut cmd = {
let mut cmd = tokio::process::Command::new("cmd");
cmd.args(["/D", "/C", "echo ready>agent-ready & ping -n 30 127.0.0.1 >nul"]);
cmd
};
ide_core::process::prepare(cmd.as_std_mut());
ide_core::process::own_group(cmd.as_std_mut());
cmd.current_dir(cwd).kill_on_drop(true);
let child = cmd.spawn()?;
started.lock().push(child.id().unwrap());
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
// `connect_with_child` frames with Content-Length.
fake.servers.lock().push(Peer::connect(br, bw, fake.clone()));
Ok(Peer::connect_with_child(ar, aw, client, child))
});
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: root.clone(),
store_dir: root.join("store"),
worktrees_dir: root.join("worktrees"),
models_file: root.join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(if no_load { "again" } else { "Fix the lexer" }),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Worktree,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, if no_load { AgentStatus::Ready } else { AgentStatus::NeedsInput }).await;
let pid = *pids.lock().last().expect("the agent was started");
#[cfg(windows)]
{
tokio::time::timeout(Duration::from_secs(5), async {
while !task.cwd.0.join("agent-ready").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(
std::fs::rename(&task.cwd.0, task.cwd.0.with_extension("moved")).unwrap_err().raw_os_error(),
Some(32),
"the running agent really locks its worktree"
);
}
mgr.delete(&task.id, true).await.unwrap();
assert!(!task.cwd.0.exists());
// Killed and reaped: not even a zombie is left.
#[cfg(unix)]
let alive = std::process::Command::new("kill").args(["-0", &pid.to_string()]).status().unwrap().success();
#[cfg(windows)]
let alive = {
let mut cmd = std::process::Command::new("tasklist");
ide_core::process::prepare(&mut cmd).args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
String::from_utf8_lossy(&cmd.output().unwrap().stdout).contains(&format!("\"{pid}\""))
};
assert!(!alive, "the agent (pid {pid}) is still running");
}
+2 -70
View File
@@ -5,7 +5,7 @@ use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, Repo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
use std::time::UNIX_EPOCH;
pub(crate) struct Created {
pub path: PathBuf,
@@ -65,27 +65,7 @@ pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) ->
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
return Err(err);
}
retrying(REMOVE_ATTEMPTS, REMOVE_PAUSE, || std::fs::remove_dir_all(path))
.map_err(|e| IdeError::internal(format!("{}: {e}", path.display())))
}
/// On Windows a folder can't be deleted while a process still has a file in it open
/// (os error 32): an agent's helper that outlived it, an antivirus scan, the search indexer.
/// Those let go within moments, so removing is retried for a few seconds.
const REMOVE_ATTEMPTS: u32 = 10;
const REMOVE_PAUSE: Duration = Duration::from_millis(300);
fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
for _ in 1..attempts {
match op() {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => std::thread::sleep(pause),
_ => return Ok(()),
}
}
match op() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
r => r,
}
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
@@ -169,54 +149,6 @@ fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
mod tests {
use super::*;
#[test]
fn removing_is_retried_while_a_file_is_still_in_use() {
use std::io::{Error, ErrorKind};
// A sharing violation as Windows reports it (os error 32), a vanished folder, other errors.
let in_use = || Error::from_raw_os_error(32);
for (fails, outcome) in [(0, true), (3, true), (REMOVE_ATTEMPTS - 1, true), (REMOVE_ATTEMPTS, false)] {
let mut calls = 0;
let r = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
calls += 1;
if calls <= fails { Err(in_use()) } else { Ok(()) }
});
assert_eq!(r.is_ok(), outcome, "{fails} failures");
assert_eq!(calls, (fails + 1).min(REMOVE_ATTEMPTS));
}
let mut calls = 0;
let gone = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
calls += 1;
if calls == 1 { Err(in_use()) } else { Err(Error::from(ErrorKind::NotFound)) }
});
assert!(gone.is_ok(), "deleted meanwhile: done");
assert_eq!(calls, 2);
}
#[cfg(windows)]
#[test]
fn removing_waits_for_a_windows_file_lock_to_be_released() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let worktrees = tmp.path().join("wt");
let created = create(&repo, &worktrees, "Locked file", None).unwrap();
let file = std::fs::OpenOptions::new().read(true).share_mode(0).open(created.path.join("a.txt")).unwrap();
assert_eq!(std::fs::remove_file(created.path.join("a.txt")).unwrap_err().raw_os_error(), Some(32));
let release = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(800));
drop(file);
});
let result = remove(&repo, &worktrees, &created.path);
release.join().unwrap();
result.unwrap();
assert!(!created.path.exists());
}
#[test]
fn slugs_are_branch_safe() {
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
+1 -25
View File
@@ -315,10 +315,7 @@ pub fn sync_maven(
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
let _ = std::fs::remove_file(&effective);
// `-T1` keeps mvnd serial: its default parallel build interleaves the
// `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<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
args.extend(options.maven_options.iter().cloned());
if options.offline {
args.push("-o".into());
@@ -683,25 +680,4 @@ mod tests {
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
}
#[cfg(unix)]
#[test]
fn maven_sync_runs_the_daemon_command_serially() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let launcher = tmp.path().join("fake-mvnd");
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$@\" > mvnd-args\n").unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options =
SyncOptions { maven_command: Some(launcher.to_string_lossy().into_owned()), ..Default::default() };
sync_maven(&root, &tmp.path().join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
let args = std::fs::read_to_string(root.join("mvnd-args")).unwrap();
// `-T1` pins the daemon serial so the dependency trees stay parseable.
assert!(args.contains("-T1"), "args: {args}");
assert!(args.contains("help:effective-pom"), "args: {args}");
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
-23
View File
@@ -2,18 +2,6 @@
//! treats as nonexistent — hidden from the project view and navigation, never
//! indexed or searched, never offered as unversioned changes.
use std::path::Path;
/// Whether `dir` lies in a Git (or jj) working tree: it or an ancestor holds `.git` / `.jj`.
///
/// Walkers pass it to `ignore::WalkBuilder::require_git`: inside a repository, `.gitignore`
/// rules then stop at a nested repository's root as in git, so an outer repository's
/// `.gitignore` does not hide a project that is its own repository. A folder outside any
/// repository still honours its `.gitignore`.
pub fn inside_git_repo(dir: &Path) -> bool {
dir.ancestors().any(|d| d.join(".git").exists() || d.join(".jj").exists())
}
/// IDEA's default list, plus `.jj` and the Windows shell files.
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
"*.pyc",
@@ -115,15 +103,4 @@ mod tests {
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
assert!(!m.is_ignored_path("src/main.rs"));
}
#[test]
fn finds_the_repository_at_or_above_a_directory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a/b");
std::fs::create_dir_all(&sub).unwrap();
assert!(!inside_git_repo(&sub));
std::fs::create_dir(dir.path().join("a/.git")).unwrap();
assert!(inside_git_repo(&sub));
assert!(inside_git_repo(&dir.path().join("a")));
}
}
+15 -101
View File
@@ -43,21 +43,19 @@ pub fn own_group(cmd: &mut Command) -> &mut Command {
/// Kills the child and everything it started, then reaps it.
///
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id, then a
/// wait for what the child started to be gone.
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
pub fn kill_tree(child: &mut Child) {
#[cfg(unix)]
signal_group(child, libc::SIGKILL);
#[cfg(windows)]
taskkill_tree_of(child.id());
taskkill_tree(child);
let _ = child.kill();
let _ = child.wait();
}
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
/// process group (spawn it with [`own_group`]) or `taskkill /T /F` and a wait for what the
/// child started to be gone. Call it before the child is reaped, so the id still names it.
/// The child itself is left to its owner to kill and reap.
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
pub fn kill_tree_of(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = libc::pid_t::try_from(pid) {
@@ -67,7 +65,15 @@ pub fn kill_tree_of(pid: u32) {
}
}
#[cfg(windows)]
taskkill_tree_of(pid);
{
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
#[cfg(not(any(unix, windows)))]
let _ = pid;
}
@@ -100,107 +106,15 @@ fn signal_group(child: &Child, signal: libc::c_int) {
}
}
/// How long killing a tree waits for the processes the child started to be gone.
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
fn taskkill_tree(child: &Child) {
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
windows_tree::wait_all(&started, TREE_EXIT_WAIT);
}
#[cfg(windows)]
mod windows_tree {
use std::collections::HashSet;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{FILETIME, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
let mut parents = vec![(root, root_started)];
let mut found = Vec::new();
while let Some((parent, parent_started)) = parents.pop() {
for &(pid, ppid) in &processes {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
}
}
}
found
}
pub(super) fn wait_all(processes: &[OwnedHandle], within: Duration) {
let until = Instant::now() + within;
for process in processes {
let left = until.saturating_duration_since(Instant::now());
// SAFETY: a live process handle opened with SYNCHRONIZE access.
unsafe { WaitForSingleObject(process.as_raw_handle(), left.as_millis().min(u32::MAX as u128 - 1) as u32) };
}
}
/// (process id, parent process id) of every running process.
fn processes() -> Vec<(u32, u32)> {
let mut out = Vec::new();
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return out;
}
let snapshot = OwnedHandle::from_raw_handle(snapshot);
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
while more != 0 {
out.push((entry.th32ProcessID, entry.th32ParentProcessID));
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
}
}
out
}
fn open(pid: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
/// When the process started, in 100 ns ticks since 1601.
fn started(process: &OwnedHandle) -> Option<u64> {
let (mut created, mut exited, mut kernel, mut user) =
(FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default());
// SAFETY: a live process handle opened with query access; four out-parameters.
let ok = unsafe { GetProcessTimes(process.as_raw_handle(), &mut created, &mut exited, &mut kernel, &mut user) };
(ok != 0).then(|| (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
-26
View File
@@ -713,15 +713,6 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -870,12 +861,6 @@ pub struct MavenSettings {
pub user_settings: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
/// its own Maven, so `home` and the project's `mvnw` are bypassed when it is on.
pub daemon: bool,
/// 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,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1107,17 +1092,6 @@ mod maven_linkage_tests {
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn daemon_settings_round_trip() {
// Old settings files predate the daemon fields; `serde(default)` keeps them loadable.
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(!old.daemon && old.mvnd_home.is_empty());
let settings = MavenSettings { daemon: true, mvnd_home: "/opt/mvnd".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
+6 -18
View File
@@ -589,12 +589,11 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
@@ -652,19 +651,8 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
-32
View File
@@ -698,37 +698,6 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
@@ -954,7 +923,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
+20 -220
View File
@@ -21,18 +21,6 @@ pub struct FontFamily {
pub monospaced: bool,
}
/// PANOSE bProportion value 9 means "Monospaced"; DirectWrite's
/// `IsMonospacedFont` alone misses fonts that only carry this classification.
const PANOSE_MONOSPACED: u8 = 9;
/// One face counts as monospaced when the font system flags it fixed pitch, its
/// PANOSE proportion is "Monospaced", or its glyph advances are uniform
/// (fonts with wrong/missing metadata, e.g. bitmap-style TTFs).
#[cfg_attr(not(windows), allow(dead_code))]
fn face_is_mono(is_monospaced_font: bool, panose_proportion: u8, advances_equal: bool) -> bool {
is_monospaced_font || panose_proportion == PANOSE_MONOSPACED || advances_equal
}
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
/// The installed families, sorted case-insensitively, read on first use and
@@ -207,9 +195,7 @@ mod imp {
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else {
return Vec::new();
};
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
let mut seen = std::collections::BTreeSet::new();
descriptors
.cast_unchecked::<CTFontDescriptor>()
@@ -285,16 +271,14 @@ mod imp {
#[cfg(windows)]
mod imp {
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Graphics::DirectWrite::IDWriteFontFace1;
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory, IDWriteFactory3, IDWriteFactory5, IDWriteFont,
IDWriteFont1, IDWriteFontCollection, IDWriteFontFamily, IDWriteLocalizedStrings,
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
};
use windows::core::{BOOL, Interface, PCWSTR, w};
use windows::core::{BOOL, Interface, w};
/// The installed families, named in English when the family has an
/// English name (what CSS matches), else its first name.
/// The system font collection's families, named in English when the
/// family has an English name (what CSS matches), else its first name.
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read(refresh) }.unwrap_or_default()
@@ -302,85 +286,11 @@ mod imp {
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
let mut faces = Vec::new();
// Windows 10+: the newer collection covers registered per-user fonts.
// Exclude downloadable fonts that are not installed on this machine.
// Bitmap .FON fonts (e.g. the stock raster Fixedsys) are invisible to
// DirectWrite either way: it only reads TrueType/OpenType files.
let mut collection = None;
if let Ok(factory3) = factory.cast::<IDWriteFactory3>() {
let mut local = None;
if unsafe { factory3.GetSystemFontCollection(false, &mut local, refresh) }.is_ok() {
collection = local.map(Into::into);
}
}
if collection.is_none() {
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
}
if let Some(collection) = collection {
faces.extend(collect(&collection));
}
// DirectWrite enumerates via the font registry, so font files copied
// into the per-user directory without registering (no HKCU entry)
// are invisible to the calls above; scan the directory directly.
faces.extend(unsafe { user_dir_faces(&factory) });
Ok(faces)
}
/// Families from `%LOCALAPPDATA%\Microsoft\Windows\Fonts` files, registered
/// or not, via a font set built from the files on disk.
unsafe fn user_dir_faces(factory: &IDWriteFactory) -> Vec<(String, bool)> {
let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else {
return Vec::new();
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
let dir = std::path::Path::new(&local_app_data).join(r"Microsoft\Windows\Fonts");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let files: Vec<_> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
Some("ttf" | "otf" | "ttc" | "otc")
)
})
.collect();
unsafe { file_faces(factory, &files) }
}
unsafe fn file_faces(factory: &IDWriteFactory, files: &[std::path::PathBuf]) -> Vec<(String, bool)> {
if files.is_empty() {
return Vec::new();
}
let Ok(factory3) = factory.cast::<IDWriteFactory3>() else {
return Vec::new();
};
let Ok(factory5) = factory.cast::<IDWriteFactory5>() else {
return Vec::new();
};
let Ok(builder) = (unsafe { factory5.CreateFontSetBuilder() }) else {
return Vec::new();
};
for path in files {
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// SAFETY: wide is a valid NUL-terminated string for the calls.
let Ok(file) = (unsafe { factory.CreateFontFileReference(PCWSTR(wide.as_ptr()), None) }) else {
continue;
};
let _ = unsafe { builder.AddFontFile(&file) };
}
let Ok(set) = (unsafe { builder.CreateFontSet() }) else {
return Vec::new();
};
let Ok(collection) = (unsafe { factory3.CreateFontCollectionFromFontSet(&set) }) else {
return Vec::new();
};
collect(&collection)
}
fn collect(collection: &IDWriteFontCollection) -> Vec<(String, bool)> {
let mut out = Vec::new();
for i in 0..unsafe { collection.GetFontFamilyCount() } {
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
@@ -389,120 +299,19 @@ mod imp {
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
continue;
};
out.push((name, unsafe { family_is_mono(&family) }));
}
out
}
/// A family is monospaced when any face reports fixed pitch, PANOSE
/// proportion 9, or uniform glyph advances; probing every face matches
/// `normalize`'s any-face merge (macOS and Linux already flag every face,
/// not just the regular one).
unsafe fn family_is_mono(family: &IDWriteFontFamily) -> bool {
(0..unsafe { family.GetFontCount() }).any(|i| {
let Ok(font) = (unsafe { family.GetFont(i) }) else {
return false;
};
if let Ok(font1) = font.cast::<IDWriteFont1>() {
let is_monospaced = unsafe { font1.IsMonospacedFont() }.as_bool();
// The proportion byte is defined for text/display fonts only.
let panose = unsafe { font1.GetPanose().values };
let panose_proportion = if panose[0] == 2 { panose[3] } else { 0 };
if super::face_is_mono(is_monospaced, panose_proportion, false) {
return true;
}
}
unsafe { advances_equal(&font) }
})
}
/// Some monospaced fonts carry no fixed-pitch metadata at all (bitmap-style
/// TTFs like Fixedsys Excelsior): compare the advance widths of 'i' and
/// 'W'. Dual-width CJK monospace fonts pass too (both half-width).
unsafe fn advances_equal(font: &IDWriteFont) -> bool {
let Ok(face) = (unsafe { font.CreateFontFace() }) else {
return false;
};
let Ok(face1) = face.cast::<IDWriteFontFace1>() else {
return false;
};
let codepoints = ['i' as u32, 'W' as u32];
let mut indices = [0u16; 2];
if unsafe { face.GetGlyphIndices(codepoints.as_ptr(), 2, indices.as_mut_ptr()) }.is_err()
|| indices.contains(&0)
{
return false;
}
let mut advances = [0i32; 2];
unsafe { face1.GetDesignGlyphAdvances(2, indices.as_ptr(), advances.as_mut_ptr(), false) }.is_ok()
&& advances[0] > 0
&& advances[0] == advances[1]
}
#[test]
fn glyph_probe_distinguishes_monospace_proportional_and_symbol_fonts() {
unsafe {
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
let mut collection = None;
factory.GetSystemFontCollection(&mut collection, false).unwrap();
let collection = collection.unwrap();
for (name, expected) in [
("Consolas", true),
("Arial", false),
("Times New Roman", false),
("Wingdings", false),
("Webdings", false),
("Segoe MDL2 Assets", false),
] {
let wide: Vec<_> = name.encode_utf16().chain(Some(0)).collect();
let mut index = 0;
let mut exists = BOOL(0);
collection.FindFamilyName(PCWSTR(wide.as_ptr()), &mut index, &mut exists).unwrap();
assert!(exists.as_bool(), "{name} ships with Windows");
let family = collection.GetFontFamily(index).unwrap();
for i in 0..family.GetFontCount() {
let font = family.GetFont(i).unwrap();
assert_eq!(advances_equal(&font), expected, "{name} face {i}");
}
}
}
}
#[test]
fn font_files_include_every_family_in_a_collection() {
let path = std::path::PathBuf::from(std::env::var_os("WINDIR").unwrap()).join(r"Fonts\cambria.ttc");
unsafe {
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
let faces = file_faces(&factory, &[path]);
for name in ["Cambria", "Cambria Math"] {
assert!(faces.iter().any(|(family, _)| family == name), "missing {name}: {faces:?}");
}
}
}
#[test]
fn excludes_families_available_only_as_downloads() {
use windows::Win32::Graphics::DirectWrite::{DWRITE_E_REMOTEFONT, DWRITE_LOCALITY_LOCAL, IDWriteFont3};
unsafe {
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
let factory3: IDWriteFactory3 = factory.cast().unwrap();
let set = factory3.GetSystemFontSet().unwrap();
let collection = factory3.CreateFontCollectionFromFontSet(&set).unwrap();
let listed = super::normalize(read(false).unwrap());
let user = super::normalize(user_dir_faces(&factory));
for i in 0..collection.GetFontFamilyCount() {
let family = collection.GetFontFamily(i).unwrap();
let name = english(&family.GetFamilyNames().unwrap()).unwrap();
let remote_only = (0..family.GetFontCount()).all(|j| match family.GetFont(j) {
Ok(font) => font.cast::<IDWriteFont3>().unwrap().GetLocality() != DWRITE_LOCALITY_LOCAL,
Err(error) => error.code() == DWRITE_E_REMOTEFONT,
});
if remote_only && !user.iter().any(|f| f.name == name) {
assert!(!listed.iter().any(|f| f.name == name), "{name} is not installed locally");
}
let mono = unsafe {
family.GetFirstMatchingFont(
DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
DWRITE_FONT_STYLE_NORMAL,
)
}
.ok()
.and_then(|f| f.cast::<IDWriteFont1>().ok())
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
out.push((name, mono));
}
Ok(out)
}
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
@@ -667,15 +476,6 @@ mod tests {
);
}
#[test]
fn panose_monospaced_proportion_counts_as_mono() {
assert!(face_is_mono(true, 0, false));
assert!(face_is_mono(false, PANOSE_MONOSPACED, false));
assert!(face_is_mono(false, 0, true));
assert!(!face_is_mono(false, 0, false));
assert!(!face_is_mono(false, 3, false));
}
#[test]
fn fc_list_spacing_marks_monospace() {
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
@@ -33,9 +33,7 @@ usage: openidea-plugin <command> [options]
commands:
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
create a plugin; T = command (default), theme, icon-theme, language. A Rust
plugin depends on the SDK of this tool's release (git); --sdk PATH uses a
local sdk/rust/openidea-plugin instead
create a plugin; T = command (default), theme, icon-theme, language
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it
link [dir] load the folder in the IDE without installing it
@@ -179,7 +177,7 @@ fn cmd_new(args: &[String]) -> Result<(), String> {
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let dir = canonical(&dir)?;
let sdk = match template {
templates::Template::Command => Some(sdk_dependency(a.value("sdk"), &dir)?),
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
_ => None,
};
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
@@ -234,16 +232,12 @@ fn title_case(s: &str) -> String {
.join(" ")
}
/// The SDK dependency written into a new plugin's Cargo.toml: by path with `--sdk`, or
/// when the plugin is inside the source tree this tool was built from; otherwise the
/// repository at this tool's release tag.
fn sdk_dependency(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
/// or the SDK of the source tree this tool was built from.
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
let sdk = match explicit {
Some(p) => PathBuf::from(p),
None => match dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) {
Ok(tree) if plugin_dir.starts_with(&tree) => tree.join("sdk/rust/openidea-plugin"),
_ => return Ok(format!("{{ git = \"{REPOSITORY}\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))),
},
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
};
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
@@ -252,11 +246,9 @@ fn sdk_dependency(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, S
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
}
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
Ok(format!("{{ path = \"{}\" }}", path.to_string_lossy().replace('\\', "/")))
Ok(path.to_string_lossy().replace('\\', "/"))
}
const REPOSITORY: &str = "https://github.com/l0ng-ai/openidea";
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
@@ -789,27 +781,6 @@ mod tests {
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
}
#[test]
fn new_plugins_depend_on_the_sdk_by_path_only_in_its_source_tree() {
let tree = dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")).unwrap();
assert_eq!(
sdk_dependency(None, &tree.join("plugins/official/new")).unwrap(),
"{ path = \"../../../sdk/rust/openidea-plugin\" }"
);
let outside = dunce::canonicalize(std::env::temp_dir()).unwrap().join("my-plugin");
assert_eq!(
sdk_dependency(None, &outside).unwrap(),
format!("{{ git = \"https://github.com/l0ng-ai/openidea\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))
);
let sdk = tree.join("sdk/rust/openidea-plugin");
assert!(sdk_dependency(Some(sdk.to_str().unwrap()), &outside).unwrap().starts_with("{ path = "));
assert!(
sdk_dependency(Some(tree.join("wit").to_str().unwrap()), &outside)
.unwrap_err()
.contains("not the openidea-plugin SDK")
);
}
/// 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);
@@ -930,48 +901,13 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
#[test]
fn the_command_template_builds_against_the_sdk() {
let dir = std::env::temp_dir().join(format!("openidea-plugin-template-build-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dunce::canonicalize(&dir).unwrap();
let plugin = dir.join("plugin");
let sdk = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin");
let sdk = sdk_dependency(Some(sdk), &plugin).unwrap();
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some(&sdk) };
for (rel, content) in templates::render(templates::Template::Command, &vars) {
let path = plugin.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
// The SDK's lockfile keeps the versions its own CI builds with (the resolver also
// looks up the wasm-only dependencies, which may not be downloaded yet).
let sdk_lock = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin/Cargo.lock");
std::fs::copy(sdk_lock, plugin.join("Cargo.lock")).unwrap();
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["test", "--manifest-path"])
.arg(plugin.join("Cargo.toml"))
.env("CARGO_TARGET_DIR", dir.join("target"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_template_is_a_valid_package() {
for t in templates::Template::ALL {
let dir =
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("{ path = \"../sdk\" }") };
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
for (rel, content) in templates::render(t, &vars) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -32,7 +32,6 @@ impl Template {
pub struct Vars<'a> {
pub id: &'a str,
pub name: &'a str,
/// The SDK dependency, as a TOML value (`{ path = "…" }`).
pub sdk: Option<&'a str>,
}
@@ -119,7 +118,7 @@ publish = false
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = __SDK__
openidea-plugin = { path = "__SDK__" }
[profile.release]
opt-level = "s"
@@ -131,7 +130,7 @@ panic = "abort"
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Main {
@@ -139,7 +138,7 @@ pub struct Main {
}
impl Plugin for Main {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
match id {
"say-hello" => {
// Instance state lasts until the plugin is idle for a while;
@@ -154,7 +153,7 @@ impl Plugin for Main {
let text = editor.document()?.text;
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
}
_ => Err(format!("unknown command {id}").into()),
_ => Err(format!("unknown command {id}")),
}
}
}
+5 -14
View File
@@ -79,7 +79,6 @@ pub struct Host {
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
@@ -123,7 +122,6 @@ impl Host {
owner,
runtime,
projects: RwLock::default(),
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
@@ -180,10 +178,6 @@ impl Host {
}
pub fn close_project(&self, window: &str) {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
@@ -199,6 +193,9 @@ impl Host {
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
@@ -314,20 +311,14 @@ impl Host {
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_project_read_request(window, id);
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
/// Read-only work remains cancellable/disposed with its window in restricted projects.
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
+3 -10
View File
@@ -137,23 +137,16 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
}
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_workspace::file_ops::delete(&path)
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+12 -93
View File
@@ -76,17 +76,9 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// `data` of a plugin's code action whose edits it computes when picked.
#[derive(serde::Serialize, serde::Deserialize)]
struct PluginActionRef {
plugin: String,
data: String,
}
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
@@ -128,14 +120,14 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: item.filter_text,
filter_text: None,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: item.snippet,
is_snippet: false,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: item.additional_edits,
additional_edits: Vec::new(),
commit_characters: Vec::new(),
command: None,
data: None,
@@ -260,9 +252,6 @@ pub async fn lsp_implementations(
/// 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<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -325,7 +314,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(plugin, a)| plugin_code_action(&plugin, a))
.map(|(_, a)| plugin_code_action(a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
@@ -341,53 +330,24 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and no edits, the plugin computes them when the action is picked.
let lazy = a.edits.is_empty() && a.data.is_some();
let files: Vec<_> = a
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
let data = a
.data
.filter(|_| lazy)
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data,
data: None,
server: PLUGIN_SERVER,
}
}
/// A code action resolved by whoever offered it: its language server, or
/// the plugin its `data` names.
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
if action.server != PLUGIN_SERVER {
return host.project(window)?.lsp.resolve_code_action(doc, action).await;
}
let Some(r) = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok()) else {
return Ok(action);
};
let (plugins, d) = plugin_providers(host, window, doc, ProviderKind::CodeActions)
.ok_or_else(|| ide_core::IdeError::unavailable("the plugin that offered this action is gone"))?;
let pending = ide_plugin::CodeAction {
title: action.title.clone(),
kind: action.kind.clone(),
preferred: action.is_preferred,
edits: Vec::new(),
data: Some(r.data),
};
let plugin = r.plugin.clone();
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
@@ -421,7 +381,7 @@ pub async fn lsp_resolve_code_action(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = resolve_action(host, window, doc, action).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
}
@@ -431,7 +391,7 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let action = if action.edit.is_none() && action.command.is_none() {
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = resolve_action(host, window, doc, action).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
} else {
action
@@ -441,30 +401,12 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
async fn is_client_follow_up(
project: &crate::project::Project,
doc: DocId,
server: Option<ide_core::ServerId>,
command: &Command,
) -> bool {
let provided = project.lsp.provides_command(doc, server, &command.command).await;
if !provided {
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
}
!provided
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -697,7 +639,6 @@ pub async fn lsp_execute_code_action(
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -705,9 +646,6 @@ pub async fn lsp_execute_code_action(
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
Ok(Vec::new())
}
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
@@ -863,25 +801,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits,
data: data.map(str::to_owned),
};
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
// With edits, `data` is the plugin's business: applied as is.
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
assert!(eager.edit.is_some() && eager.data.is_none());
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+1 -12
View File
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,19 +32,10 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
pub fn find_symbols(
host: &Host,
window: &str,
@@ -165,9 +156,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
});
-3
View File
@@ -17,8 +17,6 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
pub fn info(project: &Project) -> ProjectInfo {
@@ -35,7 +33,6 @@ pub fn info(project: &Project) -> ProjectInfo {
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
-3
View File
@@ -155,9 +155,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let (root, settings) = settings_for(host, &project, &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);
-18
View File
@@ -142,21 +142,6 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -323,9 +308,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
-40
View File
@@ -34,42 +34,6 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.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<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -180,10 +144,6 @@ pub async fn sdk_python_pip(
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_php_language_level",
|c| async move { sdk_php_language_level(&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
+22 -266
View File
@@ -12,76 +12,6 @@ use std::path::{Path, PathBuf};
use crate::apply_git_executable;
/// Read-only repository access also works in a standalone log window.
struct LogAccess {
project: Option<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
cache: PathBuf,
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
repo: PathBuf,
revision: String,
path: PathBuf,
}
let encoded = uri
.strip_prefix("git-revision:")
.and_then(|s| s.split('/').next())
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
}
let bytes = (0..encoded.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&encoded[i..i + 2], 16)
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
})
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
uri: uri.into(),
language_id: host.languages.detect(&target.path, text.lines().next()),
text,
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
})
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
let external = host
.external_logs
.read()
.get(host.owner_window(window))
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
}
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(repo) = &self.external {
repo.enable_log_index(&self.cache);
return f(repo);
}
self.project
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -252,8 +182,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -267,21 +197,9 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let project = host.project(window)?;
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
&path,
rev.as_deref(),
encoding_rs::UTF_8,
)
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
@@ -304,148 +222,9 @@ pub async fn vcs_blame(
.await
}
/// History for Selection: the commits that changed `lines` (0-based, inclusive) of the
/// editor's text of `path`. Cancellable.
pub async fn vcs_block_history(
host: &Host,
window: &str,
request: RequestId,
path: PathBuf,
text: Option<String>,
lines: (u32, u32),
) -> IdeResult<BlockHistory> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
let project = LogAccess::new(host, window, repo.as_deref());
let request = query.request;
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
})
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
pub async fn vcs_log_refs(
host: &Host,
window: &str,
repo: Option<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
}
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
}
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
let path = dunce::canonicalize(path)?;
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
if let Ok(project) = host.project(window) {
project.open_external_log(&root)?;
}
let mut logs = host.external_logs.write();
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
if !roots.iter().any(|r| r.root() == root) {
roots.push(std::sync::Arc::new(repo));
}
Ok(root.into())
}
pub async fn vcs_log_details(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
}
pub async fn vcs_log_details_batch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_log_index(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
})
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
}
pub async fn vcs_revision_files(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
pub async fn vcs_commit_files(
@@ -454,8 +233,8 @@ pub async fn vcs_commit_files(
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -465,13 +244,13 @@ pub async fn vcs_commits_changes(
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -826,8 +605,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
@@ -888,8 +667,8 @@ pub async fn vcs_create_tag(
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
@@ -925,8 +704,8 @@ pub async fn vcs_rebase_with(
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1013,8 +792,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1025,8 +804,8 @@ pub async fn vcs_diff_revisions(
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
@@ -1208,30 +987,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_blame", |c| async move {
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
});
d.add("vcs_block_history", |c| async move {
let (request, path, text, lines) = (c.arg("request")?, c.arg("path")?, c.arg("text")?, c.arg("lines")?);
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log_details", |c| async move {
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
d.add(
"vcs_log_index",
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
);
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
d.add("vcs_revision_files", |c| async move {
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
-11
View File
@@ -667,17 +667,6 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
-15
View File
@@ -292,21 +292,6 @@ impl PluginHost {
.collect()
}
/// The edits of an action `plugin_id` returned with `data`, now that the
/// user picked it: a call with the command budget, not the edit path's.
pub fn resolve_code_action(
&self,
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
})
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
@@ -114,7 +114,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
-2
View File
@@ -192,8 +192,6 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
-10
View File
@@ -378,19 +378,9 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
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)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+10 -158
View File
@@ -34,8 +34,6 @@ pub struct Project {
pub lsp: Arc<LspManager>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Explicitly opened external logs remain read-only and outside project indexing.
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -58,9 +56,6 @@ pub struct Project {
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
/// Watchers of folders outside the project that language servers registered file
/// watchers for (a virtualenv elsewhere, a path dependency).
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
/// nothing indexed, no language servers, repositories or project services.
@@ -223,8 +218,6 @@ impl Host {
ide_sdk::jdtls::launch_directly(cfg);
}
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
// After the overrides: the PHP version they may set decides the storage.
ide_sdk::php::configure_intelephense_storage(cfg, &data_dir);
if cfg.name == "jdtls" {
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
@@ -309,16 +302,12 @@ impl Host {
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
index.update(&changed, &batch.removed);
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
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)) {
lsp.reconfigure_soon();
}
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&[&changed[..], &ignored].concat());
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
@@ -422,16 +411,7 @@ impl Host {
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(
r,
&excludes,
&sink,
&index,
&documents,
&history,
&lsp,
Some(&kotlin_bridge),
)?;
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
Some((r.clone(), w))
})
.collect();
@@ -464,7 +444,6 @@ impl Host {
index,
lsp,
repos,
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -479,7 +458,6 @@ impl Host {
kotlin_bridge: Some(kotlin_bridge),
watcher,
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
light: false,
});
@@ -489,14 +467,6 @@ impl Host {
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_file_watch_roots_hook(Box::new(move |roots| {
if let Some(p) = weak.upgrade() {
p.watch_for_language_servers(roots);
}
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_project_excludes_hook(Box::new(move || {
@@ -598,7 +568,6 @@ impl Host {
index,
lsp,
repos: Arc::default(),
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
@@ -613,7 +582,6 @@ impl Host {
kotlin_bridge: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
light: true,
root: dir,
@@ -696,14 +664,7 @@ fn scan_repositories(
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
let name = n.to_string_lossy();
ignored.is_ignored_name(&name)
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
&& p.parent().is_some_and(ide_workspace::unity::is_project))
})
};
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
@@ -750,51 +711,6 @@ impl Project {
}
}
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
let root = dunce::canonicalize(path)?;
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
let mut external = self.external_log_repos.write();
if !external.iter().any(|r| r.root() == root) {
external.push(Arc::new(repo));
}
Ok(root)
}
/// Log read operations may also use an explicitly opened external worktree.
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(path) = at
&& self.repos().at_root(path).is_none()
{
let repo = self
.external_log_repos
.read()
.iter()
.find(|r| r.root() == path)
.cloned()
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
return f(&repo);
}
self.with_repo_at(at, f)
}
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if self.repos().for_path(path).is_some() {
return self.with_repo_of(path, f);
}
let repo = self
.external_log_repos
.read()
.iter()
.filter(|r| path.starts_with(r.root()))
.max_by_key(|r| r.root().components().count())
.cloned()
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
f(&repo)
}
/// Runs `f` with the repository `path` (a file or folder) belongs to.
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
match self.repos().for_path(path) {
@@ -900,8 +816,7 @@ impl Project {
/// Project files of `q.languages` under `q.root` containing `q.word` as a whole
/// word (unsaved buffers included; excluded roots, ignored names and
/// `.gitignore`d folders skipped, while `.gitignore`d files elsewhere count, as
/// in IDEA's project scope): the files a references search opens on its server.
/// `.gitignore`d files skipped): the files a references search opens on its server.
pub fn reference_candidates(
&self,
q: &ide_lsp::CandidateQuery,
@@ -979,7 +894,6 @@ impl Project {
&self.index,
&self.documents,
&self.history,
&self.lsp,
self.kotlin_bridge.as_ref(),
) {
watchers.push((r, w));
@@ -1011,7 +925,7 @@ impl Project {
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
@@ -1034,11 +948,6 @@ const HISTORY_BATCH_LIMIT: usize = 500;
/// Local History for changes made outside the IDE (other tools, VCS, deletions).
/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped.
/// The batch's ignored files (`.env`), split into those still on disk and those removed.
fn ignored_changes(batch: &ide_vfs::FsBatch) -> (Vec<PathBuf>, Vec<PathBuf>) {
batch.ignored.iter().cloned().partition(|p| p.symlink_metadata().is_ok())
}
fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) {
for path in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = history.record_file(path, "External change", false) {
@@ -1082,31 +991,6 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// Watches a content root outside the project: the index, open documents and
/// Local History follow its changes like the project folder's.
impl Project {
/// Watches `roots` (outside the project) for the language servers that asked to.
fn watch_for_language_servers(&self, roots: Vec<PathBuf>) {
let mut watchers = self.lsp_watchers.lock();
watchers.retain(|(r, _)| roots.contains(r));
for root in roots {
if watchers.iter().any(|(r, _)| *r == root) || !root.is_dir() {
continue;
}
let lsp = Arc::downgrade(&self.lsp);
let on_change = Box::new(move |batch: &ide_vfs::FsBatch| {
if let Some(lsp) = lsp.upgrade() {
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
}
});
// Not project files: nothing else hears of them.
match Watcher::start(&root, Vec::new(), Arc::new(ide_core::NullSink), on_change) {
Ok(w) => watchers.push((root, w)),
Err(e) => tracing::warn!("language server watcher {}: {e}", root.display()),
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn content_root_watcher(
dir: &Path,
excludes: &[String],
@@ -1114,28 +998,21 @@ fn content_root_watcher(
index: &Arc<ProjectIndex>,
documents: &Arc<Documents>,
history: &Arc<LocalHistory>,
lsp: &Arc<LspManager>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
) -> Option<Watcher> {
let kotlin_bridge = kotlin_bridge.cloned();
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
let root = dir.to_path_buf();
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
Watcher::start(
dir,
excludes.to_vec(),
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
// A workspace member's PHP language level follows its own composer.json.
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
lsp.reconfigure_soon();
}
index.update(&changed, &batch.removed);
if let Some(bridge) = &kotlin_bridge {
bridge.files_changed(changed.iter().chain(&batch.removed));
}
documents.on_disk_changed(&[&changed[..], &ignored].concat());
documents.on_disk_changed(&changed);
record_external_changes(&history, &changed, &batch.removed);
}),
)
@@ -1143,13 +1020,6 @@ fn content_root_watcher(
.ok()
}
/// Whether the `composer.json` of `root` is among `paths`: the PHP language level follows it
/// (`ide_sdk::php`).
fn composer_changed<'a>(root: &Path, mut paths: impl Iterator<Item = &'a PathBuf>) -> bool {
let composer = root.join("composer.json");
paths.any(|p| *p == composer)
}
/// Where agent tasks create their worktrees: next to the project, `<parent>/<name>.worktrees`,
/// so they sit on the project's drive and outside its tree (not indexed, watched or shown in
/// its git status); `fallback` for a project at a filesystem root.
@@ -1566,21 +1436,3 @@ mod agent_worktree_tests {
assert_eq!(agent_worktrees_dir(Path::new("/"), fallback), fallback());
}
}
#[cfg(test)]
mod composer_tests {
use super::composer_changed;
use std::path::{Path, PathBuf};
#[test]
fn only_the_roots_own_composer_json_counts() {
let root = Path::new("/work/app");
let changed = |paths: &[&str]| {
let paths: Vec<PathBuf> = paths.iter().map(|p| root.join(p)).collect();
composer_changed(root, paths.iter())
};
assert!(changed(&["src/a.php", "composer.json"]));
assert!(!changed(&["composer.lock", "vendor/acme/lib/composer.json", "packages/b/composer.json"]));
assert!(!changed(&[]));
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ impl Dispatcher {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
+1 -40
View File
@@ -222,12 +222,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_current"
| "project_roots"
| "index_status"
| "index_file_extensions"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_dirs"
| "find_symbols"
| "mark_recent"
| "remove_recent"
@@ -276,15 +274,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "plugin_set_theme_frame"
| "plugin_catalog"
| "plugin_assets"
| "plugin_market"
| "plugin_market_url"
| "plugin_set_market_url"
| "plugin_market_download"
| "plugin_unpack_package"
| "plugin_stage_package"
| "plugin_preview_install"
| "plugin_read_intellij_theme"
| "plugin_installed_intellij_themes"
| "notebook_open"
| "notebook_get"
| "notebook_edit"
@@ -306,16 +295,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_line_hunks"
| "vcs_file_at"
| "vcs_blame"
| "vcs_block_history"
| "vcs_log"
| "vcs_log_refs"
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
| "vcs_commit_files"
| "vcs_commits_changes"
| "vcs_branches"
@@ -339,31 +319,12 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
) || cmd.starts_with("plugin:")
}
/// Commands that change which plugin code runs: allowed where no project is open (the
/// welcome screen), otherwise only in a trusted project.
pub fn projectless_command_allowed(cmd: &str) -> bool {
matches!(
cmd,
"plugin_install_dir"
| "plugin_uninstall"
| "plugin_set_enabled"
| "plugin_grant"
| "plugin_set_capability"
| "plugin_roll_back"
| "plugin_reload"
)
}
impl crate::Host {
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
if restricted_command_allowed(command) {
return Ok(());
}
match self.project(window) {
Ok(project) => project.require_trust(),
Err(_) if projectless_command_allowed(command) => Ok(()),
Err(error) => Err(error),
}
self.project(window)?.require_trust()
}
}
-115
View File
@@ -1,115 +0,0 @@
//! Explicit external logs allow history reads without adding a writable project root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(root)
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_history_is_readable_and_never_becomes_a_write_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let external = base.join("external");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&external).unwrap();
git(&external, &["init", "-b", "main"]);
let deleted = external.join("deleted.txt");
std::fs::write(&deleted, "historical content\n").unwrap();
git(&external, &["add", "."]);
git(&external, &["commit", "-m", "initial"]);
let first = git(&external, &["rev-parse", "HEAD"]);
git(&external, &["rm", "deleted.txt"]);
git(&external, &["commit", "-m", "delete"]);
let head = git(&external, &["rev-parse", "HEAD"]);
let host = 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,
});
// A standalone editor needs no project, indexer, or writable VCS root.
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(standalone[0].id, head);
let details = vcs::vcs_log_details_batch(
&host,
"standalone",
Some(external.clone()),
vec![head.clone(), first.clone()],
ide_core::RequestId(987),
)
.await
.unwrap();
assert!(details.iter().all(|d| d.signature == "N"));
assert_eq!(
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
.await
.unwrap(),
vec!["deleted.txt"]
);
assert!(host.project("standalone").is_err());
host.trust.decide(&external, false, false).unwrap();
let restricted = host.open_project("restricted", &external).unwrap();
assert!(!restricted.is_trusted());
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
let restricted_log = vcs::vcs_log(
&host,
"restricted",
Some(external.clone()),
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
)
.await
.unwrap();
assert_eq!(restricted_log[0].id, head);
assert_eq!(
vcs::vcs_log_details_batch(
&host,
"restricted",
Some(external.clone()),
vec![head.clone()],
ide_core::RequestId(989)
)
.await
.unwrap()[0]
.signature,
"N"
);
host.close_project("restricted");
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
assert_eq!(
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
Some("historical content\n")
);
assert!(project.repos().at_root(&external).is_none());
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
assert!(!deleted.exists());
host.close_project("main");
}
-994
View File
@@ -1,994 +0,0 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! language server the app starts by default.
//!
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
//! pinned versions and runs one language per job:
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>`.
use ide_core::{ChangeSet, DocId, IdeEvent, Pos, Range, RequestId, TextChange};
use ide_host::{Host, HostConfig, ops};
use ide_lsp::types::{FormatOptions, GotoKind};
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
/// `word` on the first line of `file` that contains `line`.
#[derive(Clone, Copy, Debug)]
struct At {
file: &'static str,
line: &'static str,
word: &'static str,
}
const fn at(file: &'static str, line: &'static str, word: &'static str) -> At {
At { file, line, word }
}
/// A line typed after the first line of `file` containing `after`.
#[derive(Clone, Copy, Debug)]
struct Typed {
file: &'static str,
after: &'static str,
text: &'static str,
}
const fn typed(file: &'static str, after: &'static str, text: &'static str) -> Typed {
Typed { file, after, text }
}
/// One language's checklist. Every check names what it looks for, so a failure says which
/// operation broke.
struct Checks {
fixture: &'static str,
/// Files written into the project copy before it opens.
files: &'static [(&'static str, &'static str)],
/// How long the first answer may take (project import and indexing).
warm_up: Duration,
/// Go to definition from the first spot lands on the second.
definition: (At, At),
/// Find Usages of the symbol lists usages in these files.
references: (At, &'static [&'static str]),
/// Hover shows this text.
hover: (At, &'static str),
/// Completion at the end of the typed line offers this item.
completion: (Typed, &'static str),
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
/// file contain the last text.
quick_fix: QuickFix,
/// With the word selected, this intention is offered with an edit or command.
intention: (At, &'static str),
/// Formatting turns the typed line into the expected one.
format: (Typed, &'static str),
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
struct QuickFix {
typed: Typed,
diagnostic: &'static str,
title: &'static str,
result: &'static str,
}
/// The editor's indentation, sent with a format request.
#[derive(Clone, Copy, Debug)]
enum Indent {
Spaces(u32),
Tabs,
}
type Events = Arc<Mutex<Vec<IdeEvent>>>;
struct Smoke {
language: &'static str,
host: Arc<Host>,
root: PathBuf,
events: Events,
warm_up: Duration,
_tmp: tempfile::TempDir,
}
fn fixture_dir(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(name)
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
continue;
}
let target = to.join(&name);
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), target).unwrap();
}
}
}
fn utf16_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
/// The 0-based line index and text of the first line containing `needle`.
fn find_line<'a>(text: &'a str, needle: &str, what: &str) -> (u32, &'a str) {
text.lines()
.enumerate()
.find(|(_, l)| l.contains(needle))
.map(|(i, l)| (i as u32, l))
.unwrap_or_else(|| panic!("{what}: no line containing {needle:?}"))
}
/// The byte offset of `word` in `line`, not as part of a longer identifier (`parse` in `parser::parse`).
fn find_word(line: &str, word: &str) -> Option<usize> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
line.match_indices(word).map(|(i, _)| i).find(|&i| {
let before = line[..i].chars().next_back();
let after = line[i + word.len()..].chars().next();
!(word.starts_with(ident) && before.is_some_and(ident)) && !(word.ends_with(ident) && after.is_some_and(ident))
})
}
impl Smoke {
async fn start(language: &'static str, checks: &Checks) -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(checks.fixture);
copy_dir(&fixture_dir(checks.fixture), &root);
for (path, text) in checks.files {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let events: Events = Arc::default();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new({
let events = events.clone();
move |_| {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
}
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project(WINDOW, &root).unwrap();
Smoke { language, host, root, events, warm_up: checks.warm_up, _tmp: tmp }
}
fn fail(&self, check: &str, message: impl std::fmt::Display) -> ! {
let servers = ops::lsp::lsp_servers(&self.host, WINDOW).unwrap_or_default();
let servers: Vec<String> = servers.iter().map(|s| format!("{} ({:?})", s.name, s.status)).collect();
panic!("{} — {check}: {message}\nservers: {servers:?}", self.language)
}
/// Retries `f` until it succeeds (servers answer emptily while importing and indexing); on
/// timeout, fails with its last answer.
async fn retry<T, D: std::fmt::Debug, F: Future<Output = Result<T, D>>>(
&self,
check: &str,
timeout: Duration,
mut f: impl FnMut() -> F,
) -> T {
let deadline = Instant::now() + timeout;
loop {
let last = match f().await {
Ok(v) => return v,
Err(last) => last,
};
if Instant::now() > deadline {
self.fail(check, format_args!("timed out after {timeout:?}; last answer: {last:?}"));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn doc(&self, file: &str) -> (DocId, String) {
let info = ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap();
let info = ops::doc::snapshot(&self.host, WINDOW, info.id).await.unwrap();
(info.id, info.text)
}
async fn locate(&self, at: At) -> (DocId, Pos) {
let (doc, text) = self.doc(at.file).await;
let (line, content) = find_line(&text, at.line, at.file);
let col = find_word(content, at.word).unwrap_or_else(|| panic!("{at:?}: word not on the line"));
(doc, Pos::new(line, utf16_len(&content[..col])))
}
async fn word_range(&self, at: At) -> (DocId, Range) {
let (doc, start) = self.locate(at).await;
(doc, Range::new(start, Pos::new(start.line, start.col + utf16_len(at.word))))
}
/// Types a new line; returns the position at its end and the text before it.
async fn type_line(&self, t: Typed) -> (DocId, Pos, String) {
let (doc, text) = self.doc(t.file).await;
let (line, content) = find_line(&text, t.after, t.file);
let line_start: usize = text.lines().take(line as usize).map(|l| l.len() + 1).sum();
let end = utf16_len(&text[..line_start + content.len()]);
self.replace(doc, end, end, &format!("\n{}", t.text)).await;
let col = utf16_len(t.text);
(doc, Pos::new(line + 1, col), text)
}
async fn replace(&self, doc: DocId, from: u32, to: u32, insert: &str) {
let version = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().version;
let changes = ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] };
ops::doc::edit(&self.host, WINDOW, doc, version, changes).unwrap();
}
async fn restore(&self, doc: DocId, text: &str) {
let current = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text;
self.replace(doc, 0, utf16_len(&current), text).await;
}
fn path(&self, file: &str) -> PathBuf {
self.root.join(file)
}
fn files<P: AsRef<Path>>(&self, paths: impl IntoIterator<Item = P>) -> BTreeSet<String> {
paths
.into_iter()
.filter_map(|p| {
let p = p.as_ref().strip_prefix(&self.root).ok()?;
Some(p.to_string_lossy().replace('\\', "/"))
})
.collect()
}
async fn definition(&self, from: At, to: At) {
let (doc, pos) = self.locate(from).await;
let (_, target) = self.locate(to).await;
let want = self.path(to.file);
self.retry("go to definition", self.warm_up, || async {
let found = ops::lsp::lsp_goto(&self.host, WINDOW, doc, pos, GotoKind::Definition).await;
match &found {
Ok(locs) if locs.iter().any(|l| *l.path == *want && l.range.start.line == target.line) => Ok(()),
_ => Err(found),
}
})
.await;
}
async fn references(&self, of: At, files: &[&str]) {
let (doc, pos) = self.locate(of).await;
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
self.retry("find usages", self.warm_up, || async {
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
match &found {
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
}
})
.await;
}
async fn hover(&self, over: At, shows: &str) {
let (doc, pos) = self.locate(over).await;
self.retry("hover", self.warm_up, || async {
let found = ops::lsp::lsp_hover(&self.host, WINDOW, RequestId(2), doc, pos).await;
match &found {
Ok(Some(h)) if h.contents.value.contains(shows) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.contents.value))),
}
})
.await;
}
async fn completion(&self, line: Typed, offers: &str) {
let (doc, pos, before) = self.type_line(line).await;
let trigger = line.text.ends_with('.').then(|| ".".to_string());
self.retry("completion", self.warm_up, || async {
let found = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(3), doc, pos, trigger.clone()).await;
match &found {
Ok(list)
if list.items.iter().any(|i| i.label == offers || i.filter_text.as_deref() == Some(offers)) =>
{
Ok(())
}
_ => Err(found.map(|l| l.items.into_iter().take(30).map(|i| i.label).collect::<Vec<_>>())),
}
})
.await;
self.restore(doc, &before).await;
}
async fn diagnostic(&self, file: &str, message: &str) -> Range {
let path = self.path(file);
self.retry("diagnostics", self.warm_up, || async {
let events = self.events.lock().unwrap();
let latest = events.iter().rev().filter_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, source } if **p == *path => Some((source, items)),
_ => None,
});
let mut seen = Vec::new();
for (source, items) in latest {
if let Some(d) = items.iter().find(|d| d.message.contains(message)) {
return Ok(d.range);
}
seen.push(format!("{source}: {:?}", items.iter().map(|d| &d.message).collect::<Vec<_>>()));
}
Err(seen.into_iter().take(4).collect::<Vec<_>>())
})
.await
}
/// The code actions over `range` (all kinds, as the ⌥⏎ popup asks), the one titled `title`.
async fn action(&self, check: &str, doc: DocId, range: Range, title: &str) -> ide_lsp::types::CodeAction {
let action = self
.retry(check, self.warm_up, || async {
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, range, vec![]).await;
match found.as_ref().ok().and_then(|a| a.iter().find(|a| a.title.contains(title))) {
Some(action) => Ok(action.clone()),
None => Err(found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())),
}
})
.await;
if action.edit.is_some() || action.command.is_some() {
return action;
}
ops::lsp::lsp_resolve_code_action(&self.host, WINDOW, doc, action)
.await
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
}
async fn quick_fix(&self, fix: QuickFix) {
let (doc, _, before) = self.type_line(fix.typed).await;
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
let action = self.action("quick fix", doc, range, fix.title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(fix.typed.file).await.1;
if text.contains(fix.result) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn intention(&self, on: At, title: &str) {
let (doc, range) = self.word_range(on).await;
let action = self.action("intention", doc, range, title).await;
let edits = action.edit.as_ref().is_some_and(|e| !e.files.is_empty());
if !edits && action.command.is_none() {
self.fail("intention", format_args!("{title:?} has neither an edit nor a command: {action:?}"));
}
}
async fn format(&self, line: Typed, indent: Indent, formatted: &str) {
let (doc, _, before) = self.type_line(line).await;
assert!(!before.lines().any(|l| l == formatted), "{line:?}: the file already has {formatted:?}");
let (tab_size, insert_spaces) = match indent {
Indent::Spaces(n) => (n, true),
Indent::Tabs => (4, false),
};
let options = FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
let text = self
.retry("format", self.warm_up, || async {
let edits = ops::lsp::lsp_format(&self.host, WINDOW, doc, None, options.clone()).await;
let text = self.doc(line.file).await.1;
let mut document = ide_text::Document::new(&text);
match &edits {
Ok(edits) if !edits.is_empty() && document.apply_edits(edits).is_ok() => Ok(document.text()),
_ => Err(edits),
}
})
.await;
if !text.lines().any(|l| l == formatted) {
self.fail("format", format_args!("no line {formatted:?} in:\n{text}"));
}
self.restore(doc, &before).await;
}
async fn rename(&self, symbol: At, to: &str, files: &[&str]) {
let (doc, pos) = self.locate(symbol).await;
let prepared = ops::lsp::lsp_prepare_rename(&self.host, WINDOW, doc, pos).await;
if !matches!(prepared, Ok(Some(_))) {
self.fail("rename", format_args!("prepare rename: {prepared:?}"));
}
let renamed = ops::lsp::lsp_rename(&self.host, WINDOW, RequestId(5), doc, pos, to.into())
.await
.unwrap_or_else(|e| self.fail("rename", e));
let edited = self.files(renamed.edit.files.iter().filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(path.clone()),
_ => None,
}));
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
if !want.is_subset(&edited) {
self.fail("rename", format_args!("edits {edited:?}, expected at least {want:?}: {:?}", renamed.edit));
}
let mut before = Vec::new();
for file in files {
before.push(self.doc(file).await.1.matches(to).count());
}
ops::lsp::apply_workspace_edit(&self.host, WINDOW, renamed.edit)
.await
.unwrap_or_else(|e| self.fail("rename", format_args!("applying the edit: {e}")));
for (file, before) in files.iter().zip(before) {
if self.doc(file).await.1.matches(to).count() <= before {
self.fail("rename", format_args!("{file} has no new {to:?} after the rename"));
}
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
self.hover(c.hover.0, c.hover.1).await;
self.completion(c.completion.0, c.completion.1).await;
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
}
async fn smoke(language: &'static str, checks: Checks) {
let smoke = Smoke::start(language, &checks).await;
smoke.run(&checks).await;
}
const TYPESCRIPT: Checks = Checks {
fixture: "ts-app",
files: &[],
warm_up: Duration::from_secs(120),
definition: (
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
at("src/money.ts", "export function formatMoney", "formatMoney"),
),
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
quick_fix: QuickFix {
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
diagnostic: "Cannot find name 'addMoney'",
title: "import from \"./money.ts\"",
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
},
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
format: (
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
" const shown = { total, count: cart.count }",
),
indent: Indent::Spaces(2),
rename: (
// From a usage in another file: the declaration is renamed too, not aliased in the import.
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs vtsls"]
async fn typescript() {
smoke("typescript", TYPESCRIPT).await;
}
const PYTHON: Checks = Checks {
fixture: "python-app",
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
files: &[("ruff.toml", "")],
warm_up: Duration::from_secs(120),
definition: (
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
at("src/tasks/models.py", "class TaskList:", "TaskList"),
),
references: (
at("src/tasks/models.py", "class TaskList:", "TaskList"),
&["src/tasks/cli.py", "src/tasks/models.py"],
),
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
quick_fix: QuickFix {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
diagnostic: "\"Task\" is not defined",
title: "from .models import Task",
result: "from .models import Priority, Task, TaskList",
},
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
format: (
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
" shown2 = {\"a\": 1, \"b\": 2}",
),
indent: Indent::Spaces(4),
rename: (
at("src/tasks/models.py", "class TaskList:", "TaskList"),
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs basedpyright and ruff"]
async fn python() {
smoke("python", PYTHON).await;
}
const RUST: Checks = Checks {
fixture: "rust-app",
files: &[],
warm_up: Duration::from_secs(180),
definition: (
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
),
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
quick_fix: QuickFix {
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
typed: typed(
"src/main.rs",
"let mut inventory = Inventory::new();",
" let _blank = parser::ParseError { line: 1 };",
),
diagnostic: "missing structure fields",
title: "Fill struct fields",
result: "ParseError { line: 1, message",
},
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
format: (
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
" let _spaced = (1, 2);",
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs rust-analyzer"]
async fn rust() {
smoke("rust", RUST).await;
}
const GO: Checks = Checks {
fixture: "go-app",
files: &[],
warm_up: Duration::from_secs(180),
definition: (
at("cmd/server/main.go", "s := store.New()", "New"),
at("internal/store/store.go", "func New() *Store", "New"),
),
references: (
at("internal/store/store.go", "func New() *Store", "New"),
&["cmd/server/main.go", "internal/store/store.go"],
),
hover: (
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
"func (s *store.Store) Search(q string) []store.Note",
),
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
quick_fix: QuickFix {
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
diagnostic: "undefined: fmt",
title: "\"fmt\"",
result: "\t\"fmt\"",
},
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
indent: Indent::Tabs,
rename: (
at("internal/store/store.go", "func New() *Store", "New"),
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs gopls"]
async fn go() {
smoke("go", GO).await;
}
const JAVA: Checks = Checks {
fixture: "java-app",
files: &[],
warm_up: Duration::from_secs(300),
definition: (
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
),
references: (
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
),
hover: (
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
"long com.example.orders.OrderBook.revenueCents()",
),
completion: (
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
"largest",
),
quick_fix: QuickFix {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" List<Order> all = book.byCustomer(\"alice\");",
),
diagnostic: "List cannot be resolved to a type",
title: "Import 'List' (java.util)",
result: "import java.util.List;",
},
intention: (
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
"Extract to local variable",
),
format: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" long doubled=revenue*2;",
),
" long doubled = revenue * 2;",
),
indent: Indent::Spaces(4),
rename: (
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs jdtls and JDK 21"]
async fn java() {
smoke("java", JAVA).await;
}
const KOTLIN: Checks = Checks {
fixture: "kotlin-gradle",
files: &[],
warm_up: Duration::from_secs(420),
definition: (
at("src/main/kotlin/com/example/library/App.kt", "val library = Library()", "Library"),
at("src/main/kotlin/com/example/library/Library.kt", "class Library(", "Library"),
),
references: (
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
hover: (
at("src/main/kotlin/com/example/library/App.kt", "library.search(\"java\")", "search"),
"fun search(query: String): List<Book>",
),
completion: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library."),
"lend",
),
quick_fix: QuickFix {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTime.now()",
),
diagnostic: "Unresolved reference",
title: "Import",
result: "import java.time.LocalDateTime",
},
intention: (
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
"block body",
),
format: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
" val pair = listOf(1, 2)",
),
indent: Indent::Spaces(4),
rename: (
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs kotlin-lsp, a JDK and Gradle"]
async fn kotlin() {
smoke("kotlin", KOTLIN).await;
}
+37 -197
View File
@@ -123,19 +123,6 @@ fn misbehave_pkg() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
/// another (or behind a new instance starting) holds once it caught up.
fn eventually(mut f: impl FnMut() -> bool) -> bool {
let until = Instant::now() + Duration::from_secs(5);
while !f() {
if Instant::now() > until {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
fn write_theme_pair(dir: &Path) {
std::fs::create_dir_all(dir.join("t")).unwrap();
@@ -410,22 +397,26 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None));
// Three in a row: stopped for the session, backing off between them (the wait
// doubles: `Limits::backoff`, tested where it is defined; wall-clock waits on CI
// are too noisy to compare).
// Three in a row: stopped for the session, with growing waits between.
f.take_events();
for crash in 0..3 {
// How long each crash kept the next call waiting, without the restart itself, whose
// instantiation time varies a lot on CI.
let mut waits = Vec::new();
for _ in 0..3 {
let started = Instant::now();
let err = loop {
let attempt = Instant::now();
match f.run("misbehave", "panic", None) {
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
other => break other.unwrap_err(),
other => {
waits.push(attempt - started);
break other.unwrap_err();
}
}
};
assert!(err.contains("crashed"), "{err}");
if crash < 2 {
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("restarts in"), "backing off");
}
}
assert!(waits[1] >= backoff / 2 && waits[2] > waits[1] + backoff / 2, "the wait doubles: {waits:?}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
@@ -493,15 +484,12 @@ fn running_calls_can_be_cancelled() {
let f = f.clone();
std::thread::spawn(move || f.run("misbehave", "spin", None))
};
// A cancel before the call runs (the instance may still be starting) does nothing: repeat it.
let until = Instant::now() + Duration::from_secs(5);
while !spinning.is_finished() {
assert!(Instant::now() < until, "the running call wasn't interrupted");
f.host.cancel("misbehave");
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(100));
let started = Instant::now();
f.host.cancel("misbehave");
let err = spinning.join().unwrap().unwrap_err();
assert!(err.contains("was cancelled"), "{err}");
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"), "no backoff");
let stats = f.host.info_of("misbehave").unwrap().stats;
assert_eq!((stats.crashes, stats.activations), (0, 2));
@@ -543,22 +531,16 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
let provider = host.syntax_provider();
let mis = LanguageId::new("mis");
// The first call starts the instance, which may take longer than the soft deadline.
let mut tokens = None;
assert!(eventually(|| {
tokens = provider.highlights(&mis, "fast");
tokens.is_some()
}));
let tokens = tokens.unwrap();
let tokens = provider.highlights(&mis, "fast").unwrap();
assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword"));
// Slower than the soft deadline: dropped (however late this thread gets
// to look), but the instance lives on.
assert!(provider.highlights(&mis, "sleep:150").is_none());
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
std::thread::sleep(Duration::from_millis(150));
assert!(provider.highlights(&mis, "fast").is_some());
let info = host.info_of("misbehave").unwrap();
assert!(info.stats.slow_calls >= 1, "{:?}", info.stats);
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1));
assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1));
// Past the hard deadline: interrupted; the next call gets a new instance.
assert!(provider.highlights(&mis, "spin").is_none());
@@ -567,8 +549,7 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
let info = host.info_of("misbehave").unwrap();
assert_eq!(info.stats.crashes, 1, "{:?}", info.error);
assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error);
// The new instance may take longer to start than the soft deadline on a slow runner.
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
assert!(provider.highlights(&mis, "fast").is_some());
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
}
@@ -577,10 +558,9 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
#[test]
fn providers_of_several_plugins_run_concurrently() {
let tmp = tempfile::tempdir().unwrap();
// A budget no runner misses, so only the overlap is measured.
let limits = Limits {
edit_soft: Duration::from_secs(5),
edit_hard: Duration::from_secs(10),
edit_soft: Duration::from_millis(300),
edit_hard: Duration::from_secs(2),
fuel: u64::MAX,
..Limits::default()
};
@@ -609,17 +589,16 @@ fn providers_of_several_plugins_run_concurrently() {
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// Activation is not what is measured: a first call that starts an instance
// can run past the edit-path budget, and its answer is dropped.
assert!(eventually(|| host.provide_completion(&doc("fast"), at, None).len() == 2));
// Activation is not what is measured.
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
let started = Instant::now();
let items = host.provide_completion(&doc("sleep:500"), at, None);
let items = host.provide_completion(&doc("sleep:200"), at, None);
let elapsed = started.elapsed();
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
from.sort();
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
assert!(elapsed < Duration::from_millis(950), "one after the other would take 1000ms: {elapsed:?}");
assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}");
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
}
@@ -639,7 +618,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
@@ -1677,21 +1656,22 @@ fn official_ai_completion_cancels_superseded_requests() {
};
let at = ide_core::Pos::new(1, 4);
let host = Arc::new(f);
// A slow request, superseded by the next keystroke's once the server has it
// (starting the instance comes first, and takes longer on a slow runner).
// A slow request, superseded 200 ms later by the next keystroke's.
let first = ide_core::CancellationToken::new();
let slow = {
let (host, doc, first) = (host.clone(), doc.clone(), first.clone());
std::thread::spawn(move || (host.host.provide_inline_completion(&doc, at, &first), Instant::now()))
std::thread::spawn(move || {
let started = Instant::now();
let r = host.host.provide_inline_completion(&doc, at, &first);
(r, started.elapsed())
})
};
assert!(eventually(|| seen.lock().len() == 1), "the first request reaches the server");
let cancelled = Instant::now();
std::thread::sleep(Duration::from_millis(200));
first.cancel();
let started = Instant::now();
let second = host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new());
let took = started.elapsed();
let (old, old_done) = slow.join().unwrap();
let old_took = old_done.saturating_duration_since(cancelled);
let (old, old_took) = slow.join().unwrap();
assert!(old.is_none(), "the superseded request yields nothing");
assert!(old_took < Duration::from_secs(1), "cancelled, not waited out: {old_took:?}");
assert_eq!(second.map(|(_, s)| s.insert_text).as_deref(), Some("a + b"));
@@ -1869,12 +1849,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
assert_eq!((target.path, target.range.start), (xml_path.clone(), ide_core::Pos::new(1, 14)));
let xdoc = doc(&xml_path, "xml", xml);
// Reads the interface from disk: on a slow machine that too can run past the budget.
let mut to_java = Vec::new();
assert!(eventually(|| {
to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
!to_java.is_empty()
}));
let to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
assert_eq!(
to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::<Vec<_>>(),
[(java_path.clone(), ide_core::Pos::new(3, 9))]
@@ -2045,83 +2020,6 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
@@ -2495,61 +2393,3 @@ fn uninstalling_deletes_the_plugins_secrets() {
f.host.uninstall("openidea.ai-completion").unwrap();
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
}
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
/// providers carry snippets, imports, lazily computed quick fixes and tagged
/// problems.
#[test]
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let f = fixture(Limits::default());
let pkg = f._tmp.path().join("misbehave-providers");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = |text: &str| ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// The first call starts the instance, which may take longer than the edit-path budget.
let mut items = Vec::new();
assert!(eventually(|| {
items = f.host.provide_completion(&doc("rich"), at, None);
!items.is_empty()
}));
let item = &items[0].1;
assert_eq!(
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
("f(${1:x})$0", true, Some("fun"))
);
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
// Offered without edits; computed when picked.
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
let (plugin, action) = actions.into_iter().next().expect("offered");
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
// A kind this IDE doesn't know is refused, not a trap.
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
assert!(err.message.contains("no event kind"), "{}", err.message);
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
// Tags the IDE knows, the tool that found it.
let path = f.project.join("a.txt");
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
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")));
}
+4 -6
View File
@@ -228,9 +228,7 @@ async fn serves_projects_files_documents_search_and_runs() {
// Real project-host ignore writes travel through the remote command dispatcher.
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
let ignore_root = dunce::canonicalize(&project).unwrap();
// Not `.tmp`: a machine's global excludes file (often `*.tmp`) already
// ignores those, so the backend never writes the entry anywhere.
for (name, destination) in [("shared.ignore-me", "gitignore"), ("private.ignore-me", "localExclude")] {
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
let path = ignore_root.join(name);
std::fs::write(&path, "temporary").unwrap();
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [path], "destination": destination })).await.unwrap();
@@ -239,9 +237,9 @@ async fn serves_projects_files_documents_search_and_runs() {
assert_eq!(entry["ignored"], true);
assert_eq!(entry["excluded"], false);
}
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.ignore-me"));
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.ignore-me"));
let outside = tmp.path().join("outside.ignore-me");
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.tmp"));
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
let outside = tmp.path().join("outside.tmp");
std::fs::write(&outside, "keep").unwrap();
assert!(
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
-16
View File
@@ -141,19 +141,3 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
+2 -11
View File
@@ -58,11 +58,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
"plugin_market",
"plugin_market_url",
"plugin_set_market_url",
"plugin_market_download",
"plugin_preview_install",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
@@ -77,6 +72,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"tools_list",
"tools_install",
"tools_uninstall",
"plugin_install_dir",
"plugin_reload",
"vcs_git_version",
"docker_compose_op",
"db_execute",
@@ -96,12 +93,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
}
}
// Plugin installs run plugin code: from the welcome screen, but not in an untrusted project.
for command in ["plugin_install_dir", "plugin_uninstall", "plugin_set_enabled", "plugin_reload"] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
assert!(host.authorize_command("main", command).is_err(), "{command}");
assert!(host.authorize_command("welcome", command).is_ok(), "{command}");
}
// These requests validate the vendor before any network access, independently of project trust.
for window in ["main", "welcome"] {
let error = ide_host::ops::tools::jdk_download_releases(
-100
View File
@@ -1,100 +0,0 @@
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops};
use std::{sync::Arc, time::Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("Game");
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
std::fs::create_dir_all(root.join(name)).unwrap();
}
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
let host = 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,
});
let project = host.open_project("main", &root).unwrap();
let info = ops::project::info(&project);
assert_eq!(info.name, "Game");
let unity = info.unity.unwrap();
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
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);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
}))
.unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file.rel_path);
}
}),
)
.await
.unwrap();
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
// Project metadata refreshes without reopening when Unity generates its solution.
std::fs::write(root.join("Game.sln"), "").unwrap();
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
let unity = ops::project::info(&project).unity.unwrap();
assert_eq!(unity.solutions, ["Game.sln"]);
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
// Desktop commands and remote RPC share these host operations.
let original = root.join("Assets/Scripts/Player.cs");
let renamed = root.join("Assets/Scripts/Hero.cs");
let copied = root.join("Assets/Scripts/HeroCopy.cs");
let meta = |path: &std::path::Path| -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
host.close_project("main");
let ordinary = base.join("ConsoleApp");
std::fs::create_dir(&ordinary).unwrap();
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
let project = host.open_project("main", &ordinary).unwrap();
assert!(ops::project::info(&project).unity.is_none());
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
host.close_project("main");
}
-209
View File
@@ -1,209 +0,0 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let host = 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(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
-63
View File
@@ -175,66 +175,3 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
use ide_lsp::types::{CodeAction, Command};
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("imports.txt");
std::fs::write(&file, "unused\nused\n").unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &file).unwrap();
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
disabled_reason: None,
edit: edit.then(|| {
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
] }] }))
.unwrap()
}),
command: Some(Command {
title: String::new(),
command: "_typescript.didOrganizeImports".into(),
arguments: vec![],
}),
data: None,
server: ide_core::ServerId(1),
};
let follow_up = action(true).command.unwrap();
let asked = ops::lsp::lsp_resolve_command(
&host,
"main",
ide_core::RequestId(1),
info.id,
ide_core::ServerId(1),
follow_up,
vec![],
)
.await;
assert!(matches!(asked, Ok(None)), "{asked:?}");
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
.await
.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
// Without an edit the command is the action: it still can't run.
let ran =
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
assert!(ran.is_err(), "{ran:?}");
}
-51
View File
@@ -114,54 +114,3 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
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,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
-3
View File
@@ -14,9 +14,6 @@ fn bench(c: &mut Criterion) {
for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] {
g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len())));
}
for q in ["util", "src/ui", "a"] {
g.bench_function(format!("find_dirs/{q}"), |b| b.iter(|| black_box(index.find_dirs(q, 50).len())));
}
for q in ["OCo", "invoiceRepo", "renderView", "q"] {
g.bench_function(format!("find_symbols/{q}"), |b| {
b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len()))
-57
View File
@@ -40,8 +40,6 @@ pub(crate) struct FileRecord {
pub size: u64,
pub hash: [u8; 32],
pub symbols: Vec<StoredSymbol>,
/// Matched by an ignore file: listed by name only (no hash, no symbols).
pub ignored: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -74,7 +72,6 @@ pub(crate) struct FileEntry {
pub size: u64,
pub hash: [u8; 32],
pub symbols: Box<[Sym]>,
pub ignored: bool,
}
impl FileEntry {
@@ -103,7 +100,6 @@ impl FileEntry {
size: record.size,
hash: record.hash,
symbols,
ignored: record.ignored,
}
}
@@ -130,23 +126,14 @@ impl FileEntry {
range: s.range,
})
.collect(),
ignored: self.ignored,
}
}
/// Whether the entry is out of date for `found` on disk.
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
}
}
#[derive(Default)]
pub(crate) struct IndexData {
pub files: Vec<Option<FileEntry>>,
by_path: HashMap<PathBuf, u32>,
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
/// trailing `/`: its absolute path and the number of files below it.
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
free: Vec<u32>,
pub file_count: u32,
pub symbol_count: u32,
@@ -180,11 +167,6 @@ impl IndexData {
}
};
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
if let Some(e) = &self.files[slot as usize] {
for (rel, abs) in ancestors(e) {
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
}
}
self.by_path.insert(path, slot);
self.file_count += 1;
self.symbol_count += symbols;
@@ -194,14 +176,6 @@ impl IndexData {
let slot = self.by_path.remove(path)?;
let old = self.files[slot as usize].take()?;
self.free.push(slot);
for (rel, _) in ancestors(&old) {
if let Some(d) = self.dirs.get_mut(rel) {
d.1 -= 1;
if d.1 == 0 {
self.dirs.remove(rel);
}
}
}
self.file_count -= 1;
self.symbol_count -= old.symbols.len() as u32;
Some(old)
@@ -223,7 +197,6 @@ impl IndexData {
self.files.shrink_to_fit();
self.free.shrink_to_fit();
self.by_path.shrink_to_fit();
self.dirs.shrink_to_fit();
}
pub(crate) fn paths(&self) -> impl Iterator<Item = &PathBuf> {
@@ -231,19 +204,6 @@ impl IndexData {
}
}
/// The folders holding `e` (`rel` with a trailing `/`, absolute path), innermost first.
fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
let mut rel = e.dir();
let mut abs = e.path.parent();
std::iter::from_fn(move || {
let path = abs.filter(|_| !rel.is_empty())?;
let here = rel;
rel = rel[..rel.len() - 1].rfind('/').map_or("", |i| &rel[..=i]);
abs = path.parent();
Some((here, path))
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -263,23 +223,6 @@ mod tests {
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
d.insert(entry("a/b/c.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("top.rs", 0));
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
dirs.sort();
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
d.remove(Path::new("/r/a/b/c.rs"));
assert!(!d.dirs.contains_key("a/b/"));
d.remove_tree(Path::new("/r/a"));
assert!(d.dirs.is_empty());
}
#[test]
fn insert_replace_remove_keep_counts() {
let mut d = IndexData::default();
+21 -172
View File
@@ -1,16 +1,10 @@
//! Which paths belong in the index: `.gitignore`/`.ignore` rules (via the
//! `ignore` crate) plus the user's `files.exclude` globs, below the project root
//! and the extra content roots (Project Structure).
//!
//! Like IDEA, ignored directories are left out entirely, while ignored files in
//! the remaining directories (`.env`) are listed by name only, so Go to File finds
//! them but their symbols stay out of the index.
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::{WalkBuilder, WalkState};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
@@ -63,25 +57,11 @@ fn rel_string(rel: &Path) -> String {
out
}
/// Whether `path` below `root` matches the exclude globs.
fn glob_excluded(excludes: &GlobSet, root: &Path, path: &Path) -> bool {
match path.strip_prefix(root) {
Ok(rel) if !rel.as_os_str().is_empty() => {
let rel = rel.to_string_lossy();
let rel = if std::path::MAIN_SEPARATOR == '/' { rel } else { rel.replace('\\', "/").into() };
excludes.is_match(rel.as_ref())
}
_ => false,
}
}
#[derive(Clone, Debug)]
pub(crate) struct Found {
pub path: PathBuf,
pub mtime: u64,
pub size: u64,
/// Matched by an ignore file: listed by name only.
pub ignored: bool,
}
pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
@@ -197,9 +177,8 @@ impl PathFilter {
ALWAYS_SKIP.contains(&name)
}
/// Whether a path reported by the watcher should be left out (outside the
/// root, excluded, or an ignored directory or inside one). Ignored files
/// elsewhere stay in, listed by name only.
/// Whether a path reported by the watcher should be ignored (outside the
/// root, inside an excluded/ignored directory, or ignored itself).
pub(crate) fn is_excluded(&self, path: &Path, is_dir: bool) -> bool {
let Some((base, _, gitignore)) = self.base_of(path) else { return true };
let rel = path.strip_prefix(base).map(rel_string).unwrap_or_default();
@@ -219,27 +198,16 @@ impl PathFilter {
return true;
}
}
let dir = if is_dir { Some(path) } else { path.parent().filter(|p| *p != base) };
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()
}
/// All indexable files at or below `dir`, walked in parallel.
/// Stops early (returning what was found so far) once `cancel` is set.
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
self.walk_to(dir, None, cancel)
}
/// The indexable files directly in `dir`, classified by the same rules as [`Self::walk`].
pub(crate) fn list_dir(&self, dir: &Path) -> Vec<Found> {
self.walk_to(dir, Some(1), &AtomicBool::new(false))
}
fn walk_to(&self, dir: &Path, max_depth: Option<usize>, cancel: &AtomicBool) -> Vec<Found> {
let (tx, rx) = mpsc::channel::<Vec<Found>>();
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
let root = root.to_path_buf();
let excludes = self.excludes.clone();
let (walk_root, walk_excludes) = (root.clone(), excludes.clone());
let walker = WalkBuilder::new(dir)
.hidden(false)
.parents(true)
@@ -247,65 +215,43 @@ impl PathFilter {
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(ide_core::ignored::inside_git_repo(dir))
.require_git(false)
.follow_links(false)
.max_depth(max_depth)
.filter_entry(move |e| {
let name = e.file_name().to_string_lossy();
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
if Self::skip_name(&name) {
return false;
}
match e.path().strip_prefix(&root) {
Ok(rel) if !rel.as_os_str().is_empty() => {
let rel = rel.to_string_lossy();
let rel = if std::path::MAIN_SEPARATOR == '/' { rel } else { rel.replace('\\', "/").into() };
!excludes.is_match(rel.as_ref())
}
_ => true,
}
})
.build_parallel();
walker.run(|| {
let (root, excludes) = (&root, &excludes);
let mut batch = Batch { tx: tx.clone(), items: Vec::with_capacity(256) };
Box::new(move |entry| {
if cancel.load(Ordering::Relaxed) {
return WalkState::Quit;
}
let Ok(entry) = entry else { return WalkState::Continue };
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() && max_depth.is_none_or(|max| entry.depth() < max) {
// The walker drops ignored files unseen: list every file of a
// walked directory; those the walker doesn't report are ignored.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
for child in children.flatten() {
let path = child.path();
if child.file_type().is_ok_and(|t| t.is_file())
&& !Self::skip_name(&child.file_name().to_string_lossy())
&& !glob_excluded(excludes, root, &path)
&& let Ok(meta) = child.metadata()
{
batch.items.push(Found { path, mtime: mtime_of(&meta), size: meta.len(), ignored: true });
}
}
} else if file_type.is_file()
if entry.file_type().is_some_and(|t| t.is_file())
&& let Ok(meta) = entry.metadata()
{
batch.items.push(Found {
path: entry.into_path(),
mtime: mtime_of(&meta),
size: meta.len(),
ignored: false,
});
}
if batch.items.len() >= 256 && batch.tx.send(std::mem::take(&mut batch.items)).is_err() {
return WalkState::Quit;
batch.items.push(Found { path: entry.into_path(), mtime: mtime_of(&meta), size: meta.len() });
if batch.items.len() >= 256 && batch.tx.send(std::mem::take(&mut batch.items)).is_err() {
return WalkState::Quit;
}
}
WalkState::Continue
})
});
drop(tx);
// Each file comes once from its directory listing and, unless ignored, once from the walker.
let mut by_path: HashMap<PathBuf, Found> = HashMap::new();
for f in rx.into_iter().flatten() {
match by_path.entry(f.path.clone()) {
Entry::Occupied(mut e) => e.get_mut().ignored &= f.ignored,
Entry::Vacant(e) => {
e.insert(f);
}
}
}
by_path.into_values().collect()
rx.into_iter().flatten().collect()
}
}
@@ -365,103 +311,6 @@ mod tests {
assert!(!filter.is_excluded(&root.join("src/new.rs"), false));
}
#[test]
fn ignored_files_are_listed_but_ignored_directories_are_not() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
for f in [
".env",
"src/a.rs",
"src/debug.log",
"src/local/secrets.json",
"pkg/.gitignore",
"pkg/gen.rs",
"pkg/keep.rs",
"build/out.rs",
"build/nested/deep.rs",
"logs/app.log",
"node_modules/x/.env",
"src/.DS_Store",
"src/.git",
] {
touch(&root, f);
}
std::fs::write(
root.join(".gitignore"),
".env
*.log
build/
logs/
src/local/
",
)
.unwrap();
std::fs::write(
root.join("pkg/.gitignore"),
"gen.rs
",
)
.unwrap();
let filter = PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "**/.DS_Store".into()]);
let mut found: Vec<(String, bool)> = filter
.walk(&root, &AtomicBool::new(false))
.iter()
.filter_map(|f| Some((filter.rel(&f.path)?, f.ignored)))
.collect();
found.sort();
let expected = [
(".env", true),
(".gitignore", false),
("pkg/.gitignore", false),
("pkg/gen.rs", true),
("pkg/keep.rs", false),
("src/a.rs", false),
("src/debug.log", true),
];
assert_eq!(found, expected.map(|(p, i)| (p.to_owned(), i)));
// One directory at a time (watcher updates): same classification, no descent.
let mut listed: Vec<(String, bool)> =
filter.list_dir(&root.join("pkg")).iter().filter_map(|f| Some((filter.rel(&f.path)?, f.ignored))).collect();
listed.sort();
let expected = [("pkg/.gitignore", false), ("pkg/gen.rs", true), ("pkg/keep.rs", false)];
assert_eq!(listed, expected.map(|(p, i)| (p.to_owned(), i)));
let top: Vec<String> = filter.list_dir(&root).iter().filter_map(|f| filter.rel(&f.path)).collect();
assert!(top.iter().all(|p| !p.contains('/')), "{top:?}");
for (rel, excluded) in [
(".env", false),
("src/new.log", false),
("src/new.rs", false),
("build/x.rs", true),
("logs/app.log", true),
("src/local/x.json", true),
] {
assert_eq!(filter.is_excluded(&root.join(rel), false), excluded, "{rel}");
}
assert!(filter.is_excluded(&root.join("build"), true));
assert!(!filter.is_excluded(&root.join("src"), true));
}
#[test]
fn a_nested_repository_is_not_hidden_by_the_outer_repositorys_gitignore() {
let dir = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
for f in ["projects/nested/src/a.rs", "projects/nested/out/x.rs"] {
touch(&base, f);
}
std::fs::create_dir(base.join(".git")).unwrap();
std::fs::create_dir(base.join("projects/nested/.git")).unwrap();
std::fs::write(base.join("projects/.gitignore"), "*\n!.gitignore\n").unwrap();
std::fs::write(base.join("projects/nested/.gitignore"), "out/\n").unwrap();
let root = base.join("projects/nested");
let filter = PathFilter::new(&root, &vec![].into(), &[]);
let mut found: Vec<String> =
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
found.sort();
assert_eq!(found, [".gitignore", "src/a.rs"]);
}
#[test]
fn extra_content_roots_are_walked_and_prefixed() {
let dir = tempfile::tempdir().unwrap();
-39
View File
@@ -8,7 +8,6 @@ use ide_language::{LanguageRegistry, Syntax};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -65,14 +64,6 @@ pub struct IndexStatus {
pub symbols: u32,
}
/// How many indexed files share an extension (lower case, without the dot).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionCount {
pub extension: String,
pub files: u32,
}
pub struct ProjectIndex {
shared: Arc<Shared>,
worker: Mutex<Option<JoinHandle<()>>>,
@@ -168,27 +159,6 @@ impl ProjectIndex {
}
}
/// The extensions of the indexed files, most common first: the project's file
/// types, for suggestions such as Find in Files' file masks. Dotfiles
/// (`.gitignore`) have no extension.
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
let d = self.shared.data.read();
let mut counts: HashMap<String, u32> = HashMap::new();
for file in d.files.iter().flatten() {
let name = &file.rel[file.name_start as usize..];
if let Some((stem, extension)) = name.rsplit_once('.')
&& !stem.is_empty()
&& !extension.is_empty()
{
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
}
}
let mut out: Vec<ExtensionCount> =
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
out
}
/// 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<FileMatch> {
@@ -197,11 +167,6 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.shared.data.read(), query, limit)
}
/// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`).
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
let recent = self.shared.recent.lock().clone();
@@ -401,10 +366,6 @@ pub mod bench_support {
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
}
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.data, query, limit)
}
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
}
+1 -71
View File
@@ -12,7 +12,7 @@ use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
const TIER_NAME: u32 = 1 << 20;
const EXACT: u32 = 4000;
@@ -251,76 +251,6 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
.collect()
}
/// Fuzzy folder search over the folders holding indexed files: folder-name matches beat
/// path matches; `/` in the query matches the whole path. `rel_path` ends in `/`.
pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<FileMatch> {
let q = normalize_file_query(query);
if limit == 0 || q.is_empty() {
return Vec::new();
}
let by_path = q.contains('/');
let needle = Needle::new(q.trim_end_matches('/'));
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
let trimmed = &rel[..rel.len() - 1];
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
let name = &trimmed[start..];
if !by_path
&& needle.mask & char_mask(name) == needle.mask
&& let Some(s) = needle.score(m, name, buf)
{
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
}
if needle.mask & char_mask(trimmed) != needle.mask {
return None;
}
needle.score(m, trimmed, buf).map(|s| (s, None))
};
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
.dirs
.par_iter()
.filter_map(|(rel, (path, _))| {
let mut buf = Vec::new();
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
})
.collect();
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
};
if best.len() > limit {
best.select_nth_unstable_by(limit, order);
best.truncate(limit);
}
// Match indices and the CamelHump bonus `find_files` gives, so equal matches rank alike.
let mut out: Vec<FileMatch> = with_matcher(|m| {
let (mut buf, mut idx) = (Vec::new(), Vec::new());
best.into_iter()
.map(|(score, rel, path, name_at)| {
let trimmed = &rel[..rel.len() - 1];
let (bonus, indices) = match name_at {
Some(at) => {
let name = &trimmed[trimmed.rfind('/').map_or(0, |i| i + 1)..];
needle.indices(m, name, &mut buf, &mut idx);
(hump_bonus(name, &idx), idx.iter().map(|i| i + at).collect())
}
None => {
needle.indices(m, trimmed, &mut buf, &mut idx);
(hump_bonus(trimmed, &idx), idx.clone())
}
};
FileMatch { path: path.to_path_buf().into(), rel_path: rel.to_owned(), score: score + bonus, indices }
})
.collect()
});
out.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.rel_path.len().cmp(&b.rel_path.len()))
.then_with(|| a.rel_path.cmp(&b.rel_path))
});
out
}
fn file(data: &IndexData, slot: u32) -> &FileEntry {
data.files[slot as usize].as_ref().expect("candidate slots are live")
}
+1 -1
View File
@@ -8,7 +8,7 @@ use std::path::Path;
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
/// Bump when [`FileRecord`] or its encoding changes.
const SCHEMA: &str = "openidea-index-3";
const SCHEMA: &str = "openidea-index-2";
type Result<T> = std::result::Result<T, String>;
-89
View File
@@ -111,28 +111,6 @@ fn indexes_files_and_symbols_honouring_excludes() {
assert_eq!(m.path, fx.root.join("src/main.rs"));
}
#[test]
fn counts_file_extensions_most_common_first() {
let fx = Fixture::new(&[
("src/a.rs", "fn a() {}\n"),
("src/b.RS", "fn b() {}\n"),
("src/lib.rs", "fn c() {}\n"),
("Cargo.toml", "[package]\n"),
("README.md", "# r\n"),
("docs/guide.md", "# g\n"),
(".gitignore", "target\n"),
("Makefile", "all:\n"),
("target/debug/gen.rs", "fn generated() {}\n"),
]);
let idx = fx.open();
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
assert_eq!(
counts,
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
"case-folded, excluded folders and extension-less files left out"
);
}
#[test]
fn file_ranking_is_idea_like() {
let fx = sample();
@@ -160,23 +138,6 @@ fn file_ranking_is_idea_like() {
assert_eq!(shown.to_lowercase(), "uibtn");
}
#[test]
fn find_dirs_ranks_folder_names() {
let f = sample();
let idx = f.open();
let dirs = |q: &str| idx.find_dirs(q, 20).into_iter().map(|m| m.rel_path).collect::<Vec<_>>();
assert_eq!(dirs("order"), ["src/order/"]);
assert_eq!(dirs("src")[0], "src/");
assert!(dirs("src").contains(&"src/ui/".to_owned()), "{:?}", dirs("src"));
assert_eq!(dirs("src/ui/"), ["src/ui/"]);
assert!(dirs("").is_empty());
assert!(dirs("main").is_empty());
let m = &idx.find_dirs("ui", 1)[0];
assert_eq!(m.path.0, f.root.join("src/ui"));
assert_eq!(m.indices, vec![4, 5]);
}
#[test]
fn shorter_paths_win_ties_and_recent_files_boost() {
let fx = Fixture::new(&[("a/b/c/util.rs", ""), ("x/util.rs", ""), ("y/util.rs", "")]);
@@ -315,56 +276,6 @@ fn binary_and_huge_files_are_listed_without_symbols() {
assert_eq!(idx.status().symbols, 0);
}
#[test]
fn go_to_file_finds_ignored_files_but_not_their_symbols() {
let fx = Fixture::new(&[
(".gitignore", ".env\n*.local.ts\ndist/\n"),
(".env", "SECRET=1\n"),
("src/app.ts", "export function app() {}\n"),
("src/config.local.ts", "export function localOnly() {}\n"),
("dist/bundle.js", "function bundled() {}\n"),
]);
let idx = fx.open();
assert_eq!(files(&idx, ".env"), vec![".env"], "the reported case: a git-ignored root file");
assert_eq!(files(&idx, "config.local"), vec!["src/config.local.ts"]);
assert!(files(&idx, "bundle").is_empty(), "ignored directories stay out");
assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty(), "ignored files are listed by name only");
assert_eq!(symbols(&idx, "app", SymbolFilter::All), vec!["app"]);
// Watcher updates keep the distinction.
let fresh = fx.write("src/fresh.local.ts", "export function freshLocal() {}\n");
let bundled = fx.write("dist/more.js", "function more() {}\n");
idx.update(&[fresh, bundled], &[]);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "fresh.local"), vec!["src/fresh.local.ts"]);
assert!(symbols(&idx, "freshLocal", SymbolFilter::All).is_empty());
assert!(files(&idx, "more.js").is_empty());
// Nested ignore files apply to watcher updates as they do to the scan.
fx.write("pkg/.gitignore", "gen.ts\n");
let generated = fx.write("pkg/gen.ts", "export function generatedThing() {}\n");
let kept = fx.write("pkg/kept.ts", "export function keptThing() {}\n");
idx.update(&[generated, kept], &[]);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "pkg/gen"), vec!["pkg/gen.ts"]);
assert!(symbols(&idx, "generatedThing", SymbolFilter::All).is_empty());
assert_eq!(symbols(&idx, "keptThing", SymbolFilter::All), vec!["keptThing"]);
drop(idx);
// No longer ignored: the next scan parses it although its mtime is unchanged.
std::fs::write(fx.root.join(".gitignore"), ".env\ndist/\n").unwrap();
let idx = fx.open();
assert_eq!(symbols(&idx, "localOnly", SymbolFilter::All), vec!["localOnly"]);
assert_eq!(files(&idx, ".env"), vec![".env"]);
drop(idx);
// Ignored again: its symbols are dropped.
std::fs::write(fx.root.join(".gitignore"), "*.local.ts\n").unwrap();
let idx = fx.open();
assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty());
assert_eq!(files(&idx, "bundle"), vec!["dist/bundle.js"]);
}
#[test]
fn second_window_on_same_project_works_in_memory() {
let fx = sample();
+15 -17
View File
@@ -2,13 +2,13 @@
//! updates queued by the watcher.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{Found, PathFilter};
use crate::filter::{Found, PathFilter, mtime_of};
use crate::store::Store;
use ide_core::{IdeEvent, SharedSink, TaskId};
use ide_language::{LanguageRegistry, Syntax};
use parking_lot::{Condvar, Mutex, RwLock};
use rayon::prelude::*;
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{HashSet, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -233,7 +233,10 @@ impl Shared {
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
let d = self.data.read();
let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect();
let todo = found
.iter()
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
.collect();
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
(todo, stale)
};
@@ -308,7 +311,6 @@ impl Shared {
let mut rec = d.get(&f.path)?.record();
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
Some((rel, rec, f.path.clone()))
}
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
@@ -340,8 +342,8 @@ impl Shared {
}
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
if f.ignored || f.size > MAX_SYMBOL_FILE {
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ..Default::default() };
if f.size > MAX_SYMBOL_FILE {
return Extracted::New(rec);
}
let mut bytes = Vec::with_capacity(f.size as usize);
@@ -418,7 +420,6 @@ impl Shared {
let mut files: Vec<Found> = Vec::new();
let mut seen = HashSet::new();
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for path in changed {
if !seen.insert(path.clone()) {
continue;
@@ -429,19 +430,16 @@ impl Shared {
}
if meta.is_dir() {
files.extend(filter.walk(&path, &self.cancelled));
} else if meta.is_file()
&& let Some(dir) = path.parent()
{
by_dir.entry(dir.to_owned()).or_default().insert(path);
} else if meta.is_file() {
files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path });
}
}
// Listing the folder applies every ignore file (nested, global) to tell ignored files apart.
for (dir, wanted) in by_dir {
files.extend(filter.list_dir(&dir).into_iter().filter(|f| wanted.contains(&f.path)));
}
let files: Vec<Found> = {
let d = self.data.read();
files.into_iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect()
files
.into_iter()
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
.collect()
};
if !files.is_empty() {
touched = true;
@@ -459,7 +457,7 @@ impl Shared {
.filter(|p| before.detect(p, None) != self.languages.detect(p, None))
.filter_map(|p| {
let e = d.get(p)?;
Some(Found { path: p.clone(), mtime: e.mtime, size: e.size, ignored: e.ignored })
Some(Found { path: p.clone(), mtime: e.mtime, size: e.size })
})
.collect()
};
-3
View File
@@ -16,6 +16,3 @@ parking_lot.workspace = true
thiserror.workspace = true
tracing.workspace = true
encoding_rs.workspace = true
[dev-dependencies]
tempfile.workspace = true
+2 -23
View File
@@ -216,9 +216,6 @@ pub trait Handler: Send + Sync + 'static {
fn on_request_with_data(&self, method: &str, params: Value) -> Result<Value, (i64, String, Option<Value>)> {
self.on_request(method, params).map_err(|(code, message)| (code, message, None))
}
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -246,9 +243,6 @@ struct Inner {
pid: Option<u32>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
/// also what it started); at once when there is no child.
exited: CancellationToken,
}
impl Inner {
@@ -438,7 +432,6 @@ impl Peer {
(child.wait().await.ok(), true)
}
};
inner.exited.cancel();
if !killed {
// Bounded: a grandchild may keep the pipes open.
let drained = async {
@@ -517,11 +510,7 @@ impl Peer {
kill: CancellationToken::new(),
pid,
reader_done: CancellationToken::new(),
exited: CancellationToken::new(),
});
if pid.is_none() {
inner.exited.cancel();
}
// Writer: a single task drains the queue so messages never interleave.
{
@@ -715,13 +704,6 @@ impl Peer {
self.inner.close_transport();
self.inner.kill.cancel();
}
/// Resolves once the child process is gone: exited on its own, or killed with what it
/// started and reaped. [`closed`](Self::closed) can come earlier, at EOF. Until then the
/// child may still hold files open, which on Windows can't be deleted.
pub async fn exited(&self) {
self.inner.exited.cancelled().await;
}
}
/// Removes the pending entry and tells the remote side to stop when a request
@@ -758,8 +740,7 @@ enum Incoming {
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
let m = method.clone();
let h = handler.clone();
let result = tokio::task::spawn_blocking(move || h.on_request_with_data(&m, params)).await;
let result = tokio::task::spawn_blocking(move || handler.on_request_with_data(&m, params)).await;
let reply = match result {
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Ok(Err((code, message, data))) => {
@@ -774,9 +755,7 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
}),
};
if inner.send_value(&reply).is_ok() {
handler.after_response(&method);
}
let _ = inner.send_value(&reply);
}
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
-87
View File
@@ -251,34 +251,6 @@ async fn server_to_client_request() {
assert!(matches!(err, RpcError::Server { code: codes::METHOD_NOT_FOUND, .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn after_response_messages_follow_the_response() {
#[derive(Default)]
struct Client(std::sync::OnceLock<Peer>);
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Ok(json!("answer"))
}
fn after_response(&self, method: &str) {
self.0.get().unwrap().notify("after", method).unwrap();
}
}
let client = Arc::new(Client::default());
let (client_io, server_io) = duplex(64 * 1024);
let (cr, cw) = tokio::io::split(client_io);
assert!(client.0.set(Peer::connect(cr, cw, client.clone())).is_ok());
let (sr, mut sw) = tokio::io::split(server_io);
let request = br#"{"jsonrpc":"2.0","id":1,"method":"workspace/configuration","params":{}}"#;
framing::write_message(&mut sw, request).await.unwrap();
let mut reader = tokio::io::BufReader::new(sr);
let mut next = async || -> Value {
serde_json::from_slice(&framing::read_message(&mut reader).await.unwrap().unwrap()).unwrap()
};
assert_eq!(next().await, json!({ "jsonrpc": "2.0", "id": 1, "result": "answer" }));
assert_eq!(next().await, json!({ "jsonrpc": "2.0", "method": "after", "params": "workspace/configuration" }));
}
#[tokio::test(flavor = "multi_thread")]
async fn requests_start_after_the_notifications_before_them() {
// `seen` answers with the notifications handled so far; handling one is slow.
@@ -441,65 +413,6 @@ async fn kill_terminates_process() {
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn exited_resolves_once_the_killed_process_is_reaped() {
let peer =
Peer::spawn("sleep", &["30".into()], &std::env::temp_dir(), &[], Arc::new(NoopHandler), Box::new(|_| {}))
.unwrap();
let pid = peer.pid().unwrap().to_string();
let exited = tokio::time::timeout(Duration::from_millis(200), peer.exited()).await;
assert!(exited.is_err(), "running until killed");
peer.kill();
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
let alive = std::process::Command::new("kill").args(["-0", &pid]).status().unwrap().success();
assert!(!alive, "reaped, not even a zombie");
}
#[tokio::test(flavor = "multi_thread")]
async fn exited_resolves_at_once_without_a_process() {
let (client, _server) = pair(Arc::new(NoopHandler), Arc::new(Recorder::default()));
tokio::time::timeout(Duration::from_secs(1), client.exited()).await.unwrap();
}
#[cfg(windows)]
#[tokio::test(flavor = "multi_thread")]
async fn exited_waits_for_a_windows_descendant_to_release_its_file() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("hold.ps1"),
"$f=[IO.File]::Open('locked.txt','Create','ReadWrite','None'); [IO.File]::WriteAllText('ready.txt','ready'); Start-Sleep 30",
)
.unwrap();
let peer = Peer::spawn(
"cmd",
&[
"/D".into(),
"/C".into(),
"powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File hold.ps1".into(),
],
tmp.path(),
&[],
Arc::new(NoopHandler),
Box::new(|_| {}),
)
.unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
while !tmp.path().join("ready.txt").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let open = || std::fs::OpenOptions::new().read(true).share_mode(0).open(tmp.path().join("locked.txt"));
assert_eq!(open().unwrap_err().raw_os_error(), Some(32));
assert!(tokio::time::timeout(Duration::from_millis(200), peer.exited()).await.is_err());
peer.kill();
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
drop(open().expect("exited must wait for the descendant's file handle to close"));
}
/// A launcher that prints in the system code page and exits before speaking the
/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then
/// `pause` prints `请按任意键继续. . .` in GBK on stdout.
-13
View File
@@ -60,9 +60,6 @@ pub struct LanguageConfig {
/// External formatter used when no language server can format, or selected in settings.
#[serde(default)]
pub formatter: Option<FormatterConfig>,
/// Content of a new scratch file; `$END$` marks where the caret goes.
#[serde(default)]
pub scratch_template: Option<String>,
}
/// An external formatting tool and its language-specific parser.
@@ -155,11 +152,6 @@ pub struct LanguageServerConfig {
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
/// 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.
#[serde(default)]
pub resync_on_configuration: bool,
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
@@ -250,10 +242,6 @@ pub struct LanguageInfo {
#[serde(default)]
#[specta(optional)]
pub first_line: Option<String>,
/// Content of a new scratch file; `$END$` marks where the caret goes.
#[serde(default)]
#[specta(optional)]
pub scratch_template: Option<String>,
}
impl From<&LanguageConfig> for LanguageInfo {
@@ -270,7 +258,6 @@ impl From<&LanguageConfig> for LanguageInfo {
language_servers: c.language_servers.iter().map(|s| s.name.clone()).collect(),
filenames: c.filenames.clone(),
first_line: c.first_line.clone(),
scratch_template: c.scratch_template.clone(),
}
}
}
-4
View File
@@ -772,10 +772,6 @@ mod tests {
assert!(addr.parse::<std::net::SocketAddr>().is_ok(), "{name}: bad connect address {addr}");
}
}
if let Some(t) = &cfg.scratch_template {
assert_eq!(t.matches("$END$").count(), 1, "{name}: scratch-template needs one $END$");
assert!(!cfg.extensions.is_empty(), "{name}: scratch-template without an extension");
}
for ext in &cfg.extensions {
assert_eq!(ext, &ext.to_ascii_lowercase(), "{name}: extensions must be lower case");
assert!(!ext.starts_with('.'), "{name}: extension {ext} must not start with a dot");
+2 -3
View File
@@ -37,7 +37,7 @@ pub fn client_capabilities() -> Value {
"fileOperations": { "dynamicRegistration": true, "willRename": true, "didRename": true },
"configuration": true,
"didChangeConfiguration": { "dynamicRegistration": false },
"didChangeWatchedFiles": { "dynamicRegistration": true, "relativePatternSupport": true },
"didChangeWatchedFiles": { "dynamicRegistration": false, "relativePatternSupport": false },
"symbol": {
"dynamicRegistration": false,
"symbolKind": { "valueSet": symbol_kinds },
@@ -48,7 +48,7 @@ pub fn client_capabilities() -> Value {
"semanticTokens": { "refreshSupport": true },
"codeLens": { "refreshSupport": true },
"inlayHint": { "refreshSupport": true },
"diagnostics": { "refreshSupport": true },
"diagnostics": { "refreshSupport": false },
"textDocumentContent": { "dynamicRegistration": true }
},
"textDocument": {
@@ -130,7 +130,6 @@ pub fn client_capabilities() -> Value {
"prepareSupportDefaultBehavior": 1,
"honorsChangeAnnotations": false
},
"diagnostic": { "dynamicRegistration": false, "relatedDocumentSupport": false },
"publishDiagnostics": {
"relatedInformation": true,
"tagSupport": { "valueSet": [1, 2] },
+1 -1
View File
@@ -59,7 +59,7 @@ fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit:
}
let Ok(ignores) = ignores.build() else { return Vec::new() };
let mut walk = ignore::WalkBuilder::new(root);
walk.require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false).filter_entry(move |entry| {
walk.require_git(false).follow_links(false).filter_entry(move |entry| {
if entry.depth() == 0 {
return true;
}
-150
View File
@@ -1,150 +0,0 @@
//! `workspace/didChangeWatchedFiles` registrations: which changes on disk a
//! server asked to hear of (its globs, relative patterns and change kinds).
use crate::uri;
use globset::{GlobBuilder, GlobMatcher};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `FileChangeType`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
Created = 1,
Changed = 2,
Deleted = 3,
}
impl Change {
/// The `WatchKind` bit.
fn kind_bit(self) -> u64 {
match self {
Self::Created => 1,
Self::Changed => 2,
Self::Deleted => 4,
}
}
}
struct Watcher {
/// Relative pattern's base; `None` for a plain glob, matched below the server's root.
base: Option<PathBuf>,
/// A plain glob that is an absolute path pattern.
absolute: bool,
glob: GlobMatcher,
kind: u64,
}
/// A server's registrations, by registration id.
#[derive(Default)]
pub struct FileWatchers(BTreeMap<String, Vec<Watcher>>);
impl FileWatchers {
pub fn register(&mut self, id: &str, options: Option<&Value>) {
let watchers = options
.and_then(|o| o.get("watchers"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(parse)
.collect();
self.0.insert(id.to_owned(), watchers);
}
pub fn unregister(&mut self, id: &str) {
self.0.remove(id);
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Whether a server rooted at `root` wants to hear of `change` to `path`.
pub fn wants(&self, root: &Path, path: &Path, change: Change) -> bool {
self.0.values().flatten().any(|w| {
if w.kind & change.kind_bit() == 0 {
return false;
}
match &w.base {
Some(base) => path.strip_prefix(base).is_ok_and(|rel| w.glob.is_match(rel)),
None if w.absolute => w.glob.is_match(path),
None => path.strip_prefix(root).is_ok_and(|rel| w.glob.is_match(rel)),
}
})
}
/// Bases of relative patterns, which may lie outside the project (a virtualenv's
/// `site-packages`, a path dependency): the client watches them for the server.
pub fn bases(&self) -> impl Iterator<Item = &Path> {
self.0.values().flatten().filter_map(|w| w.base.as_deref())
}
}
fn parse(watcher: &Value) -> Option<Watcher> {
let kind = watcher.get("kind").and_then(Value::as_u64).unwrap_or(7);
let (base, pattern) = match watcher.get("globPattern")? {
Value::String(p) => (None, p.as_str()),
relative => {
let base = relative.get("baseUri")?;
// A `WorkspaceFolder` or a URI.
let base = base.get("uri").unwrap_or(base).as_str()?;
let base = uri::uri_to_path(base)?;
// Changes are reported for canonical paths (`/tmp` is `/private/tmp` on macOS).
(Some(dunce::canonicalize(&base).unwrap_or(base)), relative.get("pattern")?.as_str()?)
}
};
let glob = GlobBuilder::new(pattern).literal_separator(true).build().ok()?.compile_matcher();
let absolute = base.is_none() && Path::new(pattern).is_absolute();
Some(Watcher { base, absolute, glob, kind })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn watchers(list: Value) -> FileWatchers {
let mut w = FileWatchers::default();
w.register("1", Some(&json!({ "watchers": list })));
w
}
#[test]
fn matches_globs_relative_patterns_and_kinds() {
let root = Path::new("/p");
let venv = uri::path_to_uri(Path::new("/opt/venv/lib/site-packages"));
let w = watchers(json!([
{ "globPattern": "**/*.{go,mod,sum}" },
{ "globPattern": "**/pyrightconfig.json", "kind": 2 },
{ "globPattern": { "baseUri": venv, "pattern": "**" } },
{ "globPattern": { "baseUri": { "uri": uri::path_to_uri(Path::new("/shared")), "name": "s" }, "pattern": "*.toml" } },
]));
let cases = [
("/p/go.mod", Change::Changed, true),
("/p/sub/main.go", Change::Created, true),
("/elsewhere/main.go", Change::Created, false),
("/p/pyrightconfig.json", Change::Changed, true),
("/p/pyrightconfig.json", Change::Created, false),
("/opt/venv/lib/site-packages/idna/__init__.py", Change::Created, true),
("/opt/venv/lib/site-packages", Change::Deleted, true),
("/shared/Cargo.toml", Change::Changed, true),
("/shared/nested/Cargo.toml", Change::Changed, false),
("/p/readme.md", Change::Changed, false),
];
for (path, change, expected) in cases {
assert_eq!(w.wants(root, Path::new(path), change), expected, "{path} {change:?}");
}
let mut bases: Vec<_> = w.bases().collect();
bases.sort();
assert_eq!(bases, [Path::new("/opt/venv/lib/site-packages"), Path::new("/shared")]);
}
#[test]
fn unregistering_drops_the_watchers() {
let mut w = watchers(json!([{ "globPattern": "**" }]));
assert!(w.wants(Path::new("/p"), Path::new("/p/a"), Change::Created));
w.unregister("1");
assert!(w.is_empty());
assert!(!w.wants(Path::new("/p"), Path::new("/p/a"), Change::Created));
}
}
+15 -35
View File
@@ -130,8 +130,15 @@ impl ClientHandler {
{
return;
}
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
let conv = mgr.conv(self.encoding());
let items = params
.diagnostics
.iter()
.cloned()
.map(|d| convert::diagnostic(&conv, &path, d, &self.server.name))
.collect();
self.server.set_diagnostics(path.clone(), params.diagnostics);
mgr.sink().emit(IdeEvent::Diagnostics { path: path.into(), source: self.server.name.clone(), items });
}
fn show_message(&self, mgr: &LspManager, params: &Value) {
@@ -199,33 +206,19 @@ impl ClientHandler {
Ok(reply)
}
fn registration(&self, mgr: &LspManager, params: &Value, register: bool) {
let Some(run) = self.server.initialized_gen(self.generation) else { return };
fn registration(&self, params: &Value, register: bool) {
let Some(run) = self.server.running_gen(self.generation) else { return };
let key = if register { "registrations" } else { "unregisterations" };
let list = params.get(key).or_else(|| params.get("unregistrations")).and_then(Value::as_array);
let mut caps = run.caps.write();
let mut watchers_changed = false;
for reg in list.into_iter().flatten() {
let Some(method) = reg.get("method").and_then(Value::as_str) else { continue };
if method == "workspace/didChangeWatchedFiles" {
let id = reg.get("id").and_then(Value::as_str).unwrap_or_default();
let mut watchers = run.watchers.write();
if register {
watchers.register(id, reg.get("registerOptions"));
} else {
watchers.unregister(id);
}
watchers_changed = true;
} else if register {
if register {
caps.register(method, reg.get("registerOptions"));
} else {
caps.unregister(method);
}
}
drop(caps);
if watchers_changed {
mgr.file_watch_roots_changed();
}
}
}
@@ -285,11 +278,11 @@ impl Handler for ClientHandler {
"workspace/configuration" => Ok(self.configuration(&params)),
"workspace/workspaceFolders" => Ok(mgr.workspace_folders(&self.server)),
"client/registerCapability" => {
self.registration(&mgr, &params, true);
self.registration(&params, true);
Ok(Value::Null)
}
"client/unregisterCapability" => {
self.registration(&mgr, &params, false);
self.registration(&params, false);
Ok(Value::Null)
}
"workspace/applyEdit" => self.apply_edit(&mgr, params),
@@ -302,23 +295,10 @@ impl Handler for ClientHandler {
mgr.sink().emit(IdeEvent::LspRefresh { server: self.server.id, kind });
Ok(Value::Null)
}
"workspace/diagnostic/refresh" => {
mgr.refresh_pulled_diagnostics(&self.server);
Ok(Value::Null)
}
"workspace/inlineValue/refresh" => Ok(Value::Null),
"workspace/diagnostic/refresh" | "workspace/inlineValue/refresh" => Ok(Value::Null),
other => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {other}"))),
}
}
fn after_response(&self, method: &str) {
if method == "workspace/configuration"
&& self.server.config.resync_on_configuration
&& let Some(mgr) = self.mgr.upgrade()
{
mgr.resync_documents(&self.server, self.generation);
}
}
}
/// What `workspace/configuration` sections are looked up in: the server's
+2
View File
@@ -6,6 +6,8 @@
//! (`textDocument/diagnostic`). The others are waited on: the first
//! `publishDiagnostics` for the file after it was opened, then a short quiet
//! period so follow-up publishes (syntax first, semantics later) are caught.
//! The client never advertises pull support, so servers keep pushing for the
//! editor as before.
use crate::convert;
use crate::server::Server;
+1 -1
View File
@@ -1,5 +1,5 @@
//! Pull Members Up / Push Members Down through the app-owned JDT extension
//! (`java/java-usages`). The server checks the move and computes the edit; the UI
//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI
//! shows its problems and applies the edit itself.
use super::COMMAND_TIMEOUT;
use crate::types::{HierarchyChoice, HierarchyMembers, HierarchyRefactored, HierarchyRefactoring, RefactoringProblem};
+13 -160
View File
@@ -12,18 +12,15 @@ mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod handler;
mod inspect;
pub mod jdtls;
mod launch;
mod library_source;
mod pull;
mod scope;
mod server;
mod tcp;
mod text;
mod ts_file_moves;
mod tsserver_bridge;
pub mod types;
mod uri;
@@ -163,9 +160,6 @@ impl DocSnap {
}
}
/// Receives the folders outside the project that servers want watched.
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
pub struct LspManager {
me: Weak<LspManager>,
project_root: PathBuf,
@@ -183,7 +177,6 @@ pub struct LspManager {
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
java_usages_warned: AtomicBool,
@@ -343,7 +336,6 @@ impl LspManager {
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
excludes_hook: RwLock::new(None),
watch_roots_hook: RwLock::new(None),
shutting_down: AtomicBool::new(false),
execution_allowed: AtomicBool::new(true),
java_usages_warned: AtomicBool::new(false),
@@ -434,7 +426,6 @@ impl LspManager {
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
let mut opened = Vec::new();
{
let mut docs = self.docs.lock();
for (id, servers) in wanted {
@@ -449,7 +440,6 @@ impl LspManager {
user_did_open(server, &run, &d.path, &d.uri, d.did_open_params());
}
attached.opened_gen = Some(run.generation);
opened.push((id, server.clone()));
}
d.servers.push(attached);
if !start.iter().any(|s: &Arc<Server>| s.id == server.id) {
@@ -460,9 +450,6 @@ impl LspManager {
d.servers.sort_by_key(|a| servers.iter().position(|s| s.id == a.server.id).unwrap_or(usize::MAX));
}
}
for (doc, server) in opened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
for server in &start {
self.ensure_started(server);
}
@@ -600,9 +587,6 @@ impl LspManager {
return;
}
};
let run =
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
server.set_initializing(Some(run.clone()));
if let Err(e) = peer.notify("initialized", json!({})) {
tracing::warn!("{}: {e}", server.name);
}
@@ -614,34 +598,29 @@ impl LspManager {
{
tracing::debug!("{}: didChangeConfiguration: {e}", server.name);
}
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
self.refresh_external_project(&server, &run).await;
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
let mut reopened = Vec::new();
{
let mut docs = self.docs.lock();
if !server.mark_ready(generation, run.clone()) {
drop(docs);
server.set_initializing(None);
peer.kill();
return;
}
let open_close = run.caps.read().open_close;
for (id, d) in docs.iter_mut() {
for d in docs.values_mut() {
let params = d.did_open_params();
for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) {
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
tracing::debug!("{}: didOpen: {e}", server.name);
}
a.opened_gen = Some(generation);
reopened.push(*id);
}
}
}
for doc in reopened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
@@ -665,7 +644,6 @@ impl LspManager {
None => return, // superseded instance
};
self.clear_server_state(&server);
self.file_watch_roots_changed();
if intentional {
server.announce_down();
return;
@@ -865,7 +843,6 @@ impl LspManager {
}
docs.insert(doc, state);
}
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
@@ -943,30 +920,6 @@ impl LspManager {
tracing::debug!("{}: didChange: {e}", server.name);
}
}
drop(docs);
self.schedule_pull(doc, None, pull::AFTER_EDIT);
}
/// Sends the documents open on instance `generation` of `server` again, as a `didChange`
/// with their unchanged text, so the server parses them with the settings it just read.
pub(crate) fn resync_documents(&self, server: &Server, generation: u64) {
let Some(run) = server.running_gen(generation) else { return };
if run.caps.read().sync == SyncKind::None {
return;
}
let docs = self.docs.lock();
for d in docs.values() {
if !d.servers.iter().any(|a| a.server.id == server.id && a.opened_gen == Some(generation)) {
continue;
}
let params = json!({
"textDocument": { "uri": d.uri, "version": d.version },
"contentChanges": [{ "text": d.text.as_str() }],
});
if let Err(e) = run.peer.notify("textDocument/didChange", params) {
tracing::debug!("{}: didChange: {e}", server.name);
}
}
}
pub fn did_save(&self, doc: DocId, text: &str) {
@@ -989,15 +942,11 @@ impl LspManager {
tracing::debug!("{}: didSave: {e}", a.server.name);
}
}
drop(docs);
self.schedule_pull(doc, None, Duration::ZERO);
self.pull_dependents(doc);
}
pub fn did_close(&self, doc: DocId) {
let Some(d) = self.docs.lock().remove(&doc) else { return };
for a in &d.servers {
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
let Some(run) = a.server.running() else { continue };
if a.opened_gen == Some(run.generation)
&& run.caps.read().open_close
@@ -1693,20 +1642,6 @@ impl LspManager {
Ok(())
}
/// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
/// `_typescript.didOrganizeImports`): no server provides those.
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
};
self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name))
.await
.iter()
.any(|t| server.is_none_or(|s| t.server.id == s))
}
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
let snap = self.snapshot(doc)?;
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
@@ -1725,10 +1660,6 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
// Nothing to ask for a command its server doesn't provide (executing it decides).
if !self.provides_command(doc, Some(server), &command.command).await {
return Ok(None);
}
let t = self.command_target(doc, server, &command.command).await?;
let interactive = t.server.name == "gopls"
&& t.caps()
@@ -2011,30 +1942,7 @@ impl LspManager {
servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect();
let mut out = FileRenameEdits::default();
for t in targets {
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else {
if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) {
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
for (from, to, _) in &moves {
let r: IdeResult<Value> = self
.call(
&t,
"workspace/executeCommand",
ts_file_moves::request(from, to),
Some(&cancel),
LONG_TIMEOUT,
)
.await;
match r {
Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files),
Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e),
Err(e) => out.errors.push(e.message),
}
}
drop(opened);
}
continue;
};
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue };
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
@@ -2065,78 +1973,29 @@ impl LspManager {
}
}
/// Files that changed on disk, outside the editor too (a checkout, another tool), sent to
/// running servers through `workspace/didChangeWatchedFiles`. A server that registered file
/// watchers hears of what they match, including `excluded_dirs` (folders in excluded or
/// ignored trees whose entries came or went, reported as created): a package installed
/// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other
/// servers hear of their languages' files under their root; without it jdtls's incremental
/// build never sees them, so a launch runs stale classes.
pub fn watched_files_changed(
&self,
created: &[PathBuf],
modified: &[PathBuf],
removed: &[PathBuf],
excluded_dirs: &[PathBuf],
) {
use file_watch::Change;
/// Files that changed on disk, outside the editor too (a checkout, another tool): each running
/// server hears of those of its languages under its root through `workspace/didChangeWatchedFiles`.
/// Without it jdtls's incremental build never sees them, so a launch runs stale classes.
pub fn watched_files_changed(&self, created: &[PathBuf], modified: &[PathBuf], removed: &[PathBuf]) {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for s in servers {
let Some(run) = s.running() else { continue };
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
let languages = s.languages();
let changes: Vec<Value> = [(created, 1), (modified, 2), (removed, 3)]
.into_iter()
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
let watchers = run.watchers.read();
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
let languages = s.languages();
all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.collect()
} else {
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
.collect()
};
drop(watchers);
.flat_map(|(paths, kind)| paths.iter().map(move |p| (p, kind)))
.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.map(|(p, kind)| json!({ "uri": uri::path_to_uri(p), "type": kind }))
.collect();
if changes.is_empty() {
continue;
}
let changes: Vec<Value> =
changes.iter().map(|(p, c)| json!({ "uri": uri::path_to_uri(p), "type": *c as u8 })).collect();
if let Err(e) = run.peer.notify("workspace/didChangeWatchedFiles", json!({ "changes": changes })) {
tracing::debug!("{}: didChangeWatchedFiles: {e}", s.name);
}
}
}
/// Called with the folders outside the project that servers registered file watchers
/// for (a virtualenv elsewhere, a path dependency), whenever they change. Changes
/// below them go to [`Self::watched_files_changed`].
pub fn set_file_watch_roots_hook(&self, hook: Box<WatchRootsFn>) {
*self.watch_roots_hook.write() = Some(Arc::from(hook));
self.file_watch_roots_changed();
}
pub(crate) fn file_watch_roots_changed(&self) {
let Some(hook) = self.watch_roots_hook.read().clone() else { return };
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let mut roots: Vec<PathBuf> = Vec::new();
for run in servers.iter().filter_map(|s| s.instance()) {
roots.extend(
run.watchers.read().bases().filter(|b| !b.starts_with(&self.project_root)).map(Path::to_path_buf),
);
}
roots.sort();
roots.dedup();
// Sorted, so a folder's subfolders follow it.
let mut outer: Vec<PathBuf> = Vec::new();
for r in roots {
if !outer.last().is_some_and(|o| r.starts_with(o)) {
outer.push(r);
}
}
hook(outer);
}
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
let snap = self.snapshot(doc)?;
@@ -2592,12 +2451,6 @@ impl LspManager {
}
}
/// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers).
pub fn reconfigure_soon(self: &Arc<Self>) {
let me = self.clone();
self.runtime.spawn(async move { me.reconfigure().await });
}
pub async fn reconfigure(&self) {
if !self.execution_allowed.load(Ordering::Acquire) {
let servers = self.servers.lock().clone();
-192
View File
@@ -1,192 +0,0 @@
//! Pull diagnostics (`textDocument/diagnostic`). Servers that provide them may stop pushing
//! their own analysis to a client that can pull (rust-analyzer pushes only `cargo check`
//! then): the editor pulls an open file's diagnostics when it opens, after edits and saves,
//! and when the server asks for a refresh. Pulled diagnostics are kept apart from pushed ones
//! and shown together.
use crate::server::{Running, Server};
use crate::{LspManager, convert};
use ide_core::{DocId, IdeEvent};
use ide_jsonrpc::RpcError;
use lsp_types as lsp;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
/// How long typing must pause before a changed file is pulled again.
pub(crate) const AFTER_EDIT: Duration = Duration::from_millis(300);
const TIMEOUT: Duration = Duration::from_secs(30);
/// Retries of a pull the server cancelled (`ServerCancelled`, e.g. while indexing).
const RETRIES: u32 = 20;
struct PullTarget {
server: Arc<Server>,
run: Running,
path: PathBuf,
uri: lsp::Uri,
}
fn provides_pull(run: &Running) -> bool {
let caps = run.caps.read();
caps.restriction.diagnostics() && caps.has("diagnosticProvider")
}
fn inter_file(run: &Running) -> bool {
run.caps.read().raw.pointer("/diagnosticProvider/interFileDependencies").and_then(Value::as_bool) == Some(true)
}
impl LspManager {
/// Pulls `doc`'s diagnostics from its servers that provide them (`only`: from that one)
/// after `delay`; a later pull of the same file supersedes it.
pub(crate) fn schedule_pull(&self, doc: DocId, only: Option<&Server>, delay: Duration) {
let targets: Vec<PullTarget> = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc) else { return };
d.servers
.iter()
.filter(|a| only.is_none_or(|s| s.id == a.server.id))
.filter_map(|a| {
let run = a.server.running()?;
(a.opened_gen == Some(run.generation) && provides_pull(&run)).then(|| PullTarget {
server: a.server.clone(),
run,
path: d.path.clone(),
uri: d.uri.clone(),
})
})
.collect()
};
for t in targets {
if t.server.suppresses_diagnostics(&t.path) {
continue;
}
let pull = t.server.next_pull(&t.path);
let me = self.me.clone();
self.runtime.spawn(async move {
tokio::time::sleep(delay).await;
if let Some(me) = me.upgrade() {
me.pull(t, pull).await;
}
});
}
}
/// Pulls every open file of `server` again (`workspace/diagnostic/refresh`).
pub(crate) fn refresh_pulled_diagnostics(&self, server: &Server) {
let docs: Vec<DocId> = self
.docs
.lock()
.iter()
.filter(|(_, d)| d.servers.iter().any(|a| a.server.id == server.id))
.map(|(id, _)| *id)
.collect();
for doc in docs {
self.schedule_pull(doc, Some(server), Duration::ZERO);
}
}
/// After `doc` was saved: servers whose diagnostics depend on other files pull those
/// open files too.
pub(crate) fn pull_dependents(&self, doc: DocId) {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc) else { return };
let servers: Vec<Arc<Server>> = d
.servers
.iter()
.filter(|a| a.server.running().is_some_and(|run| provides_pull(&run) && inter_file(&run)))
.map(|a| a.server.clone())
.collect();
let others: Vec<(DocId, Vec<Arc<Server>>)> = docs
.iter()
.filter(|(id, _)| **id != doc)
.map(|(id, o)| {
(*id, servers.iter().filter(|s| o.servers.iter().any(|a| a.server.id == s.id)).cloned().collect())
})
.collect();
drop(docs);
for (other, servers) in others {
for server in servers {
self.schedule_pull(other, Some(&server), Duration::ZERO);
}
}
}
/// Drops what was pulled for a closed file and shows what remains.
pub(crate) fn forget_pulled(&self, server: &Server, path: &std::path::Path, run: Option<&Running>) {
if let Some(shown) = server.clear_pulled(path) {
let enc = run.map(|r| r.caps.read().encoding).unwrap_or_default();
self.emit_diagnostics(server, path.to_owned(), shown, enc);
}
}
async fn pull(&self, t: PullTarget, pull: u64) {
let mut attempt = 0;
loop {
if !t.server.is_latest_pull(&t.path, pull) {
return;
}
let mut params = json!({ "textDocument": { "uri": t.uri } });
if let Some(id) = t.server.pulled_result_id(&t.path) {
params["previousResultId"] = json!(id);
}
if let Some(identifier) = t.run.caps.read().raw.pointer("/diagnosticProvider/identifier") {
params["identifier"] = identifier.clone();
}
let request = t.run.peer.request::<Value, Value>("textDocument/diagnostic", params, None);
let report = match tokio::time::timeout(TIMEOUT, request).await {
Ok(Ok(report)) => report,
// This request has no cancellation of its own: the server cancelled it
// (`ServerCancelled` while busy, which asks to be asked again).
Ok(Err(RpcError::Cancelled)) if attempt < RETRIES => {
attempt += 1;
tokio::time::sleep(Duration::from_millis(250 * u64::from(attempt.min(8)))).await;
continue;
}
Ok(Err(e)) => {
tracing::debug!("{}: textDocument/diagnostic: {e}", t.server.name);
return;
}
Err(_) => {
tracing::debug!("{}: textDocument/diagnostic timed out", t.server.name);
return;
}
};
// A newer pull, a closed file or a restarted server makes this report stale.
if !t.server.is_latest_pull(&t.path, pull)
|| t.server.running().is_none_or(|r| r.generation != t.run.generation)
|| !self.docs.lock().values().any(|d| d.path == t.path)
{
return;
}
let result_id = report.get("resultId").and_then(Value::as_str).map(str::to_owned);
let items = match report.get("kind").and_then(Value::as_str) {
Some("unchanged") => None,
_ => match serde_json::from_value(report.get("items").cloned().unwrap_or(json!([]))) {
Ok(items) => Some(items),
Err(e) => {
tracing::debug!("{}: bad textDocument/diagnostic report: {e}", t.server.name);
return;
}
},
};
let shown = t.server.set_pulled(t.path.clone(), result_id, items);
let enc = t.run.caps.read().encoding;
self.emit_diagnostics(&t.server, t.path, shown, enc);
return;
}
}
/// Shows `items` as `server`'s diagnostics for `path`.
pub(crate) fn emit_diagnostics(
&self,
server: &Server,
path: PathBuf,
items: Vec<lsp::Diagnostic>,
enc: crate::text::Encoding,
) {
let conv = self.conv(enc);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
}
+6 -109
View File
@@ -27,8 +27,6 @@ pub struct Running {
pub generation: u64,
pub peer: Peer,
pub caps: Arc<RwLock<ServerCaps>>,
/// `workspace/didChangeWatchedFiles` registrations.
pub watchers: Arc<RwLock<crate::file_watch::FileWatchers>>,
}
pub enum Conn {
@@ -84,9 +82,6 @@ pub struct Server {
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// The instance being initialized, from `initialized` until ready: registrations
/// sent right after `initialized` must not be lost.
initializing: Mutex<Option<Running>>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
start_cancel: Mutex<CancellationToken>,
phase: watch::Sender<Phase>,
@@ -97,13 +92,7 @@ pub struct Server {
started: Mutex<Option<Instant>>,
stderr: Mutex<VecDeque<String>>,
pub progress: Mutex<HashMap<String, Progress>>,
/// What the editor shows per path: the pushed and the pulled diagnostics together.
diagnostics: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
/// `publishDiagnostics` per path.
pushed: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
/// `textDocument/diagnostic` reports per path, and the latest pull asked for each.
pulled: Mutex<HashMap<PathBuf, Pulled>>,
pulls: Mutex<HashMap<PathBuf, u64>>,
/// When diagnostics were last published per path (empty lists included).
published: Mutex<HashMap<PathBuf, Instant>>,
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
@@ -129,7 +118,6 @@ impl Server {
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
initializing: Mutex::new(None),
start_cancel: Mutex::new(CancellationToken::new()),
phase: watch::channel(Phase::Down).0,
status: Mutex::new((ServerStatus::Stopped, None)),
@@ -138,9 +126,6 @@ impl Server {
stderr: Mutex::new(VecDeque::new()),
progress: Mutex::new(HashMap::new()),
diagnostics: Mutex::new(HashMap::new()),
pushed: Mutex::new(HashMap::new()),
pulled: Mutex::new(HashMap::new()),
pulls: Mutex::new(HashMap::new()),
published: Mutex::new(HashMap::new()),
published_notify: Notify::new(),
idle_notify: Notify::new(),
@@ -225,8 +210,6 @@ impl Server {
return false;
}
st.conn = Conn::Ready(run);
drop(st);
self.set_initializing(None);
true
}
@@ -251,20 +234,6 @@ impl Server {
}
}
/// The instance told `initialized`, ready or not.
pub fn instance(&self) -> Option<Running> {
self.running().or_else(|| self.initializing.lock().clone())
}
/// Generation `generation` once it was told `initialized`, ready or not.
pub fn initialized_gen(&self, generation: u64) -> Option<Running> {
self.instance().filter(|r| r.generation == generation)
}
pub fn set_initializing(&self, run: Option<Running>) {
*self.initializing.lock() = run;
}
/// The running instance if it is still generation `generation`.
pub fn running_gen(&self, generation: u64) -> Option<Running> {
self.running().filter(|r| r.generation == generation)
@@ -502,71 +471,17 @@ impl Server {
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
/// Records the diagnostics the server pushed for `path`; returns what the file now shows.
pub fn set_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
set_or_remove(&mut self.pushed.lock(), path.clone(), items);
self.combine(path)
}
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows.
pub fn set_pulled(
&self,
path: PathBuf,
result_id: Option<String>,
items: Option<Vec<lsp::Diagnostic>>,
) -> Vec<lsp::Diagnostic> {
{
let mut pulled = self.pulled.lock();
let entry = pulled.entry(path.clone()).or_default();
entry.result_id = result_id;
if let Some(items) = items {
entry.items = items;
}
}
self.combine(path)
}
/// Forgets the pulled report of a closed file; `None` when there was none.
pub fn clear_pulled(&self, path: &Path) -> Option<Vec<lsp::Diagnostic>> {
self.pulls.lock().remove(path);
self.pulled.lock().remove(path)?;
Some(self.combine(path.to_owned()))
}
/// The `resultId` of the last report pulled for `path`.
pub fn pulled_result_id(&self, path: &Path) -> Option<String> {
self.pulled.lock().get(path).and_then(|p| p.result_id.clone())
}
/// Starts a pull for `path`, superseding earlier ones; see [`Server::is_latest_pull`].
pub fn next_pull(&self, path: &Path) -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.pulls.lock().insert(path.to_owned(), id);
id
}
pub fn is_latest_pull(&self, path: &Path, id: u64) -> bool {
self.pulls.lock().get(path) == Some(&id)
}
fn combine(&self, path: PathBuf) -> Vec<lsp::Diagnostic> {
let mut items = self.pushed.lock().get(&path).cloned().unwrap_or_default();
if let Some(pulled) = self.pulled.lock().get(&path) {
for d in &pulled.items {
if !items.contains(d) {
items.push(d.clone());
}
}
}
pub fn set_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) {
{
let mut d = self.diagnostics.lock();
self.published.lock().insert(path.clone(), Instant::now());
set_or_remove(&mut d, path, items.clone());
if items.is_empty() {
d.remove(&path);
} else {
d.insert(path, items);
}
}
self.published_notify.notify_waiters();
items
}
/// When diagnostics for `path` were last published by this server.
@@ -591,28 +506,10 @@ impl Server {
pub fn take_diagnostic_paths(&self) -> Vec<PathBuf> {
self.published.lock().clear();
self.pushed.lock().clear();
self.pulled.lock().clear();
self.pulls.lock().clear();
self.diagnostics.lock().drain().map(|(p, _)| p).collect()
}
}
/// A file's last `textDocument/diagnostic` report.
#[derive(Default)]
struct Pulled {
result_id: Option<String>,
items: Vec<lsp::Diagnostic>,
}
fn set_or_remove(map: &mut HashMap<PathBuf, Vec<lsp::Diagnostic>>, path: PathBuf, items: Vec<lsp::Diagnostic>) {
if items.is_empty() {
map.remove(&path);
} else {
map.insert(path, items);
}
}
/// The last `n` stderr lines, led by the exception headline: Node.js / Python /
/// JVM crashes print the error first and a stack after it, so a plain tail
/// shows frames without saying what went wrong.
-89
View File
@@ -1,89 +0,0 @@
//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`:
//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The
//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through
//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's
//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`).
use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::PathBuf;
pub(crate) const COMMAND: &str = "typescript.tsserverRequest";
/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`.
pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value {
json!({
"command": COMMAND,
"arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }],
})
}
#[derive(Deserialize)]
struct Location {
line: u32,
offset: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Change {
start: Location,
end: Location,
new_text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FileChanges {
file_name: PathBuf,
text_changes: Vec<Change>,
}
/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit.
pub(crate) fn edit(result: &Value) -> WorkspaceEdit {
let body = result.get("body").unwrap_or(result);
let files: Vec<FileChanges> = serde_json::from_value(body.clone()).unwrap_or_default();
let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1));
WorkspaceEdit {
files: files
.into_iter()
.filter(|f| !f.text_changes.is_empty())
.map(|f| FileEdit::Edit {
path: f.file_name.into(),
edits: f
.text_changes
.into_iter()
.map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text })
.collect(),
version: None,
})
.collect(),
preconditions: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_tsserver_file_code_edits() {
let result = json!({ "success": true, "body": [
{ "fileName": "/p/src/index.ts", "textChanges": [
{ "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" }
] },
{ "fileName": "/p/src/unchanged.ts", "textChanges": [] }
] });
let edit = edit(&result);
assert_eq!(edit.files.len(), 1);
let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") };
assert_eq!(**path, *std::path::Path::new("/p/src/index.ts"));
assert_eq!(
edits,
&[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }]
);
assert!(version.is_none());
assert!(super::edit(&json!({ "success": false })).files.is_empty());
}
}
+1 -271
View File
@@ -31,8 +31,6 @@ struct MockState {
errors: Mutex<HashMap<String, (i64, String)>>,
/// Computes a response from the request and the open documents (before `responses`).
responder: Mutex<Option<Responder>>,
/// Holds the handling of notifications of this method until the gate opens.
held: Mutex<Option<(String, Arc<Gate>)>>,
peer: Mutex<Option<Peer>>,
}
@@ -76,12 +74,6 @@ struct MockHandler(Arc<MockState>);
impl Handler for MockHandler {
fn on_notification(&self, method: &str, params: Value) {
let s = &self.0;
let held = s.held.lock().clone();
if let Some((m, gate)) = held
&& m == method
{
gate.hold();
}
// Held throughout: a test that sees the effect (a document, the peer gone) also sees the record.
let mut log = s.notifications.lock();
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
@@ -238,7 +230,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
}
}
@@ -278,7 +269,6 @@ fn harness_configured(
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
@@ -429,7 +419,6 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
&[created.clone(), other],
&[modified.clone(), outside],
std::slice::from_ref(&removed),
&[h.root.join(".venv/lib/site-packages")],
);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
@@ -441,81 +430,12 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
])
);
// Nothing of its languages: no notification.
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[], &[]);
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[]);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1);
h.mgr.shutdown_all().await;
}
/// A server that registers file watchers hears of what they match, inside excluded folders
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
/// that relative patterns name are reported for watching; unregistered, it falls back to
/// its languages' files.
#[tokio::test(flavor = "multi_thread")]
async fn registered_file_watchers_choose_the_changes_a_server_hears_of() {
let h = harness(&[("mock", incremental_caps())]);
let roots = Arc::new(Mutex::new(Vec::<Vec<PathBuf>>::new()));
{
let roots = roots.clone();
h.mgr.set_file_watch_roots_hook(Box::new(move |r| roots.lock().push(r)));
}
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let outside = tempfile::tempdir().unwrap();
let outside = dunce::canonicalize(outside.path()).unwrap();
let external = outside.join("venv/lib/site-packages");
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [
{ "globPattern": "**/*.{mk,mod}" },
{ "globPattern": "**/pyrightconfig.json", "kind": 2 },
{ "globPattern": "**/site-packages/**" },
{ "globPattern": { "baseUri": uri_of(&external), "pattern": "**" } },
{ "globPattern": { "baseUri": uri_of(&h.root.join("sub")), "pattern": "**" } },
] } }] }),
None,
)
.await
.unwrap();
assert_eq!(roots.lock().last(), Some(&vec![external.clone()]), "only folders outside the project");
let sp = h.root.join(".venv/lib/python3.14/site-packages");
let notified = || s.notified("workspace/didChangeWatchedFiles");
h.mgr.watched_files_changed(
&[h.root.join("go.mod"), h.root.join("notes.txt"), h.root.join("pyrightconfig.json")],
&[h.root.join("pyrightconfig.json"), external.join("idna/core.py")],
&[h.root.join("old.mk")],
&[sp.join("idna"), h.root.join("node_modules/x")],
);
eventually(|| !notified().is_empty()).await;
assert_eq!(
notified()[0]["changes"],
json!([
{ "uri": uri_of(&h.root.join("go.mod")), "type": 1 },
{ "uri": uri_of(&h.root.join("pyrightconfig.json")), "type": 2 },
{ "uri": uri_of(&external.join("idna/core.py")), "type": 2 },
{ "uri": uri_of(&h.root.join("old.mk")), "type": 3 },
{ "uri": uri_of(&sp.join("idna")), "type": 1 },
])
);
s.peer()
.request::<_, Value>(
"client/unregisterCapability",
json!({ "unregisterations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles" }] }),
None,
)
.await
.unwrap();
assert_eq!(roots.lock().last(), Some(&vec![]));
h.mgr.watched_files_changed(&[h.root.join("go.mod"), h.root.join("b.mk")], &[], &[], &[sp.join("idna")]);
eventually(|| notified().len() == 2).await;
assert_eq!(notified()[1]["changes"], json!([{ "uri": uri_of(&h.root.join("b.mk")), "type": 1 }]));
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn initialize_and_incremental_sync() {
let h = harness(&[("mock", incremental_caps())]);
@@ -777,113 +697,6 @@ async fn diagnostics_become_events_and_feed_code_actions() {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
}
/// The diagnostics shown last for `path` by `source`, as their messages.
fn shown(h: &Harness, path: &Path, source: &str) -> Option<Vec<String>> {
h.events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path: p, source: s, items } if **p == *path && s == source => {
Some(items.iter().map(|d| d.message.clone()).collect())
}
_ => None,
})
}
/// Reports a "bad" diagnostic per line containing `bad`, as `textDocument/diagnostic`; answers
/// `unchanged` when the text is what `previousResultId` was computed from.
fn pull_responder() -> Responder {
Arc::new(|s: &MockState, method: &str, params: &Value| {
if method != "textDocument/diagnostic" {
return None;
}
let text = s.docs.lock().get(params["textDocument"]["uri"].as_str()?).cloned()?;
let id = format!("{}", text.len() * 31 + text.matches("bad").count());
if params["previousResultId"].as_str() == Some(id.as_str()) {
return Some(json!({ "kind": "unchanged", "resultId": id }));
}
let items: Vec<Value> = text
.lines()
.enumerate()
.filter(|(_, l)| l.contains("bad"))
.map(|(i, _)| {
json!({ "range": {"start":{"line":i,"character":0},"end":{"line":i,"character":3}},
"severity": 1, "message": "bad" })
})
.collect();
Some(json!({ "kind": "full", "resultId": id, "items": items }))
})
}
#[tokio::test(flavor = "multi_thread")]
async fn pulled_diagnostics_follow_edits_and_join_pushed_ones() {
let mut pull_caps = incremental_caps();
pull_caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness(&[("mock", pull_caps), ("push", incremental_caps())]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(pull_responder());
let (doc, path) = h.open("a.mk", "ok\nbad\n");
h.wait_status("mock", ServerStatus::Ready).await;
h.wait_status("push", ServerStatus::Ready).await;
assert!(s.requested("initialize")[0]["capabilities"]["textDocument"]["diagnostic"].is_object());
eventually(|| shown(&h, &path, "mock") == Some(vec!["bad".into()])).await;
assert!(h.launcher.state("push").requested("textDocument/diagnostic").is_empty(), "it pulls from providers only");
// What the server still pushes (rust-analyzer: `cargo check`) shows next to what was pulled.
let pushed = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":2}},
"severity": 2, "message": "pushed" });
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": [pushed] }))
.unwrap();
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into(), "bad".into()])).await;
// Edits are pulled again once typing pauses.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 0), "bad\n")], &|| unreachable!());
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into(), "bad".into(), "bad".into()])).await;
// Pulled diagnostics are code actions' context like pushed ones.
let _ = h.mgr.code_actions(doc, rng(2, 1, 2, 1), vec![]).await;
let ctx = &s.requested("textDocument/codeAction")[0]["context"]["diagnostics"];
assert_eq!(ctx.as_array().unwrap().iter().map(|d| d["message"].as_str().unwrap()).collect::<Vec<_>>(), ["bad"]);
// Saving unchanged text asks with the last result id; `unchanged` keeps what is shown.
let pulls = s.requested("textDocument/diagnostic").len();
h.mgr.did_save(doc, "bad\nok\nbad\n");
eventually(|| s.requested("textDocument/diagnostic").len() > pulls).await;
let last = s.requested("textDocument/diagnostic").last().cloned().unwrap();
assert!(last["previousResultId"].is_string());
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(shown(&h, &path, "mock"), Some(vec!["pushed".into(), "bad".into(), "bad".into()]));
// Closing drops what was pulled; the pushed diagnostics stay the server's to clear.
h.mgr.did_close(doc);
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into()])).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_cancelled_pull_is_retried_and_a_refresh_pulls_again() {
let mut caps = incremental_caps();
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(pull_responder());
// Busy indexing: the server cancels and asks to be asked again.
s.errors.lock().insert("textDocument/diagnostic".into(), (-32802, "indexing".into()));
let (_doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.requested("textDocument/diagnostic").len() >= 2).await;
assert_eq!(shown(&h, &path, "mock"), None);
s.errors.lock().clear();
eventually(|| shown(&h, &path, "mock") == Some(vec!["bad".into()])).await;
// `workspace/diagnostic/refresh` (e.g. after a project reload) pulls open files again.
*s.responder.lock() = Some(Arc::new(|_: &MockState, method: &str, _: &Value| {
(method == "textDocument/diagnostic").then(|| {
json!({ "kind": "full", "items": [
{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}}, "message": "reloaded" } ] })
})
}));
let refreshed: Value = s.peer().request("workspace/diagnostic/refresh", Value::Null, None).await.unwrap();
assert_eq!(refreshed, Value::Null);
eventually(|| shown(&h, &path, "mock") == Some(vec!["reloaded".into()])).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_edit_hook_and_server_requests() {
let h = harness(&[("mock", incremental_caps())]);
@@ -1227,54 +1040,6 @@ async fn preparation_refreshes_config_and_survives_unchanged_settings() {
assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 1);
}
/// Intelephense applies the PHP version only to documents it parses after reading its
/// configuration: once that is answered, servers that opt in get their open documents again.
#[tokio::test(flavor = "multi_thread")]
async fn configuration_answer_resyncs_open_documents_of_opted_in_servers() {
let h = harness_configured("mock", "mk", &[("mock", incremental_caps()), ("extra", incremental_caps())], |cfg| {
cfg.resync_on_configuration = cfg.name == "mock"
});
let (doc, path) = h.open("a.mk", "enum A {}");
let uri = uri_of(&path);
let (s, extra) = (h.launcher.state("mock"), h.launcher.state("extra"));
h.wait_status("mock", ServerStatus::Ready).await;
h.wait_status("extra", ServerStatus::Ready).await;
eventually(|| s.docs.lock().contains_key(&uri) && extra.docs.lock().contains_key(&uri)).await;
h.mgr.did_change(doc, 2, &[change(rng(0, 9, 0, 9), "\n")], &|| unreachable!());
eventually(|| s.notified("textDocument/didChange").len() == 1).await;
let items = |section: &str| json!({ "items": [{ "section": section }] });
let options = json!([{ "flag": true, "nested": { "deep": 1 } }]);
let answer: Value = extra.peer().request("workspace/configuration", items("extra"), None).await.unwrap();
assert_eq!(answer, options);
// The mock handles what arrives in order, answers included: a re-sync sent before the
// answer would hold it back here.
let gate = Arc::new(Gate::default());
*s.held.lock() = Some(("textDocument/didChange".into(), gate.clone()));
let peer = s.peer();
let request = peer.request::<_, Value>("workspace/configuration", items("mock"), None);
let answer = tokio::time::timeout(Duration::from_secs(10), request).await.expect("re-sync sent before the answer");
assert_eq!(answer.unwrap(), options);
eventually(|| gate.entered()).await;
assert_eq!(s.notified("textDocument/didChange").len(), 1);
gate.open();
eventually(|| s.notified("textDocument/didChange").len() == 2).await;
assert_eq!(
s.notified("textDocument/didChange")[1],
json!({ "textDocument": { "uri": uri, "version": 2 }, "contentChanges": [{ "text": "enum A {}\n" }] })
);
assert_eq!(s.docs.lock()[&uri], "enum A {}\n");
// A notification sent after both answers reaches the servers after anything they triggered.
h.mgr.did_save(doc, "enum A {}\n");
eventually(|| extra.notified("textDocument/didSave").len() == 1 && s.notified("textDocument/didSave").len() == 1)
.await;
assert_eq!(s.notified("textDocument/didChange").len(), 2, "one re-sync per answer");
let methods: Vec<String> = extra.notifications.lock().iter().map(|(m, _)| m.clone()).collect();
assert_eq!(methods, ["initialized", "textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"]);
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn settings_changes_reach_servers_without_restarts() {
let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]);
@@ -2336,41 +2101,6 @@ async fn will_and_did_rename_files_honour_filters() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
let money = h.root.join("money.ts");
let index = h.root.join("index.ts");
std::fs::write(&money, "export const one = 1;\n").unwrap();
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
let s = h.launcher.state("vtsls");
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
})
}));
let to = h.root.join("lib/money.ts");
let result =
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
assert!(result.errors.is_empty(), "{:?}", result.errors);
match &result.edit.files[..] {
[FileEdit::Edit { path, edits, version: None }] => {
assert_eq!(path, &index);
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
assert_eq!(edits[0].new_text, "./lib/money");
}
other => panic!("unexpected edit {other:?}"),
}
let asked = s.requested("workspace/executeCommand");
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
}
// ------------------------------------------------------------------ inspect code
struct InspectTestHost {
-4
View File
@@ -56,7 +56,6 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: vec![server],
debugger: None,
@@ -135,7 +134,6 @@ async fn missing_binary_is_reported_with_install_hint() {
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
@@ -405,7 +403,6 @@ async fn typescript_language_server_end_to_end() {
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
@@ -455,7 +452,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
}
}
+35 -97
View File
@@ -105,7 +105,6 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
@@ -468,6 +467,7 @@ const MAX_SYNTAX_ITEMS: usize = 200_000;
const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
use crate::types::ActionSource as S;
match e {
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
@@ -477,7 +477,14 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
action: a.action,
title: a.title,
source: a.source.name().to_owned(),
source: match a.source {
S::Keyboard => wit_events::ActionSource::Keyboard,
S::Menu => wit_events::ActionSource::Menu,
S::Toolbar => wit_events::ActionSource::Toolbar,
S::ContextMenu => wit_events::ActionSource::ContextMenu,
S::Search => wit_events::ActionSource::Search,
S::Other => wit_events::ActionSource::Other,
},
shortcut: a.shortcut,
}),
}
@@ -663,17 +670,9 @@ impl Runtime {
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
kind: i.kind.map(|k| clip(k, 32)),
insert_text: clip(i.insert_text, 64 << 10),
snippet: i.snippet,
replace: i.replace.map(range_from_wit),
sort_text: i.sort_text.map(|t| clip(t, 64)),
filter_text: i.filter_text.map(|t| clip(t, MAX_TITLE)),
documentation: i.documentation.map(|d| clip(d, 16 << 10)),
additional_edits: i
.additional_edits
.into_iter()
.take(MAX_ADDITIONAL_EDITS)
.map(edit_from_wit)
.collect(),
})
.collect())
}
@@ -700,20 +699,27 @@ impl Runtime {
) -> Result<Vec<CodeAction>, CallError> {
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
}
/// The edits of an action `provide_code_actions` returned with `data`.
pub fn resolve_code_action(
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
timeout: Duration,
) -> Result<CodeAction, CallError> {
let d = doc_to_wit(doc);
let a = code_action_to_wit(action);
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
Ok(actions
.into_iter()
.take(MAX_PROVIDER_ITEMS)
.map(|a| CodeAction {
title: clip(a.title, MAX_TITLE),
kind: a.kind.map(|k| clip(k, 64)),
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|f| {
let edits = f
.edits
.into_iter()
.map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text })
.collect();
(std::path::PathBuf::from(f.path), edits)
})
.collect(),
})
.collect())
}
pub fn provide_definition(
@@ -962,47 +968,6 @@ const MAX_VIEW_ITEMS: usize = 20_000;
const MAX_CELL: usize = 64 << 10;
const MAX_HTML: usize = 4 << 20;
/// Most additional edits of a completion item.
const MAX_ADDITIONAL_EDITS: usize = 100;
fn edit_from_wit(e: wit_types::TextEdit) -> ide_core::TextEdit {
ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }
}
fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
CodeAction {
title: clip(a.title, MAX_TITLE),
kind: a.kind.map(|k| clip(k, 64)),
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
data: a.data.map(|d| clip(d, 64 << 10)),
}
}
fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
wit_prov::CodeAction {
title: a.title.clone(),
kind: a.kind.clone(),
preferred: a.preferred,
edits: a
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: path.to_string_lossy().into_owned(),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
.collect(),
})
.collect(),
data: a.data.clone(),
}
}
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
@@ -1010,9 +975,6 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
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)),
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
@@ -1044,8 +1006,10 @@ fn view_from_wit(v: wit_vt::View) -> View {
fields
.into_iter()
.take(MAX_VIEW_ITEMS)
.filter_map(|f| {
let value = match f.value {
.map(|f| ViewField {
key: clip(f.key, 128),
label: clip(f.label, MAX_TITLE),
value: match f.value {
wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)),
wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)),
wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b),
@@ -1053,10 +1017,7 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
},
})
.collect(),
),
@@ -1106,26 +1067,3 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::Message { text } => W::Message(text),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
#[test]
fn bodies_and_fields_of_a_newer_api_degrade() {
let view = |body| wit_vt::View { toolbar: Vec::new(), body, status: None };
let shown = view_from_wit(view(wit_vt::Body::Other(ext("markdown"))));
assert_eq!(shown.body, ViewBody::Message("This view needs a newer version of OpenIDEA (markdown).".into()));
let field = |key: &str, value| wit_vt::Field { key: key.into(), label: key.into(), value };
let form = view_from_wit(view(wit_vt::Body::Form(vec![
field("color", wit_vt::FieldValue::Other(ext("color"))),
field("name", wit_vt::FieldValue::Text("n".into())),
])));
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
}
-31
View File
@@ -126,20 +126,6 @@ pub enum ActionSource {
Other,
}
impl ActionSource {
/// As plugins see it.
pub fn name(self) -> &'static str {
match self {
Self::Keyboard => "keyboard",
Self::Menu => "menu",
Self::Toolbar => "toolbar",
Self::ContextMenu => "context-menu",
Self::Search => "search",
Self::Other => "other",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ActionPerformed {
@@ -317,16 +303,6 @@ pub fn net_host(url: &str) -> Result<String, String> {
#[cfg(test)]
mod tests {
#[test]
fn the_restart_backoff_doubles_up_to_a_minute() {
let limits = Limits { restart_backoff: Duration::from_millis(150), ..Limits::default() };
let waits: Vec<u128> = (1..=4).map(|n| limits.backoff(n).as_millis()).collect();
assert_eq!(waits, [150, 300, 600, 1200]);
assert_eq!(limits.backoff(30), Duration::from_millis(150 * 64), "the factor stops growing");
let long = Limits { restart_backoff: Duration::from_secs(10), ..Limits::default() };
assert_eq!(long.backoff(5), Duration::from_secs(60));
}
use super::*;
#[test]
@@ -362,14 +338,9 @@ pub struct CompletionItem {
/// WIT `completion-kind` name (`method`, `keyword`, …).
pub kind: Option<String>,
pub insert_text: String,
/// `insert_text` has tab stops and placeholders.
pub snippet: bool,
pub replace: Option<ide_core::Range>,
pub sort_text: Option<String>,
pub filter_text: Option<String>,
pub documentation: Option<String>,
/// Applied with the item elsewhere in the document (an import).
pub additional_edits: Vec<ide_core::TextEdit>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -384,8 +355,6 @@ pub struct CodeAction {
pub kind: Option<String>,
pub preferred: bool,
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
/// The plugin's reference: the edits come from `resolve_code_action`.
pub data: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
+18 -26
View File
@@ -440,11 +440,20 @@ impl vcs::Host for StoreData {
}
}
fn kind_from_wit(k: events::EventKind) -> EventKind {
match k {
events::EventKind::FileOpened => EventKind::FileOpened,
events::EventKind::FileSaved => EventKind::FileSaved,
events::EventKind::EditorFocused => EventKind::EditorFocused,
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
events::EventKind::VcsChanged => EventKind::VcsChanged,
events::EventKind::Scheduled => EventKind::Scheduled,
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
};
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
let kind = kind_from_wit(kind);
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
@@ -454,10 +463,8 @@ impl events::Host for StoreData {
Ok(Ok(()))
}
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
if let Some(kind) = EventKind::parse(&kind) {
self.host.subscribe(kind, false);
}
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
self.host.subscribe(kind_from_wit(kind), false);
Ok(())
}
}
@@ -535,10 +542,10 @@ impl diagnostics::Host for StoreData {
diagnostics::Severity::Hint => ide_core::Severity::Hint,
},
message: clip(d.message, 4096),
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
source: Some(source.clone()),
code: d.code.map(|c| clip(c, 128)),
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
deprecated: d.tags.iter().any(|t| t == "deprecated"),
unnecessary: false,
deprecated: false,
server: Some(source.clone()),
})
.collect();
@@ -640,21 +647,6 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Binary file not shown.
@@ -1 +1 @@
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
+1 -1
View File
@@ -1,5 +1,5 @@
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md).
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
Binary file not shown.
@@ -1 +1 @@
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
-1
View File
@@ -12,7 +12,6 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
-2
View File
@@ -64,8 +64,6 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
Binary file not shown.
@@ -1 +1 @@
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
@@ -40,11 +40,3 @@ title = "insert"
[[contributes.commands]]
id = "cancel"
title = "cancel"
[[contributes.commands]]
id = "subscribe"
title = "subscribe"
[[contributes.commands]]
id = "diagnose"
title = "diagnose"
+4 -52
View File
@@ -14,9 +14,7 @@ wit_bindgen::generate!({
generate_all,
});
use exports::openidea::plugin::providers::{
self, CodeAction, CompletionItem, Document, FileEdit, InlineSuggestion, LineMarker,
};
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
use exports::openidea::plugin::{commands, event_handler, lifecycle};
use openidea::plugin::{invocation::Invocation, types};
@@ -69,23 +67,6 @@ impl commands::Guest for Misbehave {
}
// The user gave up: not the plugin failing.
"cancel" => Err(types::Error::Cancelled),
// Event kinds are names: one this IDE doesn't know is refused.
"subscribe" => openidea::plugin::events::subscribe(&arg.unwrap_or_default()).map(|()| None),
// Tagged problems from another tool on the line 0 of `arg`.
"diagnose" => {
use openidea::plugin::diagnostics::{self, Diagnostic, Severity};
let start = types::Position { line: 0, character: 0 };
let d = Diagnostic {
range: types::Range { start, end: start },
severity: Severity::Warning,
message: "unused".into(),
code: None,
source: Some("lint".into()),
tags: vec!["unnecessary".into(), "glowing".into()],
related: Vec::new(),
};
diagnostics::publish(&arg.unwrap_or_default(), &[d]).map(|()| None)
}
other => Err(types::Error::NotFound(format!("command {other}"))),
}
}
@@ -132,24 +113,14 @@ impl syntax_provider::Guest for Misbehave {
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
behave(&doc.text);
// `rich`: a snippet that also adds an import.
let rich = doc.text == "rich";
let start = types::Position { line: 0, character: 0 };
vec![CompletionItem {
label: doc.path.clone(),
detail: None,
kind: Some("keyword".into()),
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
snippet: rich,
insert_text: doc.path,
replace: None,
sort_text: None,
filter_text: rich.then(|| "fun".into()),
documentation: None,
additional_edits: if rich {
vec![types::TextEdit { range: types::Range { start, end: start }, text: "use f;\n".into() }]
} else {
Vec::new()
},
}]
}
@@ -157,27 +128,8 @@ impl providers::Guest for Misbehave {
None
}
/// `lazy`: one action whose edits come from `resolve-code-action`.
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
if doc.text != "lazy" {
return Vec::new();
}
vec![CodeAction {
title: "Fix later".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some("fix".into()),
}]
}
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
if action.data.as_deref() != Some("fix") {
return Err(types::Error::Invalid("not one of mine".into()));
}
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: "fixed ".into() };
Ok(CodeAction { edits: vec![FileEdit { path: doc.path, edits: vec![edit] }], ..action })
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
Vec::new()
}
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
+1 -41
View File
@@ -143,10 +143,7 @@ impl Mappings {
}
let (l, rest, _) = best?;
// A `..` from the server must not leave the local folder.
if rest.split('/').filter(|s| !s.is_empty()).any(|s| {
let mut components = Path::new(s).components();
!matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some()
}) {
if rest.split('/').any(|s| s == ".." || s == ".") {
return None;
}
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
@@ -310,43 +307,6 @@ mod tests {
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
}
#[cfg(windows)]
#[test]
fn native_anchors_never_replace_the_project_relative_fallback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let inside = root.join("src");
let outside = tmp.path().join("outside");
let missing = tmp.path().join("missing");
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir(&outside).unwrap();
for slash in ["\\", "/"] {
let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash);
assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone()));
assert_eq!(local_folder(&spelling(&outside), &root), None);
assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing))));
assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone()));
}
}
#[test]
fn remote_paths_cannot_replace_or_leave_a_local_mapping() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let m = Mappings::new(&server(vec![]), root, "/srv");
for remote in ["/srv/../outside", "/srv/./file"] {
assert_eq!(m.local(remote), None, "{remote}");
}
#[cfg(windows)]
for remote in
["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"]
{
assert_eq!(m.local(remote), None, "{remote}");
}
assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file")));
assert_eq!(m.local("/srv"), Some(root.to_path_buf()));
}
#[cfg(unix)]
#[test]
fn absolute_symlink_inside_project_resolves_to_the_project_spelling() {
+2 -67
View File
@@ -123,21 +123,8 @@ fn not_a_jdk(p: &Path) -> String {
/// `mvn` of the configured Maven home (`languages.java.maven.home`): the
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`; else of the Maven the project
/// pins for mise / asdf when it has no Maven wrapper. With `maven.daemon` on, the
/// Maven Daemon (`mvnd`) replaces all of that — it embeds its own Maven — unless
/// it is not installed or the project JDK is older than the Java 17 its daemon
/// needs, in which case the build stays on mvn (with a logged warning).
/// pins for mise / asdf when it has no Maven wrapper.
pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
if s.languages.java.maven.daemon {
match mvnd_command(root, s) {
// No project JDK at all lets mvnd pick its own Java, which may still be new enough.
Some(mvnd) if project_jdk(root, s).is_none_or(|j| j.major >= 17) => return Some(mvnd),
Some(_) => tracing::warn!(
"Maven daemon: the project JDK is older than the Java 17 its daemon needs; building with mvn"
),
None => tracing::warn!("Maven daemon is enabled but mvnd was not found; building with mvn"),
}
}
let name = if cfg!(windows) { "mvn.cmd" } else { "mvn" };
let Some(p) = setting_path(root, &s.languages.java.maven.home) else {
if root.join("mvnw").is_file() || root.join("mvnw.cmd").is_file() {
@@ -154,41 +141,6 @@ pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
found
}
/// The `mvnd` executable when the Maven Daemon is enabled: the configured home
/// (`languages.java.maven.mvndHome`, the executable itself or `<home>/bin/mvnd`),
/// else `$MVND_HOME`, else `mvnd` on `PATH`, else the common Homebrew / sdkman
/// locations. `None` = not found; the caller falls back to `mvn`.
pub fn mvnd_command(root: &Path, s: &Settings) -> Option<PathBuf> {
let name = if cfg!(windows) { "mvnd.cmd" } else { "mvnd" };
if let Some(p) = setting_path(root, &s.languages.java.maven.mvnd_home) {
let found = [p.clone(), p.join("bin").join(name), p.join(name)].into_iter().find(|c| c.is_file());
if found.is_none() {
tracing::warn!("Maven Daemon home {} has no {name}", p.display());
}
return found;
}
if let Some(home) = std::env::var_os("MVND_HOME").map(PathBuf::from) {
let found = [home.join("bin").join(name), home.join(name)].into_iter().find(|c| c.is_file());
if found.is_none() {
tracing::warn!("MVND_HOME {} has no {name}", home.display());
}
return found;
}
if let Some(found) = crate::exec::which(name) {
return Some(found);
}
if let Some(home) = crate::home_dir() {
let sdkman = home.join(".sdkman/candidates/mvnd/current");
let candidates = [
sdkman.join("bin").join(name),
PathBuf::from("/opt/homebrew/opt/mvnd").join("bin").join(name),
PathBuf::from("/usr/local/opt/mvnd").join("bin").join(name),
];
return candidates.into_iter().find(|c| c.is_file());
}
None
}
/// Project launcher policy for sync and tool-window tasks.
pub fn gradle_command(root: &Path, build_dir: &Path, s: &Settings) -> PathBuf {
resolve_gradle_command(root, build_dir, s, true)
@@ -349,7 +301,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"];
pub(crate) fn merge(base: &mut Value, layer: Value) {
fn merge(base: &mut Value, layer: Value) {
match (base, layer) {
(Value::Object(base), Value::Object(layer)) => {
for (k, v) in layer {
@@ -610,22 +562,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
configure_vue_typescript(cfg, root, server_root, s);
}
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(level) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
merge_init_options(cfg, json!({ "stubs": stubs }));
}
let include_paths = crate::php::include_paths(root, s);
if !include_paths.is_empty() {
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
}
}
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
if let Some(dir) = node_dir(root, s) {
path_dirs.push(dir);
@@ -692,7 +628,6 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
}
}
+6 -3
View File
@@ -1128,7 +1128,7 @@ mod tests {
/// The same jar supplies the JDT command and the private Maven output redirector.
pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("kotlin-bridge-1.0.0.jar");
let bytes = include_bytes!("../../../java/kotlin-bridge/openidea-kotlin-bridge.jar");
let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
return Some(extension);
}
@@ -1171,7 +1171,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<PathBuf> {
let extension = data_dir.join("java-usages-1.3.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
return Some(extension);
@@ -1256,7 +1256,10 @@ mod usages_bundle_tests {
fn installs_the_exact_bundle_and_reuses_it() {
let dir = tempfile::tempdir().unwrap();
let jar = super::usages_bundle(dir.path()).unwrap();
assert_eq!(std::fs::read(&jar).unwrap(), include_bytes!("../../../java/java-usages/openidea-java-usages.jar"));
assert_eq!(
std::fs::read(&jar).unwrap(),
include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar")
);
assert_eq!(super::usages_bundle(dir.path()), Some(jar));
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
-1
View File
@@ -18,7 +18,6 @@ pub mod locate;
pub mod lombok;
pub mod maven_profiles;
mod npm;
pub mod php;
pub mod pinned;
pub mod python;
pub mod ruby;
+1 -1
View File
@@ -246,7 +246,7 @@ fn sources_import_lombok(root: &Path) -> bool {
// Imports precede the first type, after at most a license header.
const HEAD: u64 = 8 * 1024;
let mut walker = ignore::WalkBuilder::new(root);
walker.require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false).filter_entry(|e| {
walker.require_git(false).follow_links(false).filter_entry(|e| {
e.depth() == 0
|| !e.file_type().is_some_and(|t| t.is_dir())
|| !OUTPUT_DIRS.contains(&e.file_name().to_string_lossy().as_ref())
-376
View File
@@ -1,376 +0,0 @@
//! 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.
use crate::apply::{merge, merge_init_options, setting_path};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
let explicit = s.languages.php.language_level.trim();
if !explicit.is_empty() {
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// 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
/// with a `storagePath` of the user's, the server keeps its own storage.
pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir: &Path) {
if cfg.name != "intelephense" {
return;
}
let mut options =
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
if options.get("storagePath").is_some() {
return;
}
// The version Intelephense reads: `workspace/configuration` is answered from the
// settings merged over the initialization options (`ide_lsp::handler::all_settings`).
if let Some(settings) = &cfg.settings {
merge(&mut options, settings.clone());
}
let section = options.pointer("/settings/intelephense").unwrap_or(&options);
let version = section.pointer("/environment/phpVersion").and_then(Value::as_str);
let Some(version) = version.filter(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit() || c == '.')) else {
return;
};
let storage = data_dir.join("lsp").join("intelephense").join(version);
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
}
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
/// `stubs` replaces them, so additions are sent together with these.
#[rustfmt::skip]
const DEFAULT_STUBS: &[&str] = &[
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
];
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
/// extension name.
const MIXED_CASE_STUBS: &[&str] =
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
fn stub_key(name: &str) -> String {
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
/// that adds nothing to the defaults, so the server keeps its own.
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
let composer: Option<Value> =
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
let extensions = ["require", "require-dev"]
.into_iter()
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
.flat_map(|deps| deps.keys())
.filter_map(|dep| dep.strip_prefix("ext-"))
.map(|ext| {
let key = stub_key(ext);
MIXED_CASE_STUBS
.iter()
.find(|stub| stub_key(stub) == key)
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
});
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
for stub in extensions.chain(configured) {
if !stub.is_empty() && known.insert(stub_key(&stub)) {
stubs.push(stub);
}
}
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
}
/// The folders in Settings › PHP › Include paths, resolved against the project.
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
s.languages
.php
.include_paths
.iter()
.filter_map(|p| setting_path(root, p))
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Version = (u32, u32, u32);
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
}
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
fn version(s: &str) -> Option<Version> {
let s = s.trim().trim_start_matches(['v', 'V']);
let mut parts = s.split('.').map(|p| {
let digits: &str = &p[..p.find(|c: char| !c.is_ascii_digit()).unwrap_or(p.len())];
digits.parse::<u32>().ok()
});
let major = parts.next()??;
let minor = parts.next().flatten().unwrap_or(0);
let patch = parts.next().flatten().unwrap_or(0);
Some((major, minor, patch))
}
/// The lowest version a Composer constraint allows: the lowest lower bound of its
/// `||` alternatives. `None` when an alternative has no lower bound (`*`, `<8.0`).
fn lowest_allowed(constraint: &str) -> Option<Version> {
let bounds: Option<Vec<Version>> =
constraint.split('|').filter(|alt| !alt.trim().is_empty()).map(lower_bound).collect();
bounds?.into_iter().min()
}
/// The lower bound of constraints that must all hold (`>=7.2 <8.4`, `^7.4, !=7.4.1`,
/// `7.2 - 8.0`): the highest of their lower bounds.
fn lower_bound(alternative: &str) -> Option<Version> {
// A hyphen range: its first version.
let alternative = alternative.split(" - ").next().unwrap_or(alternative);
let mut tokens: Vec<String> = Vec::new();
for token in alternative.split([' ', ',']).filter(|t| !t.is_empty()) {
// `>= 7.2`: the operator and its version.
match tokens.last_mut() {
Some(op) if op.chars().all(|c| "<>=!~^".contains(c)) => op.push_str(token),
_ => tokens.push(token.to_owned()),
}
}
tokens
.iter()
.filter_map(|t| {
let t = t.split('@').next().unwrap_or(t);
if t.starts_with('<') || t.starts_with("!=") {
return None;
}
version(t.trim_start_matches(['>', '=', '^', '~']))
})
.max()
}
#[cfg(test)]
mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
(language_level(tmp.path(), &s), composer_level(tmp.path()))
}
fn required(constraint: &str) -> Option<String> {
level(Some(&serde_json::json!({ "require": { "php": constraint } }).to_string()), "")
}
#[test]
fn lowest_version_require_php_allows() {
assert_eq!(required("^7.4 || ^8.0").as_deref(), Some("7.4.0"));
assert_eq!(required("^8.0 || ^7.4").as_deref(), Some("7.4.0"));
assert_eq!(required(">=7.2 <8.4").as_deref(), Some("7.2.0"));
assert_eq!(required(">= 7.2, < 8.4").as_deref(), Some("7.2.0"));
assert_eq!(required("8.1.*").as_deref(), Some("8.1.0"));
assert_eq!(required("~7.4.3").as_deref(), Some("7.4.3"));
assert_eq!(required("7.2 - 8.0").as_deref(), Some("7.2.0"));
assert_eq!(required("^7.3|^8.0").as_deref(), Some("7.3.0"));
assert_eq!(required(">=8.1@dev").as_deref(), Some("8.1.0"));
assert_eq!(required("8").as_deref(), Some("8.0.0"));
}
#[test]
fn no_lower_bound_gives_nothing() {
for constraint in ["*", "<8.0", "", "^7.4 || *", "dev-main"] {
assert_eq!(required(constraint), None, "{constraint}");
}
}
#[test]
fn platform_overrides_require() {
let composer = r#"{"require": {"php": "^8.2"}, "config": {"platform": {"php": "7.2.34"}}}"#;
assert_eq!(level(Some(composer), "").as_deref(), Some("7.2.34"));
}
#[test]
fn no_requirement_gives_nothing() {
assert_eq!(level(None, ""), None);
assert_eq!(level(Some(r#"{"require": {"ext-json": "*"}}"#), ""), None);
assert_eq!(level(Some("not json"), ""), None);
}
#[test]
fn each_php_version_gets_its_own_intelephense_storage() {
let data = Path::new("/data");
let server = |name: &str, options: Value| {
let mut cfg: LanguageServerConfig =
toml::from_str(&format!("name = \"{name}\"\ncommand = \"{name}\"")).unwrap();
if !options.is_null() {
merge_init_options(&mut cfg, options);
}
cfg
};
let options = |cfg: &LanguageServerConfig| serde_json::to_value(&cfg.initialization_options).unwrap();
let mut cfg = server("intelephense", json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false}));
configure_intelephense_storage(&mut cfg, data);
let storage = data.join("lsp").join("intelephense").join("7.4.0");
assert_eq!(
options(&cfg),
json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false, "storagePath": storage.to_string_lossy()})
);
// The version Intelephense reads wins: Settings > Language Servers `settings` over the options.
let storage_of = |init: Value, settings: Value| {
let mut cfg = server("intelephense", init);
cfg.settings = Some(settings);
configure_intelephense_storage(&mut cfg, data);
options(&cfg)["storagePath"].clone()
};
let path = |v: &str| json!(data.join("lsp").join("intelephense").join(v).to_string_lossy());
let init = json!({"environment": {"phpVersion": "7.4.0"}});
assert_eq!(storage_of(init.clone(), json!({"environment": {"phpVersion": "8.3.0"}})), path("8.3.0"));
assert_eq!(storage_of(init, json!({"other": 1})), path("7.4.0"));
let nested = json!({"settings": {"intelephense": {"environment": {"phpVersion": "8.0.0"}}}});
assert_eq!(storage_of(Value::Null, nested), path("8.0.0"));
// No version, the user's own storage, not a version, another server: unchanged.
for unchanged in [
server("intelephense", Value::Null),
server("intelephense", json!({"environment": {"includePaths": []}})),
server("intelephense", json!({"environment": {"phpVersion": "8.1.0"}, "storagePath": "/mine"})),
server("intelephense", json!({"environment": {"phpVersion": "../8.1"}})),
server("phpactor", json!({"environment": {"phpVersion": "8.1.0"}})),
] {
let mut cfg = unchanged.clone();
configure_intelephense_storage(&mut cfg, data);
assert_eq!(cfg, unchanged);
}
}
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
}
let mut s = Settings::default();
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
intelephense_stubs(tmp.path(), &s)
}
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
stubs[DEFAULT_STUBS.len()..].to_vec()
}
#[test]
fn required_extensions_and_configured_stubs_follow_the_defaults() {
let composer = json!({
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
});
assert_eq!(
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
["redis", "imagick", "FFI", "wordpress"]
);
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
}
#[test]
fn nothing_beyond_the_defaults_sends_nothing() {
assert_eq!(stubs(None, &[]), None);
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
let defaults =
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
}
#[test]
fn include_paths_resolve_against_the_project() {
let root = Path::new("/work/module");
let mut s = Settings::default();
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
assert_eq!(
include_paths(root, &s),
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
assert_eq!(level(Some(composer), "8.1").as_deref(), Some("8.1.0"));
assert_eq!(level(None, " 5.6 ").as_deref(), Some("5.6.0"));
}
}
-56
View File
@@ -38,7 +38,6 @@ fn server(name: &str) -> LanguageServerConfig {
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
}
}
@@ -1294,61 +1293,6 @@ fn php_sources_and_selected_interpreter() {
assert!(configs[3].env.get("PATH").is_none_or(|p| !p.contains("php82")));
}
#[test]
fn intelephense_gets_the_php_language_level() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
let configured = |name: &str, s: &Settings| {
let mut cfg = server(name);
configure_server(&mut cfg, tmp.path(), tmp.path(), s);
cfg
};
let options = |cfg: LanguageServerConfig| serde_json::to_value(cfg.initialization_options).unwrap();
// Nothing to resolve: the options stay as they were (the rest depends on the machine's tools).
assert_eq!(configured("intelephense", &Settings::default()).initialization_options, None);
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^7.4 || ^8.0"}}"#).unwrap();
assert_eq!(
options(configured("intelephense", &Settings::default())),
json!({"environment": {"phpVersion": "7.4.0"}})
);
for other in ["phpactor", "basedpyright", "vtsls"] {
assert_eq!(options(configured(other, &Settings::default())).get("environment"), None, "{other}");
}
let mut s = Settings::default();
s.languages.php.language_level = "8.1".into();
let mut cfg = server("intelephense");
cfg.initialization_options = Some(toml::Value::try_from(json!({"clearCache": true})).unwrap());
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
}
#[test]
fn intelephense_gets_the_required_stubs_and_include_paths() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
let mut s = Settings::default();
s.languages.php.stubs = vec!["wordpress".into()];
s.languages.php.include_paths = vec!["../platform".into()];
let mut cfg = server("intelephense");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(stubs.len(), 70);
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
assert_eq!(
options["environment"],
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
);
let mut other = server("phpactor");
configure_server(&mut other, tmp.path(), tmp.path(), &s);
assert_eq!(other.initialization_options, None);
}
#[test]
fn go_constraints_change_only_protocol_settings() {
use serde_json::json;
+1 -91
View File
@@ -35,15 +35,13 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
let tmp = dunce::canonicalize(tmp.path()).unwrap();
let mise = tmp.join("mise");
let asdf = tmp.join("asdf");
// SAFETY: the only test in this binary that writes the environment; the daemon
// tests below read it, so they run after this one's fixtures are in place.
// SAFETY: the only test in this binary; nothing else reads the environment yet.
unsafe {
std::env::set_var("HOME", tmp.join("home"));
std::env::set_var("MISE_DATA_DIR", &mise);
std::env::set_var("ASDF_DATA_DIR", &asdf);
std::env::set_var("MISE_GLOBAL_CONFIG_FILE", tmp.join("no-global.toml"));
std::env::remove_var("JAVA_HOME");
std::env::remove_var("MVND_HOME");
}
let installs = mise.join("installs");
@@ -133,99 +131,11 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
ide_sdk::apply::apply_to_run_configs(&mut plain, &project, &s);
assert_eq!(plain[0].command, installs.join("gradle/8.7/bin/gradle").to_string_lossy());
// The Maven daemon replaces the pinned Maven when it is on and installed; the
// project JDK (21, from mise.toml) can host its JVM.
let mut daemon = s.clone();
daemon.languages.java.maven.daemon = true;
daemon.languages.java.maven.mvnd_home = tmp.join("missing/mvnd").to_string_lossy().into_owned();
assert_eq!(
ide_sdk::apply::maven_command(&project, &daemon),
Some(maven.join("bin/mvn")),
"a dead-end mvnd home falls back to the pinned Maven"
);
let mvnd = tmp.join("mvnd");
script(&mvnd.join("bin/mvnd"), "mvnd 1.0-m7");
daemon.languages.java.maven.mvnd_home = mvnd.to_string_lossy().into_owned();
assert_eq!(
ide_sdk::apply::maven_command(&project, &daemon),
Some(mvnd.join("bin/mvnd")),
"a bare home resolves to <home>/bin/mvnd"
);
// The executable itself, like `maven.home`, also works.
daemon.languages.java.maven.mvnd_home = mvnd.join("bin/mvnd").to_string_lossy().into_owned();
assert_eq!(ide_sdk::apply::maven_command(&project, &daemon), Some(mvnd.join("bin/mvnd")));
// The project's Maven wrapper keeps precedence over a pinned Maven.
script(&project.join("mvnw"), "");
assert_eq!(ide_sdk::apply::maven_command(&project, &s), None);
// ...but not over the daemon.
assert_eq!(ide_sdk::apply::maven_command(&project, &daemon), Some(mvnd.join("bin/mvnd")));
// A version the project pins but that isn't installed: nothing is made up.
write(&project.join("mise.toml"), "[tools]\njava = '25'\n");
assert_eq!(ide_sdk::apply::project_jdk(&project, &s), None);
}
#[test]
fn maven_daemon_run_configs_follow_the_setting_and_the_jdk() {
let tmp = tempfile::tempdir().unwrap();
let tmp = dunce::canonicalize(tmp.path()).unwrap();
let project = tmp.join("project");
write(&project.join("pom.xml"), "<project/>");
let mvnd = tmp.join("mvnd");
script(&mvnd.join("bin/mvnd"), "mvnd 1.0-m7");
let mvn = tmp.join("mvn");
script(&mvn.join("bin/mvn"), "Apache Maven 3.9.6");
let mvnd_path = mvnd.join("bin/mvnd").to_string_lossy().into_owned();
let mvn_path = mvn.join("bin/mvn").to_string_lossy().into_owned();
let mut s = Settings::default();
s.languages.java.maven.daemon = true;
s.languages.java.maven.mvnd_home = mvnd_path.clone();
s.languages.java.maven.home = mvn_path.clone();
let run = |command: &str, detected: bool| RunConfig {
id: "maven:x".into(),
name: "x".into(),
kind: "maven".into(),
command: command.into(),
args: vec!["package".into()],
cwd: None,
env: BTreeMap::new(),
detected,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
// No project JDK: the daemon may pick its own, so it stays on mvnd.
let mut configs = [run("mvn", true), run("mvn", false)];
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
assert_eq!(configs[0].command, mvnd_path, "detected config runs the daemon");
assert_eq!(configs[1].command, mvnd_path, "saved `mvn` config is pointed at the daemon");
// A launcher the user wrote into a configuration is theirs: an absolute path
// or a bare `mvnd` stays as configured.
let mut configs = [run(&mvn_path, false), run("mvnd", false)];
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
assert_eq!(configs[0].command, mvn_path, "a custom absolute launcher is kept");
assert_eq!(configs[1].command, "mvnd", "a user-written mvnd is kept");
// A project JDK older than the daemon's Java 17 requirement keeps mvn.
let jdk8 = tmp.join("jdk8");
jdk(&jdk8, "1.8.0_392");
s.languages.java.jdk = jdk8.to_string_lossy().into_owned();
let mut configs = [run("mvn", true), run("mvn", false)];
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
assert_eq!(configs[0].command, mvn_path, "JDK 8 cannot host the daemon JVM");
assert_eq!(configs[1].command, mvn_path);
// With the daemon off, the same configurations run the configured Maven.
s.languages.java.maven.daemon = false;
s.languages.java.jdk = String::new();
let mut configs = [run("mvn", true), run("mvn", false)];
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
assert_eq!(configs[0].command, mvn_path);
assert_eq!(configs[1].command, mvn_path);
}
+30 -65
View File
@@ -142,16 +142,42 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
}
}
if !ctx.ctl.should_stop() {
walk_files(&on_disk, || {
if let Some((first, rest)) = on_disk.split_first()
&& !ctx.ctl.should_stop()
{
let mut walk = WalkBuilder::new(first);
for path in rest {
walk.add(path);
}
walk.hidden(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.follow_links(false)
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
.filter_entry(|e| e.file_name() != ".git");
walk.build_parallel().run(|| {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
Box::new(move |entry| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
tracing::debug!("search walk: {err}");
return WalkState::Continue;
}
};
// Archives (jar, zip) are browsed, not searched (IDEA skips them too).
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
ctx.search_file(entry.path(), &matcher, &mut searcher);
}
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
});
@@ -167,67 +193,6 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
})
}
/// A per-thread file visitor of [`walk_files`].
pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
/// Visits the files below `roots` (and the roots that are files) in parallel,
/// the way IDEA's project scope sees them: directories ignored by
/// `.gitignore` / `.ignore` / `info/exclude` are skipped with everything in
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
/// zip: browsed, not searched) are never visited.
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
let Some((first, rest)) = roots.split_first() else { return };
let mut walk = WalkBuilder::new(first);
for path in rest {
walk.add(path);
}
walk.hidden(false)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(ide_core::ignored::inside_git_repo(first))
.follow_links(false)
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
.filter_entry(|e| e.file_name() != ".git");
walk.build_parallel().run(|| {
let mut visit = visitor();
Box::new(move |entry| {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
tracing::debug!("search walk: {err}");
return WalkState::Continue;
}
};
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() {
// The walker drops ignored files unseen, so the files of every walked
// directory come from listing it; the walker only picks the directories.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
for child in children.flatten() {
let path = child.path();
if child.file_name() != ".git"
&& child.file_type().is_ok_and(|t| t.is_file())
&& !ide_vfs::is_archive_file(&path)
&& matches!(visit(&path), WalkState::Quit)
{
return WalkState::Quit;
}
}
WalkState::Continue
} else if file_type.is_file() && entry.depth() == 0 && !ide_vfs::is_archive_file(entry.path()) {
// A root that is a file (an explicit file list); files below roots come from listings.
visit(entry.path())
} else {
WalkState::Continue
}
})
});
}
/// Walk roots for a scope; relative paths are resolved against `root`.
pub(crate) fn roots(root: &Path, scope: &SearchScope) -> IdeResult<Vec<PathBuf>> {
let dir = |path: &Path| -> IdeResult<PathBuf> {
+42 -23
View File
@@ -1,10 +1,10 @@
//! The files of a search scope, for analyses that read whole files
//! (Inspect Code, Locate Duplicates, Structural Search).
use crate::engine::{rel_path, roots, walk_files};
use crate::engine::{rel_path, roots};
use crate::{FileMask, PathFilter, SearchScope};
use ide_core::{CancellationToken, IdeResult};
use ignore::WalkState;
use ignore::{WalkBuilder, WalkState};
use std::path::PathBuf;
use std::sync::Mutex;
@@ -16,7 +16,7 @@ pub struct ScopeFile {
}
/// Lists the files of `scope` under `root` the way Find in Files walks them
/// (ignored folders and `.git` skipped; ignored and hidden files kept), filtered by
/// (`.gitignore` honoured, hidden files and `.git` skipped), filtered by
/// `mask` and `filter`, sorted by relative path. Explicitly listed files are
/// kept even when they do not exist on disk (unsaved buffers). Returns what was
/// found so far when cancelled.
@@ -43,23 +43,42 @@ pub fn scope_files(
on_disk.push(path);
}
}
walk_files(&on_disk, || {
let found = &found;
let keep = &keep;
Box::new(move |path| {
if cancel.is_cancelled() {
return WalkState::Quit;
}
let rel = rel_path(root, path);
if keep(&rel) {
found
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(ScopeFile { path: path.to_path_buf(), rel_path: rel });
}
WalkState::Continue
})
});
if let Some((first, rest)) = on_disk.split_first() {
let mut walk = WalkBuilder::new(first);
for path in rest {
walk.add(path);
}
walk.hidden(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.follow_links(false)
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
.filter_entry(|e| e.file_name() != ".git");
walk.build_parallel().run(|| {
let found = &found;
let keep = &keep;
Box::new(move |entry| {
if cancel.is_cancelled() {
return WalkState::Quit;
}
let Ok(entry) = entry else { return WalkState::Continue };
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
let rel = rel_path(root, entry.path());
if keep(&rel) {
found
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(ScopeFile { path: entry.into_path(), rel_path: rel });
}
}
WalkState::Continue
})
});
}
let mut files = found.into_inner().unwrap_or_else(|e| e.into_inner());
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
files.dedup_by(|a, b| a.path == b.path);
@@ -88,12 +107,12 @@ mod tests {
}
#[test]
fn project_scope_skips_ignored_folders_and_honours_masks() {
fn project_scope_honours_gitignore_and_masks() {
let dir = tree();
let none = FileMask::new(&[], &[]).unwrap();
let cancel = CancellationToken::new();
let all = scope_files(dir.path(), &SearchScope::Project, &none, None, &cancel).unwrap();
assert_eq!(rels(&all), vec![".gitignore", "src/a.rs", "src/c.txt", "src/sub/b.rs"]);
assert_eq!(rels(&all), vec!["src/a.rs", "src/c.txt", "src/sub/b.rs"]);
let rs = FileMask::new(&["*.rs".into()], &[]).unwrap();
let only_rs = scope_files(dir.path(), &SearchScope::Project, &rs, None, &cancel).unwrap();
@@ -101,7 +120,7 @@ mod tests {
let not_sub: PathFilter<'_> = &|rel: &str| !rel.starts_with("src/sub/");
let filtered = scope_files(dir.path(), &SearchScope::Project, &none, Some(not_sub), &cancel).unwrap();
assert_eq!(rels(&filtered), vec![".gitignore", "src/a.rs", "src/c.txt"]);
assert_eq!(rels(&filtered), vec!["src/a.rs", "src/c.txt"]);
}
#[test]
+2 -3
View File
@@ -1,8 +1,7 @@
//! Find / Replace in Files (⌘⇧F / ⌘⇧R).
//!
//! Built on ripgrep's engine crates: a parallel `ignore` walk (skipping the
//! folders `.gitignore` / `.ignore` exclude and `.git`, but like IDEA keeping
//! ignored files elsewhere and hidden files) feeding
//! Built on ripgrep's engine crates: a parallel `ignore` walk (honouring
//! `.gitignore` / `.ignore`, skipping hidden files and `.git`) feeding
//! `grep-searcher` with a `grep-regex` matcher. Binary files are skipped,
//! unsaved editor buffers (the overlay) take precedence over disk content,
//! and all columns are UTF-16.
Loaded 100 of 502 files, more files were not shown because too many files have changed in this diff. Show more