mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3950f8a66 | ||
|
|
ad1fdf9e0e | ||
|
|
54502e0a17 | ||
|
|
e129840651 | ||
|
|
77f86616c0 | ||
|
|
89ffde304b | ||
|
|
1248c3231e | ||
|
|
ff9f821621 | ||
|
|
5279443ac5 | ||
|
|
e648c0c46f | ||
|
|
791c957284 | ||
|
|
59071ac426 | ||
|
|
ad27a7064a | ||
|
|
116b1624b3 | ||
|
|
de0f9d6ecd | ||
|
|
6d55eb4aa9 | ||
|
|
a247f1ce67 | ||
|
|
f16ec29f4a | ||
|
|
02d30b1609 | ||
|
|
01e09565b7 | ||
|
|
1f70b134de | ||
|
|
11fd64f399 | ||
|
|
8a884b6362 | ||
|
|
481d44930f | ||
|
|
f5895d0336 | ||
|
|
c3c1d88d10 | ||
|
|
a0ac988963 | ||
|
|
d5c4066748 | ||
|
|
8d784ac164 | ||
|
|
7ffaa0b5da | ||
|
|
ab2b53ff80 | ||
|
|
2ba731f0ec | ||
|
|
742e504719 | ||
|
|
ebe059e00f | ||
|
|
e17e59ce7d | ||
|
|
8021a8ccca | ||
|
|
5a6a6f7e05 | ||
|
|
a7f0f8110b | ||
|
|
9669e58398 | ||
|
|
d939dca303 | ||
|
|
23378e0cc2 | ||
|
|
c83264ae75 | ||
|
|
2fc3535300 | ||
|
|
d96ff8e881 | ||
|
|
3b02e6899d | ||
|
|
948e93e1a2 | ||
|
|
5d19baf06b | ||
|
|
89309e40ba | ||
|
|
9f2e6b570f | ||
|
|
2d20e5a203 | ||
|
|
7c60737b69 | ||
|
|
7e9acdc59b | ||
|
|
2013ddaac2 | ||
|
|
ce898ea0c6 | ||
|
|
c219c81bd8 | ||
|
|
70a7c6dd2c | ||
|
|
fd69664ca7 | ||
|
|
11932a87af | ||
|
|
19a245fa04 | ||
|
|
62b5f11ae3 | ||
|
|
19c2d40528 | ||
|
|
78a3768ec1 | ||
|
|
f4fa2b42f6 | ||
|
|
d0910d3313 | ||
|
|
c0a74244b4 | ||
|
|
8d29704d46 | ||
|
|
c399ad2598 | ||
|
|
c2dd466305 | ||
|
|
c27fea13b3 | ||
|
|
94490e2b37 | ||
|
|
f3afe69ca2 | ||
|
|
9a9266f61b | ||
|
|
040578ba1d | ||
|
|
ad2aeed54e | ||
|
|
1e4eee27c4 | ||
|
|
1789dbb98e | ||
|
|
20c439baaa | ||
|
|
a0dfb1e741 | ||
|
|
4906bcb2a1 | ||
|
|
f35c781f42 | ||
|
|
6f9819fee7 | ||
|
|
834b8d4e3b | ||
|
|
7cdf690b4b | ||
|
|
057877a39e | ||
|
|
a08707dabd | ||
|
|
092a700ddc | ||
|
|
b31edc3482 | ||
|
|
d032addb17 | ||
|
|
8f77d7077c | ||
|
|
bdc108a61b | ||
|
|
a363544625 | ||
|
|
f942ea168d | ||
|
|
59f1a1e6db | ||
|
|
a4a16d0854 | ||
|
|
b440370f2d | ||
|
|
aa33d2eeab | ||
|
|
b26318a32c | ||
|
|
8a2e370178 | ||
|
|
61a05e5a36 | ||
|
|
d9355155f3 | ||
|
|
ecd593eb0c | ||
|
|
0716a48247 | ||
|
|
699c71c1f2 | ||
|
|
014f51ae54 | ||
|
|
30b0549196 | ||
|
|
0ecbc6b35d | ||
|
|
859f63454e | ||
|
|
c1ea7a9ef2 | ||
|
|
3100aa2c00 | ||
|
|
52360c72d2 | ||
|
|
7fd330912a | ||
|
|
eaca68f263 | ||
|
|
4b5ef7069c | ||
|
|
2cd16a723d | ||
|
|
71c0515af6 | ||
|
|
b473876d59 | ||
|
|
d7886bab45 | ||
|
|
dd370654cb |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
Closes #
|
||||
<!-- If this resolves an issue: Closes #<n> -->
|
||||
|
||||
## What and why
|
||||
|
||||
|
||||
+17
-15
@@ -15,6 +15,8 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -62,7 +64,7 @@ jobs:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -79,7 +81,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -100,9 +102,9 @@ jobs:
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
@@ -126,7 +128,7 @@ jobs:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
@@ -135,10 +137,10 @@ jobs:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
@@ -154,16 +156,16 @@ jobs:
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_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; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.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/features/debugger/**"
|
||||
- "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 }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- 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 }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -18,24 +18,15 @@ jobs:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
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.');
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -99,11 +99,11 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -229,10 +229,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
|
||||
@@ -18,9 +18,12 @@ 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.
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
|
||||
`templates/` — plugin starter templates.
|
||||
- `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`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
|
||||
@@ -46,8 +49,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
|
||||
## Issues & PRs
|
||||
|
||||
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
|
||||
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
|
||||
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
@@ -64,6 +67,13 @@ 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 smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `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
|
||||
@@ -111,8 +121,8 @@ Several worktrees can be developed and run side by side.
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
|
||||
+2
-4
@@ -16,8 +16,7 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
|
||||
Docs, CI and pure refactors may skip the issue.
|
||||
- One problem per PR. If it resolves an issue, link it with `Closes #123`.
|
||||
- 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.
|
||||
@@ -36,5 +35,4 @@ 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 issue link or the verification
|
||||
section is missing, and removes it once they're filled in.
|
||||
A check on each PR adds `needs-info` when the verification section is missing, and removes it once it's filled in.
|
||||
Generated
+316
-68
@@ -123,6 +123,17 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -156,6 +167,27 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-core"
|
||||
version = "0.40.1"
|
||||
@@ -1600,6 +1632,7 @@ dependencies = [
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1795,6 +1828,15 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
@@ -2163,15 +2205,6 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -4038,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4116,11 +4149,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4130,7 +4163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4148,7 +4181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4174,7 +4207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4187,7 +4220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4207,7 +4240,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4219,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4232,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4272,7 +4305,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4293,7 +4326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4311,7 +4344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4333,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4350,7 +4383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4406,7 +4439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4432,7 +4465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4455,7 +4488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4475,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4488,7 +4521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4505,7 +4538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4514,7 +4547,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4528,7 +4561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4547,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4567,7 +4600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4580,11 +4613,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
@@ -4595,11 +4630,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4614,7 +4650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4627,19 +4663,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
"proptest",
|
||||
"ropey",
|
||||
"serde",
|
||||
"similar",
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4660,14 +4697,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"gix",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"redb",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4679,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4688,7 +4727,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"notify-debouncer-full",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4701,7 +4740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4717,7 +4756,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4735,6 +4774,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5212,6 +5252,33 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
"windows-native-keyring-store",
|
||||
"zbus-secret-service-keyring-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring-core"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"log",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.1"
|
||||
@@ -5634,19 +5701,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
@@ -5675,6 +5729,30 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -5687,6 +5765,15 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -5702,6 +5789,27 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -6189,7 +6297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6590,7 +6698,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap 2.14.2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -6635,6 +6743,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
@@ -6895,6 +7009,15 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -7172,18 +7295,18 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -7204,6 +7327,19 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "1.6.1"
|
||||
@@ -7270,7 +7406,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -7601,6 +7737,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -7651,6 +7793,25 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.11.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -9139,7 +9300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9327,6 +9488,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -10663,6 +10825,66 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -10948,6 +11170,19 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-native-keyring-store"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keyring-core",
|
||||
"regex",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
@@ -11474,6 +11709,7 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
@@ -11484,6 +11720,17 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-secret-service-keyring-store"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"secret-service",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
@@ -11689,6 +11936,7 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
+10
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -76,7 +76,6 @@ ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
@@ -106,6 +105,9 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -115,8 +117,14 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -45,6 +47,16 @@ 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).
|
||||
|
||||
+12
-3
@@ -2,12 +2,12 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
|
||||
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 [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).
|
||||
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).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
@@ -52,6 +52,15 @@ 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
|
||||
|
||||
@@ -315,7 +315,10 @@ 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);
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
||||
// `-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()];
|
||||
args.extend(options.maven_options.iter().cloned());
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
@@ -680,4 +683,25 @@ 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}");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
@@ -2,6 +2,18 @@
|
||||
//! 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",
|
||||
@@ -103,4 +115,15 @@ 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")));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -111,4 +128,20 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
+101
-15
@@ -43,19 +43,21 @@ 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.
|
||||
/// - 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.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree(child);
|
||||
taskkill_tree_of(child.id());
|
||||
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`. 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` 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.
|
||||
pub fn kill_tree_of(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = libc::pid_t::try_from(pid) {
|
||||
@@ -65,15 +67,7 @@ pub fn kill_tree_of(pid: u32) {
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
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();
|
||||
}
|
||||
taskkill_tree_of(pid);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -106,15 +100,107 @@ 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)]
|
||||
fn taskkill_tree(child: &Child) {
|
||||
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);
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.args(["/T", "/F", "/PID", &pid.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
|
||||
|
||||
@@ -713,6 +713,15 @@ 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)]
|
||||
@@ -861,6 +870,12 @@ 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)]
|
||||
@@ -1092,6 +1107,17 @@ 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)]
|
||||
|
||||
@@ -72,7 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
&["-m", "debugpy.adapter"],
|
||||
"stdio",
|
||||
"pip install debugpy",
|
||||
),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
|
||||
@@ -589,11 +589,12 @@ 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).
|
||||
// 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`).
|
||||
if config.adapter == "debugpy" {
|
||||
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),
|
||||
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),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
@@ -651,8 +652,19 @@ impl DebugManager {
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
|
||||
@@ -111,8 +111,8 @@ impl Session {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
let native = windows_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
@@ -133,7 +133,11 @@ impl Session {
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
windows_source_path(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
@@ -265,8 +269,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
@@ -274,7 +278,7 @@ fn is_windows_source_path(path: &str) -> bool {
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
fn windows_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
|
||||
@@ -698,6 +698,37 @@ 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]
|
||||
@@ -923,6 +954,7 @@ 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 {
|
||||
@@ -1109,6 +1141,23 @@ async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn netcoredbg_windows_source_paths_round_trip() {
|
||||
let client = "D:/work/LoginController.cs";
|
||||
let native = r"D:\work\LoginController.cs";
|
||||
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "netcoredbg".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
|
||||
@@ -29,3 +29,4 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
+153
-15
@@ -213,6 +213,8 @@ pub struct DbManager {
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
@@ -339,6 +341,7 @@ impl DbManager {
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
@@ -351,8 +354,42 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Also lists the global data sources stored in `dir` (the app data directory).
|
||||
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
|
||||
let passwords = self.saved_passwords.scoped(&dir);
|
||||
self.global = Some((dir, passwords));
|
||||
self
|
||||
}
|
||||
|
||||
/// The project's data sources, then the global ones (a project one wins on the same id).
|
||||
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
|
||||
source::load(&self.root)
|
||||
let mut all = source::load(&self.root)?;
|
||||
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
|
||||
all.extend(global);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn global_sources(&self) -> Vec<DataSource> {
|
||||
let Some((dir, _)) = &self.global else { return Vec::new() };
|
||||
source::load_global(dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("global data sources: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_of(&self, source: &DataSource) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if source.global => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
@@ -372,18 +409,43 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
source::save(&self.root, &sources)?;
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
if gone_or_changed.is_none() {
|
||||
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
|
||||
self.session_passwords.lock().remove(&k);
|
||||
self.saved_passwords.delete(&k);
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
// Made global or project-level: its saved passwords move along.
|
||||
Some(n) if n.global != o.global => {
|
||||
for k in &keys {
|
||||
if let Some(p) = self.store_of(&o).get(k) {
|
||||
self.store_of(n).set(k, &p)?;
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
@@ -395,7 +457,7 @@ impl DbManager {
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(id.to_owned(), password, save).await
|
||||
self.remember(id, id.to_owned(), password, save).await
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
@@ -407,10 +469,10 @@ impl DbManager {
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
self.remember(id, secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
@@ -419,10 +481,11 @@ impl DbManager {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
}
|
||||
}
|
||||
let store = self.saved_for(id);
|
||||
let result = match (secret, save) {
|
||||
(Some(p), true) => self.saved_passwords.set(&key, &p),
|
||||
(Some(p), true) => store.set(&key, &p),
|
||||
_ => {
|
||||
self.saved_passwords.delete(&key);
|
||||
store.delete(&key);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -433,18 +496,18 @@ impl DbManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret(&self, key: String, saved: bool) -> Option<String> {
|
||||
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
|
||||
if let Some(p) = self.session_passwords.lock().get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
if !saved {
|
||||
return None;
|
||||
}
|
||||
self.saved_passwords.get(&key)
|
||||
self.store_of(source).get(&key)
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
@@ -500,7 +563,7 @@ impl DbManager {
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
@@ -1098,6 +1161,81 @@ impl DbManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::files_containing;
|
||||
use std::path::Path;
|
||||
|
||||
fn manager(dir: &Path) -> DbManager {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let data = dir.join("data");
|
||||
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
|
||||
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_passwords_stay_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
|
||||
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
|
||||
let reopened = manager(tmp.path());
|
||||
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
|
||||
assert_eq!(
|
||||
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
|
||||
Some("hunter2-tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project").to_string_lossy().into_owned();
|
||||
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
|
||||
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
|
||||
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
|
||||
let m = manager(tmp.path());
|
||||
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
|
||||
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
|
||||
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
|
||||
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
|
||||
let manager = |root: &std::path::Path| {
|
||||
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
|
||||
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
|
||||
.with_global_sources(app.clone())
|
||||
};
|
||||
let (a, b) = (manager(&one), manager(&two));
|
||||
let source = |id: &str, global: bool| DataSource {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
driver: DbDriver::Postgres,
|
||||
save_password: true,
|
||||
global,
|
||||
..Default::default()
|
||||
};
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
a.set_password("shared", Some("pw".into()), true).await.unwrap();
|
||||
// Made global: listed in the other project too, with its saved password.
|
||||
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
|
||||
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
|
||||
assert_eq!(ids(&b), [("shared".into(), true)]);
|
||||
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
|
||||
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
|
||||
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
|
||||
// Made project-level again: gone from the other project, the password stays with it.
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
assert!(b.sources().unwrap().is_empty());
|
||||
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
//! Saved data source passwords, in the app's data directory rather than the OS
|
||||
//! credential store (no system prompts). One JSON file for all projects, keyed
|
||||
//! by project root, then credential key; owner-only on Unix.
|
||||
//! Saved data source passwords, in the OS credential store like the other saved
|
||||
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
|
||||
//! keyed by project root, then credential key.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use ide_core::IdeResult;
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Project root → credential key → secret.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct SecretStore {
|
||||
path: PathBuf,
|
||||
file: SecretFile,
|
||||
project: String,
|
||||
}
|
||||
|
||||
@@ -23,61 +18,41 @@ pub fn ssh_key(id: &str) -> String {
|
||||
format!("{id}#ssh")
|
||||
}
|
||||
|
||||
fn file_key(project: &str, key: &str) -> String {
|
||||
format!("{project}|{key}")
|
||||
}
|
||||
|
||||
/// Older versions' file: project root → credential key → secret.
|
||||
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
|
||||
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
|
||||
Some(
|
||||
all.into_iter()
|
||||
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl SecretStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
let file = SecretFile::with_legacy(path.clone(), legacy);
|
||||
Self { path, file, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
|
||||
pub fn scoped(&self, scope: &Path) -> Self {
|
||||
Self::new(self.path.clone(), scope)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project)?.remove(key)
|
||||
self.file.get(&file_key(&self.project, key))
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
self.file.set(&file_key(&self.project, key), secret)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return };
|
||||
if project.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
if project.is_empty() {
|
||||
all.remove(&self.project);
|
||||
}
|
||||
if let Err(e) = self.write(&all) {
|
||||
tracing::warn!("saved passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
self.file.delete(&file_key(&self.project, key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +62,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_projects_apart_and_drops_empty_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("db-passwords.json");
|
||||
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
|
||||
|
||||
+42
-10
@@ -1,6 +1,7 @@
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
|
||||
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
|
||||
//! only in memory for the session.
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
|
||||
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
|
||||
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
|
||||
//! the others only in memory for the session.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -181,6 +182,9 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -209,6 +213,7 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,9 +402,32 @@ struct SourcesFile {
|
||||
sources: Vec<DataSource>,
|
||||
}
|
||||
|
||||
/// The project's data sources.
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, FILE);
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
read(&project_dir::path(root, FILE))
|
||||
}
|
||||
|
||||
/// Saves the project's data sources.
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&project_dir::path(root, FILE), sources)
|
||||
}
|
||||
|
||||
/// The global data sources stored in `dir` (the app data directory).
|
||||
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let mut sources = read(&dir.join(FILE))?;
|
||||
for s in &mut sources {
|
||||
s.global = true;
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Saves the global data sources in `dir`.
|
||||
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&dir.join(FILE), sources)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -408,19 +436,23 @@ pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
Ok(file.sources)
|
||||
}
|
||||
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
for s in sources {
|
||||
if s.id.is_empty() || s.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every data source needs an id and a name"));
|
||||
}
|
||||
}
|
||||
let path = project_dir::path(root, FILE);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
|
||||
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
|
||||
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
|
||||
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
|
||||
table.remove("global");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(hashed_path(rel).as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
@@ -372,6 +372,12 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
|
||||
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
|
||||
fn hashed_path(rel: &Path) -> String {
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -480,4 +486,20 @@ mod tests {
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_paths_with_forward_slashes_on_every_os() {
|
||||
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
|
||||
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("web")).unwrap();
|
||||
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
|
||||
// A walked path (native separators) hashes like the manifest's `/` spelling.
|
||||
assert_eq!(
|
||||
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
|
||||
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ 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
|
||||
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
|
||||
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
|
||||
@@ -177,7 +179,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_path(a.value("sdk"), &dir)?),
|
||||
templates::Template::Command => Some(sdk_dependency(a.value("sdk"), &dir)?),
|
||||
_ => None,
|
||||
};
|
||||
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
|
||||
@@ -232,12 +234,16 @@ fn title_case(s: &str) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let sdk = match explicit {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
|
||||
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"))),
|
||||
},
|
||||
};
|
||||
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())
|
||||
@@ -246,9 +252,11 @@ fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String>
|
||||
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
|
||||
}
|
||||
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
|
||||
Ok(path.to_string_lossy().replace('\\', "/"))
|
||||
Ok(format!("{{ path = \"{}\" }}", 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());
|
||||
@@ -781,6 +789,27 @@ 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);
|
||||
@@ -901,13 +930,48 @@ 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("../sdk") };
|
||||
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("{ path = \"../sdk\" }") };
|
||||
for (rel, content) in templates::render(t, &vars) {
|
||||
let path = dir.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
|
||||
@@ -32,6 +32,7 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ publish = false
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "__SDK__" }
|
||||
openidea-plugin = __SDK__
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
@@ -130,7 +131,7 @@ panic = "abort"
|
||||
|
||||
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
|
||||
|
||||
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Main {
|
||||
@@ -138,7 +139,7 @@ pub struct Main {
|
||||
}
|
||||
|
||||
impl Plugin for Main {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"say-hello" => {
|
||||
// Instance state lasts until the plugin is idle for a while;
|
||||
@@ -153,7 +154,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}")),
|
||||
_ => Err(format!("unknown command {id}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+124
-54
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -74,15 +74,23 @@ pub struct Host {
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
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>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
@@ -102,6 +110,7 @@ impl Host {
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -122,8 +131,10 @@ impl Host {
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -177,45 +188,69 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes `window`'s project and waits until its language servers, kernels and
|
||||
/// debug sessions have shut down. Never call this on the UI event-loop thread:
|
||||
/// the shutdown runs on the async runtime, whose workers may themselves be
|
||||
/// waiting for the event loop (IPC responses, window queries). Use
|
||||
/// [`Self::close_project_detached`] there.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// 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(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
/// Like [`Self::close_project`], but returns once the project is detached from
|
||||
/// the window; its workers finish shutting down in the background, and
|
||||
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
|
||||
pub fn close_project_detached(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
let mut closing = self.closing.lock();
|
||||
closing.retain(|handle| !handle.is_finished());
|
||||
closing.push(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the project from `window` and stops what it started, synchronously,
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
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 !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and its language
|
||||
// servers shut down.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
Some(self.spawn_project_shutdown(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
))
|
||||
}
|
||||
|
||||
/// Project/window destruction must finish owned kernel cleanup, not enqueue
|
||||
/// work which can be lost as the desktop runtime exits: the returned thread is
|
||||
/// joined by the closer or, for a detached close, before exit.
|
||||
fn spawn_project_shutdown(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) {
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
@@ -223,25 +258,37 @@ impl Host {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for detached closes, so a project opened next never races the
|
||||
/// language servers of one still shutting down (e.g. over a shared workspace).
|
||||
pub(crate) fn wait_detached_closes(&self) {
|
||||
let pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
for shutdown in pending {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
|
||||
let wait = move || {
|
||||
if let Err(error) = shutdown.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
@@ -253,15 +300,32 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
/// Called before the desktop runtime exits, including update/restart exits, on
|
||||
/// the event-loop thread: waits for detached closes and the open projects'
|
||||
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
|
||||
/// stuck on the event loop cannot keep the process from exiting.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
pending.push(self.spawn_project_shutdown(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for handle in pending {
|
||||
if let Err(error) = handle.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
@@ -311,14 +375,20 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn begin_project_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);
|
||||
let token = self.begin_project_read_request(window, 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);
|
||||
|
||||
@@ -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 = project.lsp.virtual_document(&uri).await?;
|
||||
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -137,16 +137,23 @@ 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 {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
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
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
@@ -76,9 +76,17 @@ 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 (resolve and commands don't apply).
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
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 {
|
||||
@@ -120,14 +128,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: None,
|
||||
filter_text: item.filter_text,
|
||||
preselect: false,
|
||||
deprecated: false,
|
||||
insert_text: item.insert_text,
|
||||
is_snippet: false,
|
||||
is_snippet: item.snippet,
|
||||
replace_range: item.replace,
|
||||
replace_range_tab: None,
|
||||
additional_edits: Vec::new(),
|
||||
additional_edits: item.additional_edits,
|
||||
commit_characters: Vec::new(),
|
||||
command: None,
|
||||
data: None,
|
||||
@@ -252,6 +260,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -314,7 +325,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(_, a)| plugin_code_action(a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, 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}."))))
|
||||
@@ -330,24 +341,53 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
|
||||
let files = a
|
||||
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
|
||||
.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: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data: None,
|
||||
data,
|
||||
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,
|
||||
@@ -381,7 +421,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 = project.lsp.resolve_code_action(doc, action).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
|
||||
}
|
||||
|
||||
@@ -391,7 +431,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 = project.lsp.resolve_code_action(doc, action).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
|
||||
} else {
|
||||
action
|
||||
@@ -401,12 +441,30 @@ 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 {
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
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
|
||||
}
|
||||
@@ -639,6 +697,7 @@ 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))
|
||||
@@ -646,6 +705,9 @@ 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)
|
||||
@@ -801,6 +863,25 @@ 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");
|
||||
|
||||
@@ -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::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,10 +32,19 @@ 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,
|
||||
@@ -156,7 +165,9 @@ 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")?)
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ 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 {
|
||||
@@ -33,6 +35,7 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +175,12 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -155,6 +155,9 @@ 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);
|
||||
|
||||
@@ -142,6 +142,21 @@ 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(
|
||||
@@ -308,6 +323,9 @@ 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
|
||||
});
|
||||
|
||||
@@ -34,6 +34,42 @@ 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>> {
|
||||
@@ -144,6 +180,10 @@ 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
|
||||
|
||||
@@ -12,6 +12,76 @@ 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")]
|
||||
@@ -182,8 +252,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 = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
@@ -197,9 +267,21 @@ 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)?;
|
||||
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()
|
||||
});
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
|
||||
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,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -222,9 +304,148 @@ pub async fn vcs_blame(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
/// 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)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn vcs_commit_files(
|
||||
@@ -233,8 +454,8 @@ pub async fn vcs_commit_files(
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
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
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -244,13 +465,13 @@ pub async fn vcs_commits_changes(
|
||||
repo: Option<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
@@ -605,8 +826,8 @@ pub async fn vcs_config_get(
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
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
|
||||
}
|
||||
|
||||
/// Local history operation that may take a while (hooks, large checkouts); git's
|
||||
@@ -667,8 +888,8 @@ pub async fn vcs_create_tag(
|
||||
}
|
||||
|
||||
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
}
|
||||
|
||||
/// Merge dialog: merges `branches` into the current branch.
|
||||
@@ -704,8 +925,8 @@ pub async fn vcs_rebase_with(
|
||||
}
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_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<()> {
|
||||
@@ -792,8 +1013,8 @@ pub async fn vcs_diff_with_worktree(
|
||||
repo: Option<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
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
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -804,8 +1025,8 @@ pub async fn vcs_diff_revisions(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
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
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
@@ -987,7 +1208,30 @@ 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
|
||||
});
|
||||
|
||||
@@ -667,6 +667,17 @@ 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
|
||||
|
||||
@@ -165,6 +165,16 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index
|
||||
/// read. Reads it when none has been read yet (first run, nothing cached).
|
||||
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
|
||||
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
|
||||
self.refresh_market()
|
||||
} else {
|
||||
Ok(self.market_plugins())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
|
||||
@@ -292,6 +292,21 @@ 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,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewChanged { .. }
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ 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,
|
||||
|
||||
@@ -378,9 +378,19 @@ 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 {
|
||||
|
||||
+170
-16
@@ -34,6 +34,8 @@ 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`.
|
||||
@@ -56,6 +58,9 @@ 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.
|
||||
@@ -83,6 +88,7 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
@@ -204,6 +210,7 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
@@ -218,6 +225,8 @@ 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());
|
||||
@@ -302,12 +311,16 @@ impl Host {
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
let (ignored, ignored_gone) = ignored_changes(batch);
|
||||
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
lsp.watched_files_changed(&batch.created, &batch.modified, &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();
|
||||
}
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
// 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).
|
||||
@@ -411,7 +424,16 @@ impl Host {
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
|
||||
let w = content_root_watcher(
|
||||
r,
|
||||
&excludes,
|
||||
&sink,
|
||||
&index,
|
||||
&documents,
|
||||
&history,
|
||||
&lsp,
|
||||
Some(&kotlin_bridge),
|
||||
)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
@@ -444,6 +466,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos,
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
@@ -458,6 +481,7 @@ 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,
|
||||
});
|
||||
@@ -467,6 +491,14 @@ 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 || {
|
||||
@@ -489,12 +521,15 @@ impl Host {
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
)
|
||||
.with_global_sources(self.data_dir.clone()),
|
||||
);
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
@@ -536,6 +571,7 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
|
||||
let effective = settings.effective();
|
||||
@@ -568,6 +604,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos: Arc::default(),
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
@@ -582,6 +619,7 @@ 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,
|
||||
@@ -664,7 +702,14 @@ fn scan_repositories(
|
||||
build_cache: &Path,
|
||||
) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
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 mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root, build_cache));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
@@ -711,6 +756,51 @@ 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) {
|
||||
@@ -816,7 +906,8 @@ 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 files skipped): the files a references search opens on its server.
|
||||
/// `.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.
|
||||
pub fn reference_candidates(
|
||||
&self,
|
||||
q: &ide_lsp::CandidateQuery,
|
||||
@@ -894,6 +985,7 @@ impl Project {
|
||||
&self.index,
|
||||
&self.documents,
|
||||
&self.history,
|
||||
&self.lsp,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
@@ -925,7 +1017,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_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
Ok(ide_vfs::detect_encoding(&bytes, fallback))
|
||||
}
|
||||
|
||||
@@ -948,6 +1040,11 @@ 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) {
|
||||
@@ -991,6 +1088,31 @@ 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],
|
||||
@@ -998,21 +1120,28 @@ 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) = (index.clone(), documents.clone(), history.clone());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
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();
|
||||
}
|
||||
if let Some(bridge) = &kotlin_bridge {
|
||||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
}
|
||||
documents.on_disk_changed(&changed);
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
)
|
||||
@@ -1020,6 +1149,13 @@ 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.
|
||||
@@ -1436,3 +1572,21 @@ 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,6 +1,7 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
//! (the OS credential store) instead of `settings.toml`, or for the session
|
||||
//! when there is no credential store.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
@@ -12,7 +13,9 @@ impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
let session = || self.proxy_session_password.lock().clone();
|
||||
// The session copy is the latest write that could not be saved.
|
||||
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
@@ -40,11 +43,70 @@ impl Host {
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
let password = password.as_str().filter(|p| !p.is_empty());
|
||||
let mut session = self.proxy_session_password.lock();
|
||||
*session = None;
|
||||
match password {
|
||||
Some(p) => {
|
||||
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
|
||||
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
|
||||
*session = Some(p.to_owned());
|
||||
}
|
||||
}
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A machine without a credential store (a headless Linux host).
|
||||
struct NoStore;
|
||||
|
||||
impl Vault for NoStore {
|
||||
fn available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_credential_store_the_password_lasts_the_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut host = Host::new(crate::HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let path = tmp.path().join("data/proxy-password.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
|
||||
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
|
||||
|
||||
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
|
||||
assert_eq!(left, None);
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
|
||||
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
|
||||
}
|
||||
}
|
||||
@@ -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 { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
|
||||
@@ -222,10 +222,12 @@ 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"
|
||||
@@ -304,7 +306,16 @@ 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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
|
||||
ide_terminal::TerminalOptions {
|
||||
@@ -52,3 +53,53 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
|
||||
host.terminals.kill_key("other");
|
||||
});
|
||||
}
|
||||
|
||||
/// The desktop closes a project on the event-loop thread while runtime workers may be
|
||||
/// blocked waiting for that thread (IPC responses, window queries): closing must not
|
||||
/// wait for workers that need the runtime, and exit still waits for them once free.
|
||||
#[test]
|
||||
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().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: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// Occupy the only runtime worker until "the event loop" answers.
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
runtime.spawn(async move {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
started_rx.recv().unwrap();
|
||||
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
||||
let closer = host.clone();
|
||||
std::thread::spawn(move || {
|
||||
closer.close_project_detached("main");
|
||||
let _ = closed_tx.send(());
|
||||
});
|
||||
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
|
||||
release_tx.send(()).unwrap();
|
||||
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
|
||||
assert!(host.project("main").is_err(), "the window no longer has the project");
|
||||
host.open_project("main", &root).expect("the window opens a project once the close has finished");
|
||||
|
||||
let started = Instant::now();
|
||||
host.shutdown_notebooks_on_exit();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
|
||||
}
|
||||
@@ -196,6 +196,28 @@ fn write_index(
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// First run: nothing cached yet, so listing the marketplace reads its index
|
||||
/// instead of showing an empty one; later listings use the index read.
|
||||
#[test]
|
||||
fn the_first_marketplace_listing_reads_the_index() {
|
||||
let f = fixture();
|
||||
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
|
||||
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let url = write_index(&market, &key, vec![entry], vec![], false);
|
||||
f.host.set_market_url(Some(url.clone()));
|
||||
|
||||
let listed = f.host.market_listing(false).unwrap();
|
||||
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
|
||||
|
||||
std::fs::remove_file(&url).unwrap();
|
||||
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
|
||||
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
|
||||
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
|
||||
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
//! pinned versions and runs one language per job:
|
||||
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>`.
|
||||
|
||||
use ide_core::{ChangeSet, DocId, IdeEvent, Pos, Range, RequestId, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_lsp::types::{FormatOptions, GotoKind};
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
/// `word` on the first line of `file` that contains `line`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct At {
|
||||
file: &'static str,
|
||||
line: &'static str,
|
||||
word: &'static str,
|
||||
}
|
||||
|
||||
const fn at(file: &'static str, line: &'static str, word: &'static str) -> At {
|
||||
At { file, line, word }
|
||||
}
|
||||
|
||||
/// A line typed after the first line of `file` containing `after`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Typed {
|
||||
file: &'static str,
|
||||
after: &'static str,
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
const fn typed(file: &'static str, after: &'static str, text: &'static str) -> Typed {
|
||||
Typed { file, after, text }
|
||||
}
|
||||
|
||||
/// One language's checklist. Every check names what it looks for, so a failure says which
|
||||
/// operation broke.
|
||||
struct Checks {
|
||||
fixture: &'static str,
|
||||
/// Files written into the project copy before it opens.
|
||||
files: &'static [(&'static str, &'static str)],
|
||||
/// How long the first answer may take (project import and indexing).
|
||||
warm_up: Duration,
|
||||
/// Go to definition from the first spot lands on the second.
|
||||
definition: (At, At),
|
||||
/// Find Usages of the symbol lists usages in these files.
|
||||
references: (At, &'static [&'static str]),
|
||||
/// Hover shows this text.
|
||||
hover: (At, &'static str),
|
||||
/// Completion at the end of the typed line offers this item.
|
||||
completion: (Typed, &'static str),
|
||||
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
|
||||
/// file contain the last text.
|
||||
quick_fix: QuickFix,
|
||||
/// With the word selected, this intention is offered with an edit or command.
|
||||
intention: (At, &'static str),
|
||||
/// Formatting turns the typed line into the expected one.
|
||||
format: (Typed, &'static str),
|
||||
indent: Indent,
|
||||
/// Renaming the symbol to the new name edits these files.
|
||||
rename: (At, &'static str, &'static [&'static str]),
|
||||
/// Parameter info in a call being typed: a signature naming this parameter.
|
||||
signature: (Typed, &'static str),
|
||||
/// The Structure view of a file: these symbols, at any depth.
|
||||
outline: (&'static str, &'static [&'static str]),
|
||||
/// Completing a name the file doesn't import yet also imports it.
|
||||
auto_import: AutoImport,
|
||||
/// Optimize Imports, with an import typed after a line.
|
||||
organize_imports: Option<(Typed, Imports)>,
|
||||
/// Moving a file updates its references: (file, new path, a file that then contains this).
|
||||
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AutoImport {
|
||||
/// The line typed, ending in the start of the name.
|
||||
typed: Typed,
|
||||
/// The completion item.
|
||||
label: &'static str,
|
||||
/// What the item's import adds to the file.
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// What Optimize Imports does with the typed import.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Imports {
|
||||
/// Removes it (unused).
|
||||
Removed,
|
||||
/// Sorts it before this line.
|
||||
Before(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct QuickFix {
|
||||
typed: Typed,
|
||||
diagnostic: &'static str,
|
||||
title: &'static str,
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// The editor's indentation, sent with a format request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Indent {
|
||||
Spaces(u32),
|
||||
Tabs,
|
||||
}
|
||||
|
||||
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
||||
|
||||
struct Smoke {
|
||||
language: &'static str,
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
events: Events,
|
||||
warm_up: Duration,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn fixture_dir(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(name)
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name();
|
||||
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
|
||||
continue;
|
||||
}
|
||||
let target = to.join(&name);
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&entry.path(), &target);
|
||||
} else {
|
||||
std::fs::copy(entry.path(), target).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16_len(s: &str) -> u32 {
|
||||
s.encode_utf16().count() as u32
|
||||
}
|
||||
|
||||
/// The 0-based line index and text of the first line containing `needle`.
|
||||
fn find_line<'a>(text: &'a str, needle: &str, what: &str) -> (u32, &'a str) {
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.find(|(_, l)| l.contains(needle))
|
||||
.map(|(i, l)| (i as u32, l))
|
||||
.unwrap_or_else(|| panic!("{what}: no line containing {needle:?}"))
|
||||
}
|
||||
|
||||
/// The byte offset of `word` in `line`, not as part of a longer identifier (`parse` in `parser::parse`).
|
||||
fn find_word(line: &str, word: &str) -> Option<usize> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
line.match_indices(word).map(|(i, _)| i).find(|&i| {
|
||||
let before = line[..i].chars().next_back();
|
||||
let after = line[i + word.len()..].chars().next();
|
||||
!(word.starts_with(ident) && before.is_some_and(ident)) && !(word.ends_with(ident) && after.is_some_and(ident))
|
||||
})
|
||||
}
|
||||
|
||||
impl Smoke {
|
||||
async fn start(language: &'static str, checks: &Checks) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(checks.fixture);
|
||||
copy_dir(&fixture_dir(checks.fixture), &root);
|
||||
for (path, text) in checks.files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let events: Events = Arc::default();
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new({
|
||||
let events = events.clone();
|
||||
move |_| {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
||||
}
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project(WINDOW, &root).unwrap();
|
||||
Smoke { language, host, root, events, warm_up: checks.warm_up, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn fail(&self, check: &str, message: impl std::fmt::Display) -> ! {
|
||||
let servers = ops::lsp::lsp_servers(&self.host, WINDOW).unwrap_or_default();
|
||||
let servers: Vec<String> = servers.iter().map(|s| format!("{} ({:?})", s.name, s.status)).collect();
|
||||
panic!("{} — {check}: {message}\nservers: {servers:?}", self.language)
|
||||
}
|
||||
|
||||
/// Retries `f` until it succeeds (servers answer emptily while importing and indexing); on
|
||||
/// timeout, fails with its last answer.
|
||||
async fn retry<T, D: std::fmt::Debug, F: Future<Output = Result<T, D>>>(
|
||||
&self,
|
||||
check: &str,
|
||||
timeout: Duration,
|
||||
mut f: impl FnMut() -> F,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let last = match f().await {
|
||||
Ok(v) => return v,
|
||||
Err(last) => last,
|
||||
};
|
||||
if Instant::now() > deadline {
|
||||
self.fail(check, format_args!("timed out after {timeout:?}; last answer: {last:?}"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn doc(&self, file: &str) -> (DocId, String) {
|
||||
let info = ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap();
|
||||
let info = ops::doc::snapshot(&self.host, WINDOW, info.id).await.unwrap();
|
||||
(info.id, info.text)
|
||||
}
|
||||
|
||||
async fn locate(&self, at: At) -> (DocId, Pos) {
|
||||
let (doc, text) = self.doc(at.file).await;
|
||||
let (line, content) = find_line(&text, at.line, at.file);
|
||||
let col = find_word(content, at.word).unwrap_or_else(|| panic!("{at:?}: word not on the line"));
|
||||
(doc, Pos::new(line, utf16_len(&content[..col])))
|
||||
}
|
||||
|
||||
async fn word_range(&self, at: At) -> (DocId, Range) {
|
||||
let (doc, start) = self.locate(at).await;
|
||||
(doc, Range::new(start, Pos::new(start.line, start.col + utf16_len(at.word))))
|
||||
}
|
||||
|
||||
/// Types a new line; returns the position at its end and the text before it.
|
||||
async fn type_line(&self, t: Typed) -> (DocId, Pos, String) {
|
||||
let (doc, text) = self.doc(t.file).await;
|
||||
let (line, content) = find_line(&text, t.after, t.file);
|
||||
let line_start: usize = text.lines().take(line as usize).map(|l| l.len() + 1).sum();
|
||||
let end = utf16_len(&text[..line_start + content.len()]);
|
||||
self.replace(doc, end, end, &format!("\n{}", t.text)).await;
|
||||
let col = utf16_len(t.text);
|
||||
(doc, Pos::new(line + 1, col), text)
|
||||
}
|
||||
|
||||
async fn replace(&self, doc: DocId, from: u32, to: u32, insert: &str) {
|
||||
let version = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().version;
|
||||
let changes = ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] };
|
||||
ops::doc::edit(&self.host, WINDOW, doc, version, changes).unwrap();
|
||||
}
|
||||
|
||||
async fn restore(&self, doc: DocId, text: &str) {
|
||||
let current = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text;
|
||||
self.replace(doc, 0, utf16_len(¤t), text).await;
|
||||
}
|
||||
|
||||
fn path(&self, file: &str) -> PathBuf {
|
||||
self.root.join(file)
|
||||
}
|
||||
|
||||
fn files<P: AsRef<Path>>(&self, paths: impl IntoIterator<Item = P>) -> BTreeSet<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.as_ref().strip_prefix(&self.root).ok()?;
|
||||
Some(p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn definition(&self, from: At, to: At) {
|
||||
let (doc, pos) = self.locate(from).await;
|
||||
let (_, target) = self.locate(to).await;
|
||||
let want = self.path(to.file);
|
||||
self.retry("go to definition", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_goto(&self.host, WINDOW, doc, pos, GotoKind::Definition).await;
|
||||
match &found {
|
||||
Ok(locs) if locs.iter().any(|l| *l.path == *want && l.range.start.line == target.line) => Ok(()),
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn references(&self, of: At, files: &[&str]) {
|
||||
let (doc, pos) = self.locate(of).await;
|
||||
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
||||
self.retry("find usages", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
|
||||
match &found {
|
||||
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
|
||||
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn hover(&self, over: At, shows: &str) {
|
||||
let (doc, pos) = self.locate(over).await;
|
||||
self.retry("hover", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_hover(&self.host, WINDOW, RequestId(2), doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.contents.value.contains(shows) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.contents.value))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn completion(&self, line: Typed, offers: &str) {
|
||||
let (doc, pos, before) = self.type_line(line).await;
|
||||
let trigger = line.text.ends_with('.').then(|| ".".to_string());
|
||||
self.retry("completion", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(3), doc, pos, trigger.clone()).await;
|
||||
match &found {
|
||||
Ok(list)
|
||||
if list.items.iter().any(|i| i.label == offers || i.filter_text.as_deref() == Some(offers)) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(found.map(|l| l.items.into_iter().take(30).map(|i| i.label).collect::<Vec<_>>())),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn diagnostic(&self, file: &str, message: &str) -> Range {
|
||||
let path = self.path(file);
|
||||
self.retry("diagnostics", self.warm_up, || async {
|
||||
let events = self.events.lock().unwrap();
|
||||
let latest = events.iter().rev().filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, source } if **p == *path => Some((source, items)),
|
||||
_ => None,
|
||||
});
|
||||
let mut seen = Vec::new();
|
||||
for (source, items) in latest {
|
||||
if let Some(d) = items.iter().find(|d| d.message.contains(message)) {
|
||||
return Ok(d.range);
|
||||
}
|
||||
seen.push(format!("{source}: {:?}", items.iter().map(|d| &d.message).collect::<Vec<_>>()));
|
||||
}
|
||||
Err(seen.into_iter().take(4).collect::<Vec<_>>())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The code actions over `range` (all kinds, as the ⌥⏎ popup asks), the one titled `title`.
|
||||
async fn action(&self, check: &str, doc: DocId, range: Range, title: &str) -> ide_lsp::types::CodeAction {
|
||||
let action = self
|
||||
.retry(check, self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, range, vec![]).await;
|
||||
match found.as_ref().ok().and_then(|a| a.iter().find(|a| a.title.contains(title))) {
|
||||
Some(action) => Ok(action.clone()),
|
||||
None => Err(found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if action.edit.is_some() || action.command.is_some() {
|
||||
return action;
|
||||
}
|
||||
ops::lsp::lsp_resolve_code_action(&self.host, WINDOW, doc, action)
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
|
||||
}
|
||||
|
||||
async fn quick_fix(&self, fix: QuickFix) {
|
||||
let (doc, _, before) = self.type_line(fix.typed).await;
|
||||
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
|
||||
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
|
||||
let action = self.action("quick fix", doc, range, fix.title).await;
|
||||
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
|
||||
self.retry("quick fix", Duration::from_secs(10), || async {
|
||||
let text = self.doc(fix.typed.file).await.1;
|
||||
if text.contains(fix.result) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn intention(&self, on: At, title: &str) {
|
||||
let (doc, range) = self.word_range(on).await;
|
||||
let action = self.action("intention", doc, range, title).await;
|
||||
let edits = action.edit.as_ref().is_some_and(|e| !e.files.is_empty());
|
||||
if !edits && action.command.is_none() {
|
||||
self.fail("intention", format_args!("{title:?} has neither an edit nor a command: {action:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn format(&self, line: Typed, indent: Indent, formatted: &str) {
|
||||
let (doc, _, before) = self.type_line(line).await;
|
||||
assert!(!before.lines().any(|l| l == formatted), "{line:?}: the file already has {formatted:?}");
|
||||
let (tab_size, insert_spaces) = match indent {
|
||||
Indent::Spaces(n) => (n, true),
|
||||
Indent::Tabs => (4, false),
|
||||
};
|
||||
let options = FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
|
||||
let text = self
|
||||
.retry("format", self.warm_up, || async {
|
||||
let edits = ops::lsp::lsp_format(&self.host, WINDOW, doc, None, options.clone()).await;
|
||||
let text = self.doc(line.file).await.1;
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
match &edits {
|
||||
Ok(edits) if !edits.is_empty() && document.apply_edits(edits).is_ok() => Ok(document.text()),
|
||||
_ => Err(edits),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if !text.lines().any(|l| l == formatted) {
|
||||
self.fail("format", format_args!("no line {formatted:?} in:\n{text}"));
|
||||
}
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn rename(&self, symbol: At, to: &str, files: &[&str]) {
|
||||
let (doc, pos) = self.locate(symbol).await;
|
||||
let prepared = ops::lsp::lsp_prepare_rename(&self.host, WINDOW, doc, pos).await;
|
||||
if !matches!(prepared, Ok(Some(_))) {
|
||||
self.fail("rename", format_args!("prepare rename: {prepared:?}"));
|
||||
}
|
||||
let renamed = ops::lsp::lsp_rename(&self.host, WINDOW, RequestId(5), doc, pos, to.into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("rename", e));
|
||||
let edited = self.files(renamed.edit.files.iter().filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(path.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
||||
if !want.is_subset(&edited) {
|
||||
self.fail("rename", format_args!("edits {edited:?}, expected at least {want:?}: {:?}", renamed.edit));
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
for file in files {
|
||||
before.push(self.doc(file).await.1.matches(to).count());
|
||||
}
|
||||
ops::lsp::apply_workspace_edit(&self.host, WINDOW, renamed.edit)
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("rename", format_args!("applying the edit: {e}")));
|
||||
for (file, before) in files.iter().zip(before) {
|
||||
if self.doc(file).await.1.matches(to).count() <= before {
|
||||
self.fail("rename", format_args!("{file} has no new {to:?} after the rename"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn signature(&self, call: Typed, parameter: &str) {
|
||||
let (doc, pos, before) = self.type_line(call).await;
|
||||
self.retry("parameter info", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn outline(&self, file: &str, symbols: &[&str]) {
|
||||
let (doc, _) = self.doc(file).await;
|
||||
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
|
||||
for item in items {
|
||||
out.insert(item.name.clone());
|
||||
names(&item.children, out);
|
||||
}
|
||||
}
|
||||
self.retry("structure", self.warm_up, || async {
|
||||
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
|
||||
let mut shown = BTreeSet::new();
|
||||
if let Ok(items) = &found {
|
||||
names(items, &mut shown);
|
||||
}
|
||||
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn auto_import(&self, a: AutoImport) {
|
||||
let (doc, pos, before) = self.type_line(a.typed).await;
|
||||
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
|
||||
self.retry("auto-import completion", self.warm_up, || async {
|
||||
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
|
||||
let items = match &list {
|
||||
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
let mut imports = Vec::new();
|
||||
for item in items {
|
||||
// Servers compute the import when the item is resolved (on selection).
|
||||
let item = if item.additional_edits.is_empty() {
|
||||
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
// Or accepting it runs a server command that edits the file (kotlin-lsp).
|
||||
if let Some(command) = item.command.clone() {
|
||||
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
if ran.is_ok() && text.contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
|
||||
}
|
||||
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
|
||||
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn organize_imports(&self, import: Typed, expect: Imports) {
|
||||
let (doc, _, before) = self.type_line(import).await;
|
||||
let typed = import.text.trim();
|
||||
let organized = |text: &str| match expect {
|
||||
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
|
||||
Imports::Before(line) => {
|
||||
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
|
||||
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
|
||||
}
|
||||
};
|
||||
self.retry("optimize imports", self.warm_up, || async {
|
||||
let text = self.doc(import.file).await.1;
|
||||
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
|
||||
let last = text.split('\n').count() as u32 - 1;
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
|
||||
let kind = "source.organizeImports".to_string();
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
|
||||
let actions: Vec<_> = match &found {
|
||||
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
|
||||
return Err(format!(
|
||||
"no {kind} action: {:?}",
|
||||
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
|
||||
));
|
||||
};
|
||||
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
|
||||
let text = self.doc(import.file).await.1;
|
||||
if organized(&text) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
|
||||
// The servers know the referencing file (opened, as in an editor).
|
||||
let _ = self.doc(referencing).await;
|
||||
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
|
||||
let edit = self
|
||||
.retry("move file", self.warm_up, || async {
|
||||
let found =
|
||||
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
|
||||
let want = self.path(referencing);
|
||||
match &found {
|
||||
Ok(e)
|
||||
if e.edit
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
|
||||
{
|
||||
Ok(e.edit.clone())
|
||||
}
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("move file", e));
|
||||
if !self.path(to).is_file() {
|
||||
self.fail("move file", format_args!("{to} doesn't exist after the move"));
|
||||
}
|
||||
let text = self.doc(referencing).await.1;
|
||||
if !text.contains(result) {
|
||||
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
self.hover(c.hover.0, c.hover.1).await;
|
||||
self.completion(c.completion.0, c.completion.1).await;
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.signature(c.signature.0, c.signature.1).await;
|
||||
self.outline(c.outline.0, c.outline.1).await;
|
||||
self.auto_import(c.auto_import).await;
|
||||
if let Some((import, expect)) = c.organize_imports {
|
||||
self.organize_imports(import, expect).await;
|
||||
}
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
// Last: it moves a file the other checks use.
|
||||
if let Some((from, to, referencing, result)) = c.file_move {
|
||||
self.file_move(from, to, referencing, result).await;
|
||||
}
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(language: &'static str, checks: Checks) {
|
||||
let smoke = Smoke::start(language, &checks).await;
|
||||
smoke.run(&checks).await;
|
||||
}
|
||||
|
||||
const TYPESCRIPT: Checks = Checks {
|
||||
fixture: "ts-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
at("src/money.ts", "export function formatMoney", "formatMoney"),
|
||||
),
|
||||
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
|
||||
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
|
||||
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
|
||||
diagnostic: "Cannot find name 'addMoney'",
|
||||
title: "import from \"./money.ts\"",
|
||||
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
|
||||
},
|
||||
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
|
||||
format: (
|
||||
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
|
||||
" const shown = { total, count: cart.count }",
|
||||
),
|
||||
indent: Indent::Spaces(2),
|
||||
rename: (
|
||||
// From a usage in another file: the declaration is renamed too, not aliased in the import.
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
|
||||
outline: ("src/cart.ts", &["Cart", "add", "total"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
|
||||
label: "addMoney",
|
||||
result: "addMoney",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed(
|
||||
"src/index.ts",
|
||||
"import { cents, formatMoney } from \"./money.ts\";",
|
||||
"import { readFileSync } from \"node:fs\";",
|
||||
),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs vtsls"]
|
||||
async fn typescript() {
|
||||
smoke("typescript", TYPESCRIPT).await;
|
||||
}
|
||||
|
||||
const PYTHON: Checks = Checks {
|
||||
fixture: "python-app",
|
||||
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
|
||||
files: &[("ruff.toml", "")],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
),
|
||||
references: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
|
||||
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
|
||||
diagnostic: "\"Task\" is not defined",
|
||||
title: "from .models import Task",
|
||||
result: "from .models import Priority, Task, TaskList",
|
||||
},
|
||||
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
|
||||
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
|
||||
format: (
|
||||
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
|
||||
" shown2 = {\"a\": 1, \"b\": 2}",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
|
||||
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
|
||||
label: "datetime",
|
||||
result: "import datetime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
|
||||
Imports::Before("import os"),
|
||||
)),
|
||||
// basedpyright has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs basedpyright and ruff"]
|
||||
async fn python() {
|
||||
smoke("python", PYTHON).await;
|
||||
}
|
||||
|
||||
const RUST: Checks = Checks {
|
||||
fixture: "rust-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
|
||||
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
|
||||
),
|
||||
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
|
||||
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
|
||||
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
|
||||
quick_fix: QuickFix {
|
||||
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
|
||||
typed: typed(
|
||||
"src/main.rs",
|
||||
"let mut inventory = Inventory::new();",
|
||||
" let _blank = parser::ParseError { line: 1 };",
|
||||
),
|
||||
diagnostic: "missing structure fields",
|
||||
title: "Fill struct fields",
|
||||
result: "ParseError { line: 1, message",
|
||||
},
|
||||
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
|
||||
format: (
|
||||
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
|
||||
" let _spaced = (1, 2);",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
|
||||
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
|
||||
label: "HashSet",
|
||||
result: "use std::collections::HashSet;",
|
||||
},
|
||||
// rust-analyzer has no `source.organizeImports`.
|
||||
organize_imports: None,
|
||||
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs rust-analyzer"]
|
||||
async fn rust() {
|
||||
smoke("rust", RUST).await;
|
||||
}
|
||||
|
||||
const GO: Checks = Checks {
|
||||
fixture: "go-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("cmd/server/main.go", "s := store.New()", "New"),
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
),
|
||||
references: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
hover: (
|
||||
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
|
||||
"func (s *store.Store) Search(q string) []store.Note",
|
||||
),
|
||||
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
|
||||
diagnostic: "undefined: fmt",
|
||||
title: "\"fmt\"",
|
||||
result: "\t\"fmt\"",
|
||||
},
|
||||
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
|
||||
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
|
||||
indent: Indent::Tabs,
|
||||
rename: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
|
||||
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
|
||||
label: "ToUpper",
|
||||
result: "\t\"strings\"",
|
||||
},
|
||||
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
|
||||
// gopls renames packages, not files: moving a Go file changes no references.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs gopls"]
|
||||
async fn go() {
|
||||
smoke("go", GO).await;
|
||||
}
|
||||
|
||||
const JAVA: Checks = Checks {
|
||||
fixture: "java-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(300),
|
||||
definition: (
|
||||
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
),
|
||||
references: (
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
|
||||
),
|
||||
hover: (
|
||||
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
|
||||
"long com.example.orders.OrderBook.revenueCents()",
|
||||
),
|
||||
completion: (
|
||||
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
|
||||
"largest",
|
||||
),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" List<Order> all = book.byCustomer(\"alice\");",
|
||||
),
|
||||
diagnostic: "List cannot be resolved to a type",
|
||||
title: "Import 'List' (java.util)",
|
||||
result: "import java.util.List;",
|
||||
},
|
||||
intention: (
|
||||
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
|
||||
"Extract to local variable",
|
||||
),
|
||||
format: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" long doubled=revenue*2;",
|
||||
),
|
||||
" long doubled = revenue * 2;",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
"submit",
|
||||
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
|
||||
),
|
||||
signature: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" book.place(",
|
||||
),
|
||||
"customer",
|
||||
),
|
||||
outline: (
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
|
||||
),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" ArrayLis",
|
||||
),
|
||||
label: "ArrayList",
|
||||
result: "import java.util.ArrayList;",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some((
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
"src/main/java/com/example/orders/Ledger.java",
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"Ledger book",
|
||||
)),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs jdtls and JDK 21"]
|
||||
async fn java() {
|
||||
smoke("java", JAVA).await;
|
||||
}
|
||||
|
||||
const KOTLIN: Checks = Checks {
|
||||
fixture: "kotlin-gradle",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(420),
|
||||
definition: (
|
||||
at("src/main/kotlin/com/example/library/App.kt", "val library = Library()", "Library"),
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "class Library(", "Library"),
|
||||
),
|
||||
references: (
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
hover: (
|
||||
at("src/main/kotlin/com/example/library/App.kt", "library.search(\"java\")", "search"),
|
||||
"fun search(query: String): List<Book>",
|
||||
),
|
||||
completion: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library."),
|
||||
"lend",
|
||||
),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTime.now()",
|
||||
),
|
||||
diagnostic: "Unresolved reference",
|
||||
title: "Import",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
intention: (
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
|
||||
"block body",
|
||||
),
|
||||
format: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
|
||||
" val pair = listOf(1, 2)",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
||||
"find",
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
signature: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
|
||||
"isbn",
|
||||
),
|
||||
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTi",
|
||||
),
|
||||
label: "LocalDateTime",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
// kotlin-lsp has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs kotlin-lsp, a JDK and Gradle"]
|
||||
async fn kotlin() {
|
||||
smoke("kotlin", KOTLIN).await;
|
||||
}
|
||||
@@ -493,12 +493,15 @@ fn running_calls_can_be_cancelled() {
|
||||
let f = f.clone();
|
||||
std::thread::spawn(move || f.run("misbehave", "spin", None))
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let started = Instant::now();
|
||||
f.host.cancel("misbehave");
|
||||
// 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));
|
||||
}
|
||||
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));
|
||||
@@ -606,8 +609,9 @@ fn providers_of_several_plugins_run_concurrently() {
|
||||
text: text.into(),
|
||||
};
|
||||
let at = ide_core::Pos::new(0, 0);
|
||||
// Activation is not what is measured.
|
||||
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
|
||||
// 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));
|
||||
|
||||
let started = Instant::now();
|
||||
let items = host.provide_completion(&doc("sleep:500"), at, None);
|
||||
@@ -635,7 +639,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(), 8, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 10, "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);
|
||||
|
||||
@@ -1673,22 +1677,21 @@ fn official_ai_completion_cancels_superseded_requests() {
|
||||
};
|
||||
let at = ide_core::Pos::new(1, 4);
|
||||
let host = Arc::new(f);
|
||||
// A slow request, superseded 200 ms later by the next keystroke's.
|
||||
// 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).
|
||||
let first = ide_core::CancellationToken::new();
|
||||
let slow = {
|
||||
let (host, doc, first) = (host.clone(), doc.clone(), first.clone());
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
let r = host.host.provide_inline_completion(&doc, at, &first);
|
||||
(r, started.elapsed())
|
||||
})
|
||||
std::thread::spawn(move || (host.host.provide_inline_completion(&doc, at, &first), Instant::now()))
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(eventually(|| seen.lock().len() == 1), "the first request reaches the server");
|
||||
let cancelled = Instant::now();
|
||||
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_took) = slow.join().unwrap();
|
||||
let (old, old_done) = slow.join().unwrap();
|
||||
let old_took = old_done.saturating_duration_since(cancelled);
|
||||
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"));
|
||||
@@ -1866,7 +1869,12 @@ 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);
|
||||
let to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
|
||||
// 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()
|
||||
}));
|
||||
assert_eq!(
|
||||
to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::<Vec<_>>(),
|
||||
[(java_path.clone(), ide_core::Pos::new(3, 9))]
|
||||
@@ -2037,6 +2045,83 @@ 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
|
||||
@@ -2410,3 +2495,61 @@ 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")));
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
|
||||
//! the source and test roots detected from its build file, the index (files and symbols),
|
||||
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
|
||||
//! come and go, and the Maven / Gradle model.
|
||||
//!
|
||||
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
|
||||
//! java, kotlin and python jobs):
|
||||
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_index::SymbolFilter;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Fixture {
|
||||
dir: &'static str,
|
||||
/// Detected roots: (project-relative path, kind).
|
||||
roots: &'static [(&'static str, RootKind)],
|
||||
/// Search Everywhere › Files: (query, file found).
|
||||
file: (&'static str, &'static str),
|
||||
/// Go to Class: (name, file declaring it).
|
||||
class: (&'static str, &'static str),
|
||||
/// Go to Symbol: (name, file declaring it).
|
||||
symbol: (&'static str, &'static str),
|
||||
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
|
||||
added: (&'static str, &'static str),
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(fixture: &str) -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
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(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
/// Polls `check` until it holds; fails with its last answer.
|
||||
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(()) => return,
|
||||
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
fn files(&self, query: &str) -> Vec<String> {
|
||||
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
|
||||
found.into_iter().map(|f| f.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
|
||||
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
|
||||
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
|
||||
}
|
||||
|
||||
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
|
||||
let found = self.symbols(name, filter);
|
||||
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(f: Fixture) {
|
||||
let p = open(f.dir).await;
|
||||
until("indexing", Duration::from_secs(30), || {
|
||||
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
|
||||
for (path, kind) in f.roots {
|
||||
assert!(
|
||||
roots.iter().any(|r| r.path == *path && r.kind == *kind),
|
||||
"{}: no {kind:?} root {path:?} in {roots:?}",
|
||||
f.dir
|
||||
);
|
||||
}
|
||||
|
||||
let files = p.files(f.file.0);
|
||||
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
|
||||
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
|
||||
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
|
||||
}
|
||||
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
|
||||
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
|
||||
}
|
||||
|
||||
// The index follows the file system while the project is open.
|
||||
let (added, text) = f.added;
|
||||
std::fs::write(p.root.join(added), text).unwrap();
|
||||
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
|
||||
.await;
|
||||
std::fs::remove_file(p.root.join(added)).unwrap();
|
||||
until("a deleted file's class", Duration::from_secs(20), || {
|
||||
let found = p.symbols("FreshWidget", SymbolFilter::Types);
|
||||
if found.is_empty() { Ok(()) } else { Err(found) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rust_app() {
|
||||
smoke(Fixture {
|
||||
dir: "rust-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("pars", "src/parser.rs"),
|
||||
class: ("Inventory", "src/lib.rs"),
|
||||
symbol: ("low_stock", "src/lib.rs"),
|
||||
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ts_app() {
|
||||
smoke(Fixture {
|
||||
dir: "ts-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("cart", "src/cart.ts"),
|
||||
class: ("Cart", "src/cart.ts"),
|
||||
symbol: ("formatMoney", "src/money.ts"),
|
||||
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn python_app() {
|
||||
smoke(Fixture {
|
||||
dir: "python-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("models", "src/tasks/models.py"),
|
||||
class: ("TaskList", "src/tasks/models.py"),
|
||||
symbol: ("build_demo", "src/tasks/cli.py"),
|
||||
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn go_app() {
|
||||
smoke(Fixture {
|
||||
dir: "go-app",
|
||||
roots: &[("", RootKind::Source)],
|
||||
file: ("store", "internal/store/store.go"),
|
||||
class: ("Store", "internal/store/store.go"),
|
||||
symbol: ("writeJSON", "cmd/server/main.go"),
|
||||
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn java_app() {
|
||||
smoke(Fixture {
|
||||
dir: "java-app",
|
||||
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
|
||||
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
added: (
|
||||
"src/main/java/com/example/orders/FreshWidget.java",
|
||||
"package com.example.orders;\n\npublic class FreshWidget {}\n",
|
||||
),
|
||||
})
|
||||
.await;
|
||||
let p = open("java-app").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let maven = model.maven.expect("a Maven model");
|
||||
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
|
||||
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn kotlin_gradle() {
|
||||
smoke(Fixture {
|
||||
dir: "kotlin-gradle",
|
||||
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
|
||||
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
|
||||
})
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
async fn maven_sync() {
|
||||
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
|
||||
assert_eq!(maven.sync_error, None);
|
||||
assert!(maven.synced_at.is_some());
|
||||
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
|
||||
// The resolved tree, not only the declaration.
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
// The build's own tasks: the application and Kotlin plugins' too.
|
||||
for task in ["run", "compileKotlin", "test"] {
|
||||
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs python3"]
|
||||
async fn python_venv() {
|
||||
let p = open("python-app").await;
|
||||
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
|
||||
assert!(made.unwrap().success());
|
||||
// The project's virtualenv: listed first, and the interpreter the project uses.
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
|
||||
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
|
||||
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs JDK 21"]
|
||||
async fn jdk() {
|
||||
let p = open("java-app").await;
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
|
||||
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
@@ -61,7 +62,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
|
||||
@@ -228,7 +228,9 @@ 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();
|
||||
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
|
||||
// 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")] {
|
||||
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();
|
||||
@@ -237,9 +239,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.tmp"));
|
||||
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
|
||||
let outside = tmp.path().join("outside.tmp");
|
||||
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");
|
||||
std::fs::write(&outside, "keep").unwrap();
|
||||
assert!(
|
||||
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
|
||||
|
||||
@@ -141,3 +141,19 @@ 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:?}");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! 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(¤t, 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);
|
||||
}
|
||||
@@ -175,3 +175,66 @@ 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:?}");
|
||||
}
|
||||
@@ -114,3 +114,54 @@ 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");
|
||||
}
|
||||
@@ -14,6 +14,9 @@ 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()))
|
||||
|
||||
@@ -40,6 +40,8 @@ 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)]
|
||||
@@ -72,6 +74,7 @@ pub(crate) struct FileEntry {
|
||||
pub size: u64,
|
||||
pub hash: [u8; 32],
|
||||
pub symbols: Box<[Sym]>,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
@@ -100,6 +103,7 @@ impl FileEntry {
|
||||
size: record.size,
|
||||
hash: record.hash,
|
||||
symbols,
|
||||
ignored: record.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +130,23 @@ 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,
|
||||
@@ -167,6 +180,11 @@ 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;
|
||||
@@ -176,6 +194,14 @@ 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)
|
||||
@@ -197,6 +223,7 @@ 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> {
|
||||
@@ -204,6 +231,19 @@ 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::*;
|
||||
@@ -223,6 +263,23 @@ 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();
|
||||
|
||||
+172
-21
@@ -1,10 +1,16 @@
|
||||
//! 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;
|
||||
@@ -57,11 +63,25 @@ 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 {
|
||||
@@ -177,8 +197,9 @@ impl PathFilter {
|
||||
ALWAYS_SKIP.contains(&name)
|
||||
}
|
||||
|
||||
/// Whether a path reported by the watcher should be ignored (outside the
|
||||
/// root, inside an excluded/ignored directory, or ignored itself).
|
||||
/// 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.
|
||||
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();
|
||||
@@ -198,16 +219,27 @@ impl PathFilter {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -215,43 +247,65 @@ impl PathFilter {
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.require_git(false)
|
||||
.require_git(ide_core::ignored::inside_git_repo(dir))
|
||||
.follow_links(false)
|
||||
.max_depth(max_depth)
|
||||
.filter_entry(move |e| {
|
||||
let name = e.file_name().to_string_lossy();
|
||||
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,
|
||||
}
|
||||
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
|
||||
})
|
||||
.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 };
|
||||
if entry.file_type().is_some_and(|t| t.is_file())
|
||||
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()
|
||||
&& let Ok(meta) = entry.metadata()
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
drop(tx);
|
||||
rx.into_iter().flatten().collect()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +365,103 @@ 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();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -64,6 +65,14 @@ 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<()>>>,
|
||||
@@ -159,6 +168,27 @@ 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> {
|
||||
@@ -167,6 +197,11 @@ 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();
|
||||
@@ -366,6 +401,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TIER_NAME: u32 = 1 << 20;
|
||||
const EXACT: u32 = 4000;
|
||||
@@ -251,6 +251,76 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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-2";
|
||||
const SCHEMA: &str = "openidea-index-3";
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
|
||||
@@ -111,6 +111,28 @@ 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();
|
||||
@@ -138,6 +160,23 @@ 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", "")]);
|
||||
@@ -276,6 +315,56 @@ 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();
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
//! updates queued by the watcher.
|
||||
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
||||
use crate::filter::{Found, PathFilter, mtime_of};
|
||||
use crate::filter::{Found, PathFilter};
|
||||
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::{HashSet, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -233,10 +233,7 @@ 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.mtime != f.mtime || e.size != f.size))
|
||||
.collect();
|
||||
let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect();
|
||||
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
|
||||
(todo, stale)
|
||||
};
|
||||
@@ -311,6 +308,7 @@ 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())),
|
||||
@@ -342,8 +340,8 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
|
||||
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ..Default::default() };
|
||||
if f.size > MAX_SYMBOL_FILE {
|
||||
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
|
||||
if f.ignored || f.size > MAX_SYMBOL_FILE {
|
||||
return Extracted::New(rec);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(f.size as usize);
|
||||
@@ -420,6 +418,7 @@ 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;
|
||||
@@ -430,16 +429,19 @@ impl Shared {
|
||||
}
|
||||
if meta.is_dir() {
|
||||
files.extend(filter.walk(&path, &self.cancelled));
|
||||
} else if meta.is_file() {
|
||||
files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path });
|
||||
} else if meta.is_file()
|
||||
&& let Some(dir) = path.parent()
|
||||
{
|
||||
by_dir.entry(dir.to_owned()).or_default().insert(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.mtime != f.mtime || e.size != f.size))
|
||||
.collect()
|
||||
files.into_iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect()
|
||||
};
|
||||
if !files.is_empty() {
|
||||
touched = true;
|
||||
@@ -457,7 +459,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 })
|
||||
Some(Found { path: p.clone(), mtime: e.mtime, size: e.size, ignored: e.ignored })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -216,6 +216,9 @@ 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.
|
||||
@@ -755,7 +758,8 @@ enum Incoming {
|
||||
|
||||
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
|
||||
let m = method.clone();
|
||||
let result = tokio::task::spawn_blocking(move || handler.on_request_with_data(&m, params)).await;
|
||||
let h = handler.clone();
|
||||
let result = tokio::task::spawn_blocking(move || h.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))) => {
|
||||
@@ -770,7 +774,9 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
|
||||
}),
|
||||
};
|
||||
let _ = inner.send_value(&reply);
|
||||
if inner.send_value(&reply).is_ok() {
|
||||
handler.after_response(&method);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
|
||||
@@ -251,6 +251,34 @@ 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.
|
||||
|
||||
@@ -60,6 +60,9 @@ 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.
|
||||
@@ -152,6 +155,11 @@ 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>,
|
||||
@@ -242,6 +250,10 @@ 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 {
|
||||
@@ -258,6 +270,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,10 @@ 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");
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn client_capabilities() -> Value {
|
||||
"fileOperations": { "dynamicRegistration": true, "willRename": true, "didRename": true },
|
||||
"configuration": true,
|
||||
"didChangeConfiguration": { "dynamicRegistration": false },
|
||||
"didChangeWatchedFiles": { "dynamicRegistration": false, "relativePatternSupport": false },
|
||||
"didChangeWatchedFiles": { "dynamicRegistration": true, "relativePatternSupport": true },
|
||||
"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": false },
|
||||
"diagnostics": { "refreshSupport": true },
|
||||
"textDocumentContent": { "dynamicRegistration": true }
|
||||
},
|
||||
"textDocument": {
|
||||
@@ -130,6 +130,7 @@ pub fn client_capabilities() -> Value {
|
||||
"prepareSupportDefaultBehavior": 1,
|
||||
"honorsChangeAnnotations": false
|
||||
},
|
||||
"diagnostic": { "dynamicRegistration": false, "relatedDocumentSupport": false },
|
||||
"publishDiagnostics": {
|
||||
"relatedInformation": true,
|
||||
"tagSupport": { "valueSet": [1, 2] },
|
||||
|
||||
@@ -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(false).follow_links(false).filter_entry(move |entry| {
|
||||
walk.require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false).filter_entry(move |entry| {
|
||||
if entry.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `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));
|
||||
}
|
||||
}
|
||||
@@ -130,15 +130,8 @@ impl ClientHandler {
|
||||
{
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
|
||||
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
|
||||
}
|
||||
|
||||
fn show_message(&self, mgr: &LspManager, params: &Value) {
|
||||
@@ -206,19 +199,33 @@ impl ClientHandler {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn registration(&self, params: &Value, register: bool) {
|
||||
let Some(run) = self.server.running_gen(self.generation) else { return };
|
||||
fn registration(&self, mgr: &LspManager, params: &Value, register: bool) {
|
||||
let Some(run) = self.server.initialized_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 register {
|
||||
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 {
|
||||
caps.register(method, reg.get("registerOptions"));
|
||||
} else {
|
||||
caps.unregister(method);
|
||||
}
|
||||
}
|
||||
drop(caps);
|
||||
if watchers_changed {
|
||||
mgr.file_watch_roots_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,11 +285,11 @@ impl Handler for ClientHandler {
|
||||
"workspace/configuration" => Ok(self.configuration(¶ms)),
|
||||
"workspace/workspaceFolders" => Ok(mgr.workspace_folders(&self.server)),
|
||||
"client/registerCapability" => {
|
||||
self.registration(¶ms, true);
|
||||
self.registration(&mgr, ¶ms, true);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"client/unregisterCapability" => {
|
||||
self.registration(¶ms, false);
|
||||
self.registration(&mgr, ¶ms, false);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"workspace/applyEdit" => self.apply_edit(&mgr, params),
|
||||
@@ -295,10 +302,23 @@ impl Handler for ClientHandler {
|
||||
mgr.sink().emit(IdeEvent::LspRefresh { server: self.server.id, kind });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"workspace/diagnostic/refresh" | "workspace/inlineValue/refresh" => Ok(Value::Null),
|
||||
"workspace/diagnostic/refresh" => {
|
||||
mgr.refresh_pulled_diagnostics(&self.server);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"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
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
//! (`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,5 +1,5 @@
|
||||
//! Pull Members Up / Push Members Down through the app-owned JDT extension
|
||||
//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI
|
||||
//! (`java/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};
|
||||
|
||||
+160
-13
@@ -12,15 +12,18 @@ 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;
|
||||
@@ -160,6 +163,9 @@ 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,
|
||||
@@ -177,6 +183,7 @@ 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,
|
||||
@@ -336,6 +343,7 @@ 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),
|
||||
@@ -426,6 +434,7 @@ 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 {
|
||||
@@ -440,6 +449,7 @@ 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) {
|
||||
@@ -450,6 +460,9 @@ 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);
|
||||
}
|
||||
@@ -587,6 +600,9 @@ 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);
|
||||
}
|
||||
@@ -598,29 +614,34 @@ 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 d in docs.values_mut() {
|
||||
for (id, d) in docs.iter_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);
|
||||
@@ -644,6 +665,7 @@ impl LspManager {
|
||||
None => return, // superseded instance
|
||||
};
|
||||
self.clear_server_state(&server);
|
||||
self.file_watch_roots_changed();
|
||||
if intentional {
|
||||
server.announce_down();
|
||||
return;
|
||||
@@ -843,6 +865,7 @@ 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()
|
||||
@@ -920,6 +943,30 @@ 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) {
|
||||
@@ -942,11 +989,15 @@ 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
|
||||
@@ -1642,6 +1693,20 @@ 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))
|
||||
@@ -1660,6 +1725,10 @@ 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()
|
||||
@@ -1942,7 +2011,30 @@ 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 { continue };
|
||||
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;
|
||||
};
|
||||
out.servers.push(t.server.name.clone());
|
||||
let opened = self.open_for_file_op(&t, &moves);
|
||||
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
|
||||
@@ -1973,29 +2065,78 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
/// 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;
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for s in servers {
|
||||
let Some(run) = s.running() else { continue };
|
||||
let languages = s.languages();
|
||||
let changes: Vec<Value> = [(created, 1), (modified, 2), (removed, 3)]
|
||||
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
|
||||
.into_iter()
|
||||
.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();
|
||||
.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);
|
||||
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)?;
|
||||
@@ -2451,6 +2592,12 @@ 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();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 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 });
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ 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 {
|
||||
@@ -82,6 +84,9 @@ 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>,
|
||||
@@ -92,7 +97,13 @@ 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).
|
||||
@@ -118,6 +129,7 @@ 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)),
|
||||
@@ -126,6 +138,9 @@ 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(),
|
||||
@@ -210,6 +225,8 @@ impl Server {
|
||||
return false;
|
||||
}
|
||||
st.conn = Conn::Ready(run);
|
||||
drop(st);
|
||||
self.set_initializing(None);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -234,6 +251,20 @@ 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)
|
||||
@@ -471,17 +502,71 @@ impl Server {
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) {
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut d = self.diagnostics.lock();
|
||||
self.published.lock().insert(path.clone(), Instant::now());
|
||||
if items.is_empty() {
|
||||
d.remove(&path);
|
||||
} else {
|
||||
d.insert(path, items);
|
||||
}
|
||||
set_or_remove(&mut d, path, items.clone());
|
||||
}
|
||||
self.published_notify.notify_waiters();
|
||||
items
|
||||
}
|
||||
|
||||
/// When diagnostics for `path` were last published by this server.
|
||||
@@ -506,10 +591,28 @@ 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.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ 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>>,
|
||||
}
|
||||
|
||||
@@ -74,6 +76,12 @@ 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();
|
||||
@@ -230,6 +238,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
@@ -269,6 +278,7 @@ fn harness_configured(
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -419,6 +429,7 @@ 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!(
|
||||
@@ -430,12 +441,81 @@ 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())]);
|
||||
@@ -697,6 +777,113 @@ 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())]);
|
||||
@@ -1040,6 +1227,54 @@ 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())]);
|
||||
@@ -2101,6 +2336,41 @@ 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 {
|
||||
|
||||
@@ -56,6 +56,7 @@ 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,
|
||||
@@ -134,6 +135,7 @@ 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)]));
|
||||
@@ -403,6 +405,7 @@ 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)]));
|
||||
@@ -452,6 +455,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ 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>;
|
||||
}
|
||||
@@ -467,7 +468,6 @@ 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,14 +477,7 @@ 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: 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,
|
||||
},
|
||||
source: a.source.name().to_owned(),
|
||||
shortcut: a.shortcut,
|
||||
}),
|
||||
}
|
||||
@@ -670,9 +663,17 @@ 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())
|
||||
}
|
||||
@@ -699,27 +700,20 @@ 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(|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())
|
||||
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()))
|
||||
}
|
||||
|
||||
pub fn provide_definition(
|
||||
@@ -968,6 +962,47 @@ 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))
|
||||
}
|
||||
@@ -975,6 +1010,9 @@ 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()
|
||||
@@ -1006,10 +1044,8 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
fields
|
||||
.into_iter()
|
||||
.take(MAX_VIEW_ITEMS)
|
||||
.map(|f| ViewField {
|
||||
key: clip(f.key, 128),
|
||||
label: clip(f.label, MAX_TITLE),
|
||||
value: match f.value {
|
||||
.filter_map(|f| {
|
||||
let 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),
|
||||
@@ -1017,7 +1053,10 @@ 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(),
|
||||
),
|
||||
@@ -1067,3 +1106,26 @@ 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");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,20 @@ 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 {
|
||||
@@ -348,9 +362,14 @@ 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)]
|
||||
@@ -365,6 +384,8 @@ 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)]
|
||||
|
||||
@@ -440,20 +440,11 @@ 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: events::EventKind) -> Fallible<()> {
|
||||
let kind = kind_from_wit(kind);
|
||||
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"))));
|
||||
};
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -463,8 +454,10 @@ impl events::Host for StoreData {
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
|
||||
self.host.subscribe(kind_from_wit(kind), false);
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
if let Some(kind) = EventKind::parse(&kind) {
|
||||
self.host.subscribe(kind, false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -542,10 +535,10 @@ impl diagnostics::Host for StoreData {
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(source.clone()),
|
||||
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
||||
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
@@ -647,6 +640,21 @@ 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 @@
|
||||
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build plugins/hello` (see plugins/README.md).
|
||||
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/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 @@
|
||||
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
@@ -64,6 +64,8 @@ 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 @@
|
||||
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
@@ -40,3 +40,11 @@ title = "insert"
|
||||
[[contributes.commands]]
|
||||
id = "cancel"
|
||||
title = "cancel"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "subscribe"
|
||||
title = "subscribe"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "diagnose"
|
||||
title = "diagnose"
|
||||
+52
-4
@@ -14,7 +14,9 @@ wit_bindgen::generate!({
|
||||
generate_all,
|
||||
});
|
||||
|
||||
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
|
||||
use exports::openidea::plugin::providers::{
|
||||
self, CodeAction, CompletionItem, Document, FileEdit, 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};
|
||||
@@ -67,6 +69,23 @@ 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}"))),
|
||||
}
|
||||
}
|
||||
@@ -113,14 +132,24 @@ 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: doc.path,
|
||||
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
|
||||
snippet: rich,
|
||||
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()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -128,8 +157,27 @@ impl providers::Guest for Misbehave {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
Vec::new()
|
||||
/// `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 definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
|
||||
|
||||
@@ -143,7 +143,10 @@ impl Mappings {
|
||||
}
|
||||
let (l, rest, _) = best?;
|
||||
// A `..` from the server must not leave the local folder.
|
||||
if rest.split('/').any(|s| s == ".." || s == ".") {
|
||||
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()
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
|
||||
@@ -307,6 +310,43 @@ 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() {
|
||||
|
||||
Loaded 100 of 547 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user