mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 10:49:39 -10:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7708725cb | ||
|
|
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
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Language smoke (${{ matrix.language }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- if: matrix.language == 'typescript'
|
||||
uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- if: matrix.language == 'python'
|
||||
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
|
||||
|
||||
- if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: "1.27", cache: false }
|
||||
- if: matrix.language == 'go'
|
||||
run: go install "golang.org/x/tools/gopls@$GOPLS"
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/jdtls"
|
||||
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
|
||||
- if: matrix.language == 'kotlin'
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with: { gradle-version: "${{ env.GRADLE }}" }
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
|
||||
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
@@ -24,18 +24,9 @@ jobs:
|
||||
const pr = context.payload.pull_request;
|
||||
const LABEL = 'needs-info';
|
||||
const MARKER = '<!-- pr-check -->';
|
||||
// Docs, CI and pure refactors may skip the linked issue.
|
||||
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
|
||||
|
||||
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const problems = [];
|
||||
|
||||
const linksIssue =
|
||||
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
|
||||
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
|
||||
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
|
||||
}
|
||||
|
||||
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
|
||||
if (!verified || !verified[1].trim()) {
|
||||
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
|
||||
|
||||
@@ -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,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
this worktree's running MCP bridges and actual ports.
|
||||
- `pnpm build` — typecheck + bundle frontend.
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
|
||||
+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
+36
-36
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,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 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4116,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4130,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4148,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4174,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4187,7 +4187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4207,7 +4207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4232,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4293,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4311,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4333,7 +4333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4350,7 +4350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4406,7 +4406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4432,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4455,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4475,7 +4475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4488,7 +4488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4505,7 +4505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4528,7 +4528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4547,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4567,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4580,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4599,7 +4599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4614,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4627,7 +4627,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4639,7 +4639,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4660,7 +4660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4679,7 +4679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4701,7 +4701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4717,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6189,7 +6189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.35"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
+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,9 @@ 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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -861,6 +864,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 +1101,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)]
|
||||
|
||||
@@ -923,6 +923,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 {
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
|
||||
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,
|
||||
@@ -157,6 +161,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&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")?)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -222,6 +222,29 @@ pub async fn vcs_blame(
|
||||
.await
|
||||
}
|
||||
|
||||
/// History for Selection: the commits that changed `lines` (0-based, inclusive) of the
|
||||
/// editor's text of `path`. Cancellable.
|
||||
pub async fn vcs_block_history(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
path: PathBuf,
|
||||
text: Option<String>,
|
||||
lines: (u32, u32),
|
||||
) -> IdeResult<BlockHistory> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
@@ -987,6 +1010,10 @@ 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_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
|
||||
@@ -56,6 +56,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.
|
||||
@@ -218,6 +221,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 +307,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 +420,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();
|
||||
@@ -458,6 +476,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 +486,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 || {
|
||||
@@ -582,6 +609,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,
|
||||
@@ -816,7 +844,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 +923,7 @@ impl Project {
|
||||
&self.index,
|
||||
&self.documents,
|
||||
&self.history,
|
||||
&self.lsp,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
@@ -948,6 +978,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 +1026,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 +1058,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 +1087,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 +1510,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(&[]));
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_dirs"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
@@ -304,6 +305,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_line_hunks"
|
||||
| "vcs_file_at"
|
||||
| "vcs_blame"
|
||||
| "vcs_block_history"
|
||||
| "vcs_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
|
||||
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
//! pinned versions and runs one language per job:
|
||||
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <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]),
|
||||
}
|
||||
|
||||
#[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 run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
self.hover(c.hover.0, c.hover.1).await;
|
||||
self.completion(c.completion.0, c.completion.1).await;
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(language: &'static str, checks: Checks) {
|
||||
let smoke = Smoke::start(language, &checks).await;
|
||||
smoke.run(&checks).await;
|
||||
}
|
||||
|
||||
const TYPESCRIPT: Checks = Checks {
|
||||
fixture: "ts-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
at("src/money.ts", "export function formatMoney", "formatMoney"),
|
||||
),
|
||||
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
|
||||
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
|
||||
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
|
||||
diagnostic: "Cannot find name 'addMoney'",
|
||||
title: "import from \"./money.ts\"",
|
||||
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
|
||||
},
|
||||
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
|
||||
format: (
|
||||
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
|
||||
" const shown = { total, count: cart.count }",
|
||||
),
|
||||
indent: Indent::Spaces(2),
|
||||
rename: (
|
||||
// From a usage in another file: the declaration is renamed too, not aliased in the import.
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs vtsls"]
|
||||
async fn typescript() {
|
||||
smoke("typescript", TYPESCRIPT).await;
|
||||
}
|
||||
|
||||
const PYTHON: Checks = Checks {
|
||||
fixture: "python-app",
|
||||
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
|
||||
files: &[("ruff.toml", "")],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
),
|
||||
references: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
|
||||
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
|
||||
diagnostic: "\"Task\" is not defined",
|
||||
title: "from .models import Task",
|
||||
result: "from .models import Priority, Task, TaskList",
|
||||
},
|
||||
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
|
||||
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
|
||||
format: (
|
||||
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
|
||||
" shown2 = {\"a\": 1, \"b\": 2}",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs basedpyright and ruff"]
|
||||
async fn python() {
|
||||
smoke("python", PYTHON).await;
|
||||
}
|
||||
|
||||
const RUST: Checks = Checks {
|
||||
fixture: "rust-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
|
||||
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
|
||||
),
|
||||
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
|
||||
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
|
||||
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
|
||||
quick_fix: QuickFix {
|
||||
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
|
||||
typed: typed(
|
||||
"src/main.rs",
|
||||
"let mut inventory = Inventory::new();",
|
||||
" let _blank = parser::ParseError { line: 1 };",
|
||||
),
|
||||
diagnostic: "missing structure fields",
|
||||
title: "Fill struct fields",
|
||||
result: "ParseError { line: 1, message",
|
||||
},
|
||||
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
|
||||
format: (
|
||||
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
|
||||
" let _spaced = (1, 2);",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs rust-analyzer"]
|
||||
async fn rust() {
|
||||
smoke("rust", RUST).await;
|
||||
}
|
||||
|
||||
const GO: Checks = Checks {
|
||||
fixture: "go-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("cmd/server/main.go", "s := store.New()", "New"),
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
),
|
||||
references: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
hover: (
|
||||
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
|
||||
"func (s *store.Store) Search(q string) []store.Note",
|
||||
),
|
||||
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
|
||||
diagnostic: "undefined: fmt",
|
||||
title: "\"fmt\"",
|
||||
result: "\t\"fmt\"",
|
||||
},
|
||||
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
|
||||
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
|
||||
indent: Indent::Tabs,
|
||||
rename: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs gopls"]
|
||||
async fn go() {
|
||||
smoke("go", GO).await;
|
||||
}
|
||||
|
||||
const JAVA: Checks = Checks {
|
||||
fixture: "java-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(300),
|
||||
definition: (
|
||||
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
),
|
||||
references: (
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
|
||||
),
|
||||
hover: (
|
||||
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
|
||||
"long com.example.orders.OrderBook.revenueCents()",
|
||||
),
|
||||
completion: (
|
||||
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
|
||||
"largest",
|
||||
),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" List<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"],
|
||||
),
|
||||
};
|
||||
|
||||
#[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"],
|
||||
),
|
||||
};
|
||||
|
||||
#[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);
|
||||
@@ -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))]
|
||||
@@ -2434,7 +2442,12 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
};
|
||||
let at = ide_core::Pos::new(0, 0);
|
||||
|
||||
let items = f.host.provide_completion(&doc("rich"), at, None);
|
||||
// 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()),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -167,6 +167,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 +371,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>;
|
||||
|
||||
|
||||
@@ -138,6 +138,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 +293,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};
|
||||
|
||||
+117
-12
@@ -12,11 +12,13 @@ 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;
|
||||
@@ -160,6 +162,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 +182,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 +342,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 +433,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 +448,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 +459,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 +599,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 +613,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 +664,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 +864,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 +942,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 +988,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
|
||||
@@ -1973,29 +2023,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 +2550,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.
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c9acbc021f3840649823cb7680e13907d429c6fda8271fdb0ae6e5ea89ed15ee
|
||||
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
|
||||
+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};
|
||||
|
||||
@@ -123,8 +123,21 @@ fn not_a_jdk(p: &Path) -> String {
|
||||
|
||||
/// `mvn` of the configured Maven home (`languages.java.maven.home`): the
|
||||
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`; else of the Maven the project
|
||||
/// pins for mise / asdf when it has no Maven wrapper.
|
||||
/// pins for mise / asdf when it has no Maven wrapper. With `maven.daemon` on, the
|
||||
/// Maven Daemon (`mvnd`) replaces all of that — it embeds its own Maven — unless
|
||||
/// it is not installed or the project JDK is older than the Java 17 its daemon
|
||||
/// needs, in which case the build stays on mvn (with a logged warning).
|
||||
pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
if s.languages.java.maven.daemon {
|
||||
match mvnd_command(root, s) {
|
||||
// No project JDK at all lets mvnd pick its own Java, which may still be new enough.
|
||||
Some(mvnd) if project_jdk(root, s).is_none_or(|j| j.major >= 17) => return Some(mvnd),
|
||||
Some(_) => tracing::warn!(
|
||||
"Maven daemon: the project JDK is older than the Java 17 its daemon needs; building with mvn"
|
||||
),
|
||||
None => tracing::warn!("Maven daemon is enabled but mvnd was not found; building with mvn"),
|
||||
}
|
||||
}
|
||||
let name = if cfg!(windows) { "mvn.cmd" } else { "mvn" };
|
||||
let Some(p) = setting_path(root, &s.languages.java.maven.home) else {
|
||||
if root.join("mvnw").is_file() || root.join("mvnw.cmd").is_file() {
|
||||
@@ -141,6 +154,41 @@ pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
found
|
||||
}
|
||||
|
||||
/// The `mvnd` executable when the Maven Daemon is enabled: the configured home
|
||||
/// (`languages.java.maven.mvndHome`, the executable itself or `<home>/bin/mvnd`),
|
||||
/// else `$MVND_HOME`, else `mvnd` on `PATH`, else the common Homebrew / sdkman
|
||||
/// locations. `None` = not found; the caller falls back to `mvn`.
|
||||
pub fn mvnd_command(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
let name = if cfg!(windows) { "mvnd.cmd" } else { "mvnd" };
|
||||
if let Some(p) = setting_path(root, &s.languages.java.maven.mvnd_home) {
|
||||
let found = [p.clone(), p.join("bin").join(name), p.join(name)].into_iter().find(|c| c.is_file());
|
||||
if found.is_none() {
|
||||
tracing::warn!("Maven Daemon home {} has no {name}", p.display());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
if let Some(home) = std::env::var_os("MVND_HOME").map(PathBuf::from) {
|
||||
let found = [home.join("bin").join(name), home.join(name)].into_iter().find(|c| c.is_file());
|
||||
if found.is_none() {
|
||||
tracing::warn!("MVND_HOME {} has no {name}", home.display());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
if let Some(found) = crate::exec::which(name) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(home) = crate::home_dir() {
|
||||
let sdkman = home.join(".sdkman/candidates/mvnd/current");
|
||||
let candidates = [
|
||||
sdkman.join("bin").join(name),
|
||||
PathBuf::from("/opt/homebrew/opt/mvnd").join("bin").join(name),
|
||||
PathBuf::from("/usr/local/opt/mvnd").join("bin").join(name),
|
||||
];
|
||||
return candidates.into_iter().find(|c| c.is_file());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Project launcher policy for sync and tool-window tasks.
|
||||
pub fn gradle_command(root: &Path, build_dir: &Path, s: &Settings) -> PathBuf {
|
||||
resolve_gradle_command(root, build_dir, s, true)
|
||||
@@ -301,7 +349,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
|
||||
const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"];
|
||||
|
||||
fn merge(base: &mut Value, layer: Value) {
|
||||
pub(crate) fn merge(base: &mut Value, layer: Value) {
|
||||
match (base, layer) {
|
||||
(Value::Object(base), Value::Object(layer)) => {
|
||||
for (k, v) in layer {
|
||||
@@ -562,6 +610,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
configure_vue_typescript(cfg, root, server_root, s);
|
||||
}
|
||||
|
||||
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
|
||||
if name == "intelephense"
|
||||
&& let Some(version) = crate::php::language_level(root, s)
|
||||
{
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
|
||||
}
|
||||
|
||||
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
|
||||
if let Some(dir) = node_dir(root, s) {
|
||||
path_dirs.push(dir);
|
||||
@@ -628,6 +683,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,7 +1128,7 @@ mod tests {
|
||||
/// The same jar supplies the JDT command and the private Maven output redirector.
|
||||
pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("kotlin-bridge-1.0.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar");
|
||||
let bytes = include_bytes!("../../../java/kotlin-bridge/openidea-kotlin-bridge.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
return Some(extension);
|
||||
}
|
||||
@@ -1171,7 +1171,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
return Some(extension);
|
||||
@@ -1256,10 +1256,7 @@ mod usages_bundle_tests {
|
||||
fn installs_the_exact_bundle_and_reuses_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jar = super::usages_bundle(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&jar).unwrap(),
|
||||
include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar")
|
||||
);
|
||||
assert_eq!(std::fs::read(&jar).unwrap(), include_bytes!("../../../java/java-usages/openidea-java-usages.jar"));
|
||||
assert_eq!(super::usages_bundle(dir.path()), Some(jar));
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod locate;
|
||||
pub mod lombok;
|
||||
pub mod maven_profiles;
|
||||
mod npm;
|
||||
pub mod php;
|
||||
pub mod pinned;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
|
||||
@@ -246,7 +246,7 @@ fn sources_import_lombok(root: &Path) -> bool {
|
||||
// Imports precede the first type, after at most a license header.
|
||||
const HEAD: u64 = 8 * 1024;
|
||||
let mut walker = ignore::WalkBuilder::new(root);
|
||||
walker.require_git(false).follow_links(false).filter_entry(|e| {
|
||||
walker.require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false).filter_entry(|e| {
|
||||
e.depth() == 0
|
||||
|| !e.file_type().is_some_and(|t| t.is_dir())
|
||||
|| !OUTPUT_DIRS.contains(&e.file_name().to_string_lossy().as_ref())
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
|
||||
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
|
||||
//! and the Intelephense storage that goes with it.
|
||||
|
||||
use crate::apply::{merge, merge_init_options};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
|
||||
/// `composer.json` name one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
|
||||
let explicit = s.languages.php.language_level.trim();
|
||||
if !explicit.is_empty() {
|
||||
return version(explicit).map(dotted);
|
||||
}
|
||||
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
|
||||
let composer: Value = serde_json::from_str(&text).ok()?;
|
||||
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
|
||||
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
|
||||
/// with in that storage, and reuses them after the version changes. Without a version, or
|
||||
/// with a `storagePath` of the user's, the server keeps its own storage.
|
||||
pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
if cfg.name != "intelephense" {
|
||||
return;
|
||||
}
|
||||
let mut options =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
if options.get("storagePath").is_some() {
|
||||
return;
|
||||
}
|
||||
// The version Intelephense reads: `workspace/configuration` is answered from the
|
||||
// settings merged over the initialization options (`ide_lsp::handler::all_settings`).
|
||||
if let Some(settings) = &cfg.settings {
|
||||
merge(&mut options, settings.clone());
|
||||
}
|
||||
let section = options.pointer("/settings/intelephense").unwrap_or(&options);
|
||||
let version = section.pointer("/environment/phpVersion").and_then(Value::as_str);
|
||||
let Some(version) = version.filter(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit() || c == '.')) else {
|
||||
return;
|
||||
};
|
||||
let storage = data_dir.join("lsp").join("intelephense").join(version);
|
||||
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
fn dotted((major, minor, patch): Version) -> String {
|
||||
format!("{major}.{minor}.{patch}")
|
||||
}
|
||||
|
||||
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
|
||||
fn version(s: &str) -> Option<Version> {
|
||||
let s = s.trim().trim_start_matches(['v', 'V']);
|
||||
let mut parts = s.split('.').map(|p| {
|
||||
let digits: &str = &p[..p.find(|c: char| !c.is_ascii_digit()).unwrap_or(p.len())];
|
||||
digits.parse::<u32>().ok()
|
||||
});
|
||||
let major = parts.next()??;
|
||||
let minor = parts.next().flatten().unwrap_or(0);
|
||||
let patch = parts.next().flatten().unwrap_or(0);
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// The lowest version a Composer constraint allows: the lowest lower bound of its
|
||||
/// `||` alternatives. `None` when an alternative has no lower bound (`*`, `<8.0`).
|
||||
fn lowest_allowed(constraint: &str) -> Option<Version> {
|
||||
let bounds: Option<Vec<Version>> =
|
||||
constraint.split('|').filter(|alt| !alt.trim().is_empty()).map(lower_bound).collect();
|
||||
bounds?.into_iter().min()
|
||||
}
|
||||
|
||||
/// The lower bound of constraints that must all hold (`>=7.2 <8.4`, `^7.4, !=7.4.1`,
|
||||
/// `7.2 - 8.0`): the highest of their lower bounds.
|
||||
fn lower_bound(alternative: &str) -> Option<Version> {
|
||||
// A hyphen range: its first version.
|
||||
let alternative = alternative.split(" - ").next().unwrap_or(alternative);
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
for token in alternative.split([' ', ',']).filter(|t| !t.is_empty()) {
|
||||
// `>= 7.2`: the operator and its version.
|
||||
match tokens.last_mut() {
|
||||
Some(op) if op.chars().all(|c| "<>=!~^".contains(c)) => op.push_str(token),
|
||||
_ => tokens.push(token.to_owned()),
|
||||
}
|
||||
}
|
||||
tokens
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let t = t.split('@').next().unwrap_or(t);
|
||||
if t.starts_with('<') || t.starts_with("!=") {
|
||||
return None;
|
||||
}
|
||||
version(t.trim_start_matches(['>', '=', '^', '~']))
|
||||
})
|
||||
.max()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.language_level = explicit.into();
|
||||
language_level(tmp.path(), &s)
|
||||
}
|
||||
|
||||
fn required(constraint: &str) -> Option<String> {
|
||||
level(Some(&serde_json::json!({ "require": { "php": constraint } }).to_string()), "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_version_require_php_allows() {
|
||||
assert_eq!(required("^7.4 || ^8.0").as_deref(), Some("7.4.0"));
|
||||
assert_eq!(required("^8.0 || ^7.4").as_deref(), Some("7.4.0"));
|
||||
assert_eq!(required(">=7.2 <8.4").as_deref(), Some("7.2.0"));
|
||||
assert_eq!(required(">= 7.2, < 8.4").as_deref(), Some("7.2.0"));
|
||||
assert_eq!(required("8.1.*").as_deref(), Some("8.1.0"));
|
||||
assert_eq!(required("~7.4.3").as_deref(), Some("7.4.3"));
|
||||
assert_eq!(required("7.2 - 8.0").as_deref(), Some("7.2.0"));
|
||||
assert_eq!(required("^7.3|^8.0").as_deref(), Some("7.3.0"));
|
||||
assert_eq!(required(">=8.1@dev").as_deref(), Some("8.1.0"));
|
||||
assert_eq!(required("8").as_deref(), Some("8.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_lower_bound_gives_nothing() {
|
||||
for constraint in ["*", "<8.0", "", "^7.4 || *", "dev-main"] {
|
||||
assert_eq!(required(constraint), None, "{constraint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_overrides_require() {
|
||||
let composer = r#"{"require": {"php": "^8.2"}, "config": {"platform": {"php": "7.2.34"}}}"#;
|
||||
assert_eq!(level(Some(composer), "").as_deref(), Some("7.2.34"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_requirement_gives_nothing() {
|
||||
assert_eq!(level(None, ""), None);
|
||||
assert_eq!(level(Some(r#"{"require": {"ext-json": "*"}}"#), ""), None);
|
||||
assert_eq!(level(Some("not json"), ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_php_version_gets_its_own_intelephense_storage() {
|
||||
let data = Path::new("/data");
|
||||
let server = |name: &str, options: Value| {
|
||||
let mut cfg: LanguageServerConfig =
|
||||
toml::from_str(&format!("name = \"{name}\"\ncommand = \"{name}\"")).unwrap();
|
||||
if !options.is_null() {
|
||||
merge_init_options(&mut cfg, options);
|
||||
}
|
||||
cfg
|
||||
};
|
||||
let options = |cfg: &LanguageServerConfig| serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
|
||||
let mut cfg = server("intelephense", json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false}));
|
||||
configure_intelephense_storage(&mut cfg, data);
|
||||
let storage = data.join("lsp").join("intelephense").join("7.4.0");
|
||||
assert_eq!(
|
||||
options(&cfg),
|
||||
json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false, "storagePath": storage.to_string_lossy()})
|
||||
);
|
||||
|
||||
// The version Intelephense reads wins: Settings > Language Servers `settings` over the options.
|
||||
let storage_of = |init: Value, settings: Value| {
|
||||
let mut cfg = server("intelephense", init);
|
||||
cfg.settings = Some(settings);
|
||||
configure_intelephense_storage(&mut cfg, data);
|
||||
options(&cfg)["storagePath"].clone()
|
||||
};
|
||||
let path = |v: &str| json!(data.join("lsp").join("intelephense").join(v).to_string_lossy());
|
||||
let init = json!({"environment": {"phpVersion": "7.4.0"}});
|
||||
assert_eq!(storage_of(init.clone(), json!({"environment": {"phpVersion": "8.3.0"}})), path("8.3.0"));
|
||||
assert_eq!(storage_of(init, json!({"other": 1})), path("7.4.0"));
|
||||
let nested = json!({"settings": {"intelephense": {"environment": {"phpVersion": "8.0.0"}}}});
|
||||
assert_eq!(storage_of(Value::Null, nested), path("8.0.0"));
|
||||
|
||||
// No version, the user's own storage, not a version, another server: unchanged.
|
||||
for unchanged in [
|
||||
server("intelephense", Value::Null),
|
||||
server("intelephense", json!({"environment": {"includePaths": []}})),
|
||||
server("intelephense", json!({"environment": {"phpVersion": "8.1.0"}, "storagePath": "/mine"})),
|
||||
server("intelephense", json!({"environment": {"phpVersion": "../8.1"}})),
|
||||
server("phpactor", json!({"environment": {"phpVersion": "8.1.0"}})),
|
||||
] {
|
||||
let mut cfg = unchanged.clone();
|
||||
configure_intelephense_storage(&mut cfg, data);
|
||||
assert_eq!(cfg, unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_level_wins() {
|
||||
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
|
||||
assert_eq!(level(Some(composer), "8.1").as_deref(), Some("8.1.0"));
|
||||
assert_eq!(level(None, " 5.6 ").as_deref(), Some("5.6.0"));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
@@ -1293,6 +1294,37 @@ fn php_sources_and_selected_interpreter() {
|
||||
assert!(configs[3].env.get("PATH").is_none_or(|p| !p.contains("php82")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelephense_gets_the_php_language_level() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let configured = |name: &str, s: &Settings| {
|
||||
let mut cfg = server(name);
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), s);
|
||||
cfg
|
||||
};
|
||||
let options = |cfg: LanguageServerConfig| serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
|
||||
// Nothing to resolve: the options stay as they were (the rest depends on the machine's tools).
|
||||
assert_eq!(configured("intelephense", &Settings::default()).initialization_options, None);
|
||||
|
||||
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^7.4 || ^8.0"}}"#).unwrap();
|
||||
assert_eq!(
|
||||
options(configured("intelephense", &Settings::default())),
|
||||
json!({"environment": {"phpVersion": "7.4.0"}})
|
||||
);
|
||||
for other in ["phpactor", "basedpyright", "vtsls"] {
|
||||
assert_eq!(options(configured(other, &Settings::default())).get("environment"), None, "{other}");
|
||||
}
|
||||
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.language_level = "8.1".into();
|
||||
let mut cfg = server("intelephense");
|
||||
cfg.initialization_options = Some(toml::Value::try_from(json!({"clearCache": true})).unwrap());
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_constraints_change_only_protocol_settings() {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -35,13 +35,15 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
|
||||
let tmp = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let mise = tmp.join("mise");
|
||||
let asdf = tmp.join("asdf");
|
||||
// SAFETY: the only test in this binary; nothing else reads the environment yet.
|
||||
// SAFETY: the only test in this binary that writes the environment; the daemon
|
||||
// tests below read it, so they run after this one's fixtures are in place.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.join("home"));
|
||||
std::env::set_var("MISE_DATA_DIR", &mise);
|
||||
std::env::set_var("ASDF_DATA_DIR", &asdf);
|
||||
std::env::set_var("MISE_GLOBAL_CONFIG_FILE", tmp.join("no-global.toml"));
|
||||
std::env::remove_var("JAVA_HOME");
|
||||
std::env::remove_var("MVND_HOME");
|
||||
}
|
||||
|
||||
let installs = mise.join("installs");
|
||||
@@ -131,11 +133,99 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
|
||||
ide_sdk::apply::apply_to_run_configs(&mut plain, &project, &s);
|
||||
assert_eq!(plain[0].command, installs.join("gradle/8.7/bin/gradle").to_string_lossy());
|
||||
|
||||
// The Maven daemon replaces the pinned Maven when it is on and installed; the
|
||||
// project JDK (21, from mise.toml) can host its JVM.
|
||||
let mut daemon = s.clone();
|
||||
daemon.languages.java.maven.daemon = true;
|
||||
daemon.languages.java.maven.mvnd_home = tmp.join("missing/mvnd").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
ide_sdk::apply::maven_command(&project, &daemon),
|
||||
Some(maven.join("bin/mvn")),
|
||||
"a dead-end mvnd home falls back to the pinned Maven"
|
||||
);
|
||||
let mvnd = tmp.join("mvnd");
|
||||
script(&mvnd.join("bin/mvnd"), "mvnd 1.0-m7");
|
||||
daemon.languages.java.maven.mvnd_home = mvnd.to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
ide_sdk::apply::maven_command(&project, &daemon),
|
||||
Some(mvnd.join("bin/mvnd")),
|
||||
"a bare home resolves to <home>/bin/mvnd"
|
||||
);
|
||||
// The executable itself, like `maven.home`, also works.
|
||||
daemon.languages.java.maven.mvnd_home = mvnd.join("bin/mvnd").to_string_lossy().into_owned();
|
||||
assert_eq!(ide_sdk::apply::maven_command(&project, &daemon), Some(mvnd.join("bin/mvnd")));
|
||||
|
||||
// The project's Maven wrapper keeps precedence over a pinned Maven.
|
||||
script(&project.join("mvnw"), "");
|
||||
assert_eq!(ide_sdk::apply::maven_command(&project, &s), None);
|
||||
// ...but not over the daemon.
|
||||
assert_eq!(ide_sdk::apply::maven_command(&project, &daemon), Some(mvnd.join("bin/mvnd")));
|
||||
|
||||
// A version the project pins but that isn't installed: nothing is made up.
|
||||
write(&project.join("mise.toml"), "[tools]\njava = '25'\n");
|
||||
assert_eq!(ide_sdk::apply::project_jdk(&project, &s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_daemon_run_configs_follow_the_setting_and_the_jdk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let project = tmp.join("project");
|
||||
write(&project.join("pom.xml"), "<project/>");
|
||||
let mvnd = tmp.join("mvnd");
|
||||
script(&mvnd.join("bin/mvnd"), "mvnd 1.0-m7");
|
||||
let mvn = tmp.join("mvn");
|
||||
script(&mvn.join("bin/mvn"), "Apache Maven 3.9.6");
|
||||
let mvnd_path = mvnd.join("bin/mvnd").to_string_lossy().into_owned();
|
||||
let mvn_path = mvn.join("bin/mvn").to_string_lossy().into_owned();
|
||||
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.daemon = true;
|
||||
s.languages.java.maven.mvnd_home = mvnd_path.clone();
|
||||
s.languages.java.maven.home = mvn_path.clone();
|
||||
|
||||
let run = |command: &str, detected: bool| RunConfig {
|
||||
id: "maven:x".into(),
|
||||
name: "x".into(),
|
||||
kind: "maven".into(),
|
||||
command: command.into(),
|
||||
args: vec!["package".into()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
|
||||
// No project JDK: the daemon may pick its own, so it stays on mvnd.
|
||||
let mut configs = [run("mvn", true), run("mvn", false)];
|
||||
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
|
||||
assert_eq!(configs[0].command, mvnd_path, "detected config runs the daemon");
|
||||
assert_eq!(configs[1].command, mvnd_path, "saved `mvn` config is pointed at the daemon");
|
||||
|
||||
// A launcher the user wrote into a configuration is theirs: an absolute path
|
||||
// or a bare `mvnd` stays as configured.
|
||||
let mut configs = [run(&mvn_path, false), run("mvnd", false)];
|
||||
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
|
||||
assert_eq!(configs[0].command, mvn_path, "a custom absolute launcher is kept");
|
||||
assert_eq!(configs[1].command, "mvnd", "a user-written mvnd is kept");
|
||||
|
||||
// A project JDK older than the daemon's Java 17 requirement keeps mvn.
|
||||
let jdk8 = tmp.join("jdk8");
|
||||
jdk(&jdk8, "1.8.0_392");
|
||||
s.languages.java.jdk = jdk8.to_string_lossy().into_owned();
|
||||
let mut configs = [run("mvn", true), run("mvn", false)];
|
||||
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
|
||||
assert_eq!(configs[0].command, mvn_path, "JDK 8 cannot host the daemon JVM");
|
||||
assert_eq!(configs[1].command, mvn_path);
|
||||
|
||||
// With the daemon off, the same configurations run the configured Maven.
|
||||
s.languages.java.maven.daemon = false;
|
||||
s.languages.java.jdk = String::new();
|
||||
let mut configs = [run("mvn", true), run("mvn", false)];
|
||||
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
|
||||
assert_eq!(configs[0].command, mvn_path);
|
||||
assert_eq!(configs[1].command, mvn_path);
|
||||
}
|
||||
@@ -142,42 +142,16 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((first, rest)) = on_disk.split_first()
|
||||
&& !ctx.ctl.should_stop()
|
||||
{
|
||||
let mut walk = WalkBuilder::new(first);
|
||||
for path in rest {
|
||||
walk.add(path);
|
||||
}
|
||||
walk.hidden(true)
|
||||
.ignore(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.parents(true)
|
||||
.require_git(false)
|
||||
.follow_links(false)
|
||||
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
|
||||
.filter_entry(|e| e.file_name() != ".git");
|
||||
walk.build_parallel().run(|| {
|
||||
if !ctx.ctl.should_stop() {
|
||||
walk_files(&on_disk, || {
|
||||
let mut searcher = searcher_builder.build();
|
||||
let matcher = matcher.clone();
|
||||
let ctx = &ctx;
|
||||
Box::new(move |entry| {
|
||||
Box::new(move |path| {
|
||||
if ctx.ctl.should_stop() {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
tracing::debug!("search walk: {err}");
|
||||
return WalkState::Continue;
|
||||
}
|
||||
};
|
||||
// Archives (jar, zip) are browsed, not searched (IDEA skips them too).
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
|
||||
ctx.search_file(entry.path(), &matcher, &mut searcher);
|
||||
}
|
||||
ctx.search_file(path, &matcher, &mut searcher);
|
||||
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
|
||||
})
|
||||
});
|
||||
@@ -193,6 +167,67 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A per-thread file visitor of [`walk_files`].
|
||||
pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
|
||||
|
||||
/// Visits the files below `roots` (and the roots that are files) in parallel,
|
||||
/// the way IDEA's project scope sees them: directories ignored by
|
||||
/// `.gitignore` / `.ignore` / `info/exclude` are skipped with everything in
|
||||
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
|
||||
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
|
||||
/// zip: browsed, not searched) are never visited.
|
||||
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
|
||||
let Some((first, rest)) = roots.split_first() else { return };
|
||||
let mut walk = WalkBuilder::new(first);
|
||||
for path in rest {
|
||||
walk.add(path);
|
||||
}
|
||||
walk.hidden(false)
|
||||
.ignore(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.parents(true)
|
||||
.require_git(ide_core::ignored::inside_git_repo(first))
|
||||
.follow_links(false)
|
||||
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
|
||||
.filter_entry(|e| e.file_name() != ".git");
|
||||
walk.build_parallel().run(|| {
|
||||
let mut visit = visitor();
|
||||
Box::new(move |entry| {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
tracing::debug!("search walk: {err}");
|
||||
return WalkState::Continue;
|
||||
}
|
||||
};
|
||||
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
|
||||
if file_type.is_dir() {
|
||||
// The walker drops ignored files unseen, so the files of every walked
|
||||
// directory come from listing it; the walker only picks the directories.
|
||||
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
|
||||
for child in children.flatten() {
|
||||
let path = child.path();
|
||||
if child.file_name() != ".git"
|
||||
&& child.file_type().is_ok_and(|t| t.is_file())
|
||||
&& !ide_vfs::is_archive_file(&path)
|
||||
&& matches!(visit(&path), WalkState::Quit)
|
||||
{
|
||||
return WalkState::Quit;
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
} else if file_type.is_file() && entry.depth() == 0 && !ide_vfs::is_archive_file(entry.path()) {
|
||||
// A root that is a file (an explicit file list); files below roots come from listings.
|
||||
visit(entry.path())
|
||||
} else {
|
||||
WalkState::Continue
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Walk roots for a scope; relative paths are resolved against `root`.
|
||||
pub(crate) fn roots(root: &Path, scope: &SearchScope) -> IdeResult<Vec<PathBuf>> {
|
||||
let dir = |path: &Path| -> IdeResult<PathBuf> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! The files of a search scope, for analyses that read whole files
|
||||
//! (Inspect Code, Locate Duplicates, Structural Search).
|
||||
|
||||
use crate::engine::{rel_path, roots};
|
||||
use crate::engine::{rel_path, roots, walk_files};
|
||||
use crate::{FileMask, PathFilter, SearchScope};
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use ignore::WalkState;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct ScopeFile {
|
||||
}
|
||||
|
||||
/// Lists the files of `scope` under `root` the way Find in Files walks them
|
||||
/// (`.gitignore` honoured, hidden files and `.git` skipped), filtered by
|
||||
/// (ignored folders and `.git` skipped; ignored and hidden files kept), filtered by
|
||||
/// `mask` and `filter`, sorted by relative path. Explicitly listed files are
|
||||
/// kept even when they do not exist on disk (unsaved buffers). Returns what was
|
||||
/// found so far when cancelled.
|
||||
@@ -43,42 +43,23 @@ pub fn scope_files(
|
||||
on_disk.push(path);
|
||||
}
|
||||
}
|
||||
if let Some((first, rest)) = on_disk.split_first() {
|
||||
let mut walk = WalkBuilder::new(first);
|
||||
for path in rest {
|
||||
walk.add(path);
|
||||
}
|
||||
walk.hidden(true)
|
||||
.ignore(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.parents(true)
|
||||
.require_git(false)
|
||||
.follow_links(false)
|
||||
.threads(std::thread::available_parallelism().map_or(4, |n| n.get()).min(12))
|
||||
.filter_entry(|e| e.file_name() != ".git");
|
||||
walk.build_parallel().run(|| {
|
||||
let found = &found;
|
||||
let keep = &keep;
|
||||
Box::new(move |entry| {
|
||||
if cancel.is_cancelled() {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
let Ok(entry) = entry else { return WalkState::Continue };
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
|
||||
let rel = rel_path(root, entry.path());
|
||||
if keep(&rel) {
|
||||
found
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(ScopeFile { path: entry.into_path(), rel_path: rel });
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
||||
walk_files(&on_disk, || {
|
||||
let found = &found;
|
||||
let keep = &keep;
|
||||
Box::new(move |path| {
|
||||
if cancel.is_cancelled() {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
let rel = rel_path(root, path);
|
||||
if keep(&rel) {
|
||||
found
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(ScopeFile { path: path.to_path_buf(), rel_path: rel });
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
files.dedup_by(|a, b| a.path == b.path);
|
||||
@@ -107,12 +88,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scope_honours_gitignore_and_masks() {
|
||||
fn project_scope_skips_ignored_folders_and_honours_masks() {
|
||||
let dir = tree();
|
||||
let none = FileMask::new(&[], &[]).unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let all = scope_files(dir.path(), &SearchScope::Project, &none, None, &cancel).unwrap();
|
||||
assert_eq!(rels(&all), vec!["src/a.rs", "src/c.txt", "src/sub/b.rs"]);
|
||||
assert_eq!(rels(&all), vec![".gitignore", "src/a.rs", "src/c.txt", "src/sub/b.rs"]);
|
||||
|
||||
let rs = FileMask::new(&["*.rs".into()], &[]).unwrap();
|
||||
let only_rs = scope_files(dir.path(), &SearchScope::Project, &rs, None, &cancel).unwrap();
|
||||
@@ -120,7 +101,7 @@ mod tests {
|
||||
|
||||
let not_sub: PathFilter<'_> = &|rel: &str| !rel.starts_with("src/sub/");
|
||||
let filtered = scope_files(dir.path(), &SearchScope::Project, &none, Some(not_sub), &cancel).unwrap();
|
||||
assert_eq!(rels(&filtered), vec!["src/a.rs", "src/c.txt"]);
|
||||
assert_eq!(rels(&filtered), vec![".gitignore", "src/a.rs", "src/c.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Find / Replace in Files (⌘⇧F / ⌘⇧R).
|
||||
//!
|
||||
//! Built on ripgrep's engine crates: a parallel `ignore` walk (honouring
|
||||
//! `.gitignore` / `.ignore`, skipping hidden files and `.git`) feeding
|
||||
//! Built on ripgrep's engine crates: a parallel `ignore` walk (skipping the
|
||||
//! folders `.gitignore` / `.ignore` exclude and `.git`, but like IDEA keeping
|
||||
//! ignored files elsewhere and hidden files) feeding
|
||||
//! `grep-searcher` with a `grep-regex` matcher. Binary files are skipped,
|
||||
//! unsaved editor buffers (the overlay) take precedence over disk content,
|
||||
//! and all columns are UTF-16.
|
||||
|
||||
@@ -25,8 +25,8 @@ pub struct WordFiles {
|
||||
}
|
||||
|
||||
/// Files under `query.dir` containing `query.word` as a whole word, in walk
|
||||
/// order (unsaved buffers through `overlay`, `.gitignore`d and hidden files
|
||||
/// skipped like Find in Files). Stops at `query.limit` files or when cancelled.
|
||||
/// order (unsaved buffers through `overlay`; ignored folders skipped, ignored
|
||||
/// and hidden files searched, like Find in Files). Stops at `query.limit` files or when cancelled.
|
||||
pub fn files_with_word(
|
||||
root: &std::path::Path,
|
||||
query: &WordQuery,
|
||||
@@ -74,7 +74,9 @@ mod tests {
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("pkg/ignored")).unwrap();
|
||||
fs::create_dir_all(root.join("build")).unwrap();
|
||||
fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
|
||||
fs::write(root.join(".gitignore"), "ignored/\nlocal.py\n").unwrap();
|
||||
// An ignored file outside ignored folders is still a candidate, as in IDEA.
|
||||
fs::write(root.join("pkg/local.py"), "get_value\n").unwrap();
|
||||
fs::write(root.join("pkg/use.py"), "x.get_value(1)\n").unwrap();
|
||||
fs::write(root.join("pkg/longer.py"), "get_values()\n").unwrap();
|
||||
fs::write(root.join("pkg/case.py"), "Get_Value()\n").unwrap();
|
||||
@@ -90,7 +92,10 @@ mod tests {
|
||||
found.files.sort();
|
||||
assert_eq!(
|
||||
found,
|
||||
WordFiles { files: vec![root.join("pkg/open.py"), root.join("pkg/use.py")], limit_hit: false }
|
||||
WordFiles {
|
||||
files: vec![root.join("pkg/local.py"), root.join("pkg/open.py"), root.join("pkg/use.py")],
|
||||
limit_hit: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -187,24 +187,77 @@ fn file_masks() {
|
||||
assert_eq!(with(&[], &["*.kt,*.ts"]), ["notes.md", "src/Util.java"]);
|
||||
}
|
||||
|
||||
/// The files of `scope` under `root` (relative), sorted.
|
||||
fn scope_rels(root: &Path, scope: &SearchScope) -> Vec<String> {
|
||||
let none = ide_search::FileMask::new(&[], &[]).unwrap();
|
||||
let files = ide_search::scope_files(root, scope, &none, None, &CancellationToken::new()).unwrap();
|
||||
let mut rels: Vec<_> = files.into_iter().map(|f| f.rel_path).collect();
|
||||
rels.sort();
|
||||
rels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_ignore_files_hidden_and_git() {
|
||||
let p = project(&[
|
||||
(".gitignore", b"target/\n*.log\n"),
|
||||
(".ignore", b"generated.rs\n"),
|
||||
("src/a.rs", b"needle\n"),
|
||||
("src/generated.rs", b"needle\n"),
|
||||
("target/debug/out.rs", b"needle\n"),
|
||||
("build.log", b"needle\n"),
|
||||
(".hidden/secret.rs", b"needle\n"),
|
||||
(".env", b"needle\n"),
|
||||
(".git/config", b"needle\n"),
|
||||
("sub/.gitignore", b"local.txt\n"),
|
||||
("sub/local.txt", b"needle\n"),
|
||||
("sub/kept.txt", b"needle\n"),
|
||||
]);
|
||||
fn searches_ignored_and_hidden_files_but_not_ignored_folders() {
|
||||
// #1317: like IDEA, only ignored folders are left out; ignored files and dotfiles are searched.
|
||||
// (path, content, walked): every file mentions `needle`.
|
||||
let files: &[(&str, &[u8], bool)] = &[
|
||||
(".gitignore", b"# needle\ntarget/\n*.log\n.env\nsrc/local/\n", true),
|
||||
(".ignore", b"# needle\ngenerated.rs\nout/\n", true),
|
||||
("src/a.rs", b"needle\n", true),
|
||||
// Ignored files outside ignored folders.
|
||||
("src/generated.rs", b"needle\n", true),
|
||||
("build.log", b"needle\n", true),
|
||||
(".env", b"needle\n", true),
|
||||
("sub/.gitignore", b"# needle\nlocal.txt\ncache/\n", true),
|
||||
("sub/local.txt", b"needle\n", true),
|
||||
("sub/kept.txt", b"needle\n", true),
|
||||
// Hidden files and folders.
|
||||
(".eslintrc", b"needle\n", true),
|
||||
(".github/workflows/ci.yml", b"needle\n", true),
|
||||
(".hidden/secret.rs", b"needle\n", true),
|
||||
// Ignored folders, whatever they hold.
|
||||
("target/debug/out.rs", b"needle\n", false),
|
||||
("target/.env", b"needle\n", false),
|
||||
("src/local/notes.txt", b"needle\n", false),
|
||||
("out/gen.rs", b"needle\n", false),
|
||||
("sub/cache/blob.txt", b"needle\n", false),
|
||||
// Never: Git's own folder.
|
||||
(".git/config", b"needle\n", false),
|
||||
];
|
||||
let p = project(&files.iter().map(|(path, text, _)| (*path, *text)).collect::<Vec<_>>());
|
||||
let mut expected: Vec<&str> = files.iter().filter(|(_, _, walked)| *walked).map(|(path, _, _)| *path).collect();
|
||||
expected.sort();
|
||||
|
||||
let (views, _) = run(p.path(), &query("needle"));
|
||||
assert_eq!(paths(&views), ["src/a.rs", "sub/kept.txt"]);
|
||||
assert_eq!(paths(&views), expected, "Find in Files");
|
||||
|
||||
assert_eq!(scope_rels(p.path(), &SearchScope::Project), expected, "scope files");
|
||||
|
||||
// A directory scope and an explicit ignored file are searched the same way.
|
||||
let dir = SearchScope::Directory { path: ide_core::IpcPath(p.path().join("sub")) };
|
||||
let (views, _) = run(p.path(), &SearchQuery { scope: dir, ..query("needle") });
|
||||
assert_eq!(paths(&views), ["sub/.gitignore", "sub/kept.txt", "sub/local.txt"]);
|
||||
let one = SearchScope::Files { paths: vec![ide_core::IpcPath(p.path().join("build.log"))] };
|
||||
let (views, _) = run(p.path(), &SearchQuery { scope: one, ..query("needle") });
|
||||
assert_eq!(paths(&views), ["build.log"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repository_ignores_outer_repository_rules() {
|
||||
// #1254: the opened project is a Git repository inside a folder its parent repository ignores.
|
||||
let p = project(&[
|
||||
(".git/HEAD", b"ref: refs/heads/main\n"),
|
||||
("projects/.gitignore", b"*\n!.gitignore\n"),
|
||||
("projects/nested/.git/HEAD", b"ref: refs/heads/main\n"),
|
||||
("projects/nested/.gitignore", b"logs/\n"),
|
||||
("projects/nested/search-test.txt", b"somesss\n"),
|
||||
("projects/nested/src/main.txt", b"somesss\n"),
|
||||
("projects/nested/logs/debug.txt", b"somesss\n"),
|
||||
]);
|
||||
let nested = p.path().join("projects/nested");
|
||||
let (views, _) = run(&nested, &query("somesss"));
|
||||
assert_eq!(paths(&views), ["search-test.txt", "src/main.txt"]);
|
||||
assert_eq!(scope_rels(&nested, &SearchScope::Project), [".gitignore", "search-test.txt", "src/main.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -643,3 +696,31 @@ fn archives_are_skipped() {
|
||||
let (views, _) = run(p.path(), &query("needle"));
|
||||
assert_eq!(paths(&views), ["src/a.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_repository_is_not_hidden_by_the_outer_repositorys_gitignore() {
|
||||
// parent/.git, parent/projects/.gitignore ignores everything, parent/projects/nested is its own repository.
|
||||
let p = project(&[
|
||||
("projects/.gitignore", b"*\n!.gitignore\n"),
|
||||
("projects/nested/search-test.txt", b"somesss\n"),
|
||||
("projects/nested/src/main.txt", b"somesss\n"),
|
||||
("projects/nested/.gitignore", b"logs/\n"),
|
||||
("projects/nested/logs/debug.txt", b"somesss\n"),
|
||||
]);
|
||||
std::fs::create_dir(p.path().join(".git")).unwrap();
|
||||
std::fs::create_dir(p.path().join("projects/nested/.git")).unwrap();
|
||||
let (views, _) = run(&p.path().join("projects/nested"), &query("somesss"));
|
||||
assert_eq!(
|
||||
paths(&views),
|
||||
["search-test.txt", "src/main.txt"],
|
||||
"the nested repository's own .gitignore still applies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_inside_a_repository_keeps_the_repositorys_gitignore() {
|
||||
let p = project(&[(".gitignore", b"build/\n"), ("app/build/out.txt", b"hit\n"), ("app/src/a.txt", b"hit\n")]);
|
||||
std::fs::create_dir(p.path().join(".git")).unwrap();
|
||||
let (views, _) = run(&p.path().join("app"), &query("hit"));
|
||||
assert_eq!(paths(&views), ["src/a.txt"]);
|
||||
}
|
||||
@@ -641,11 +641,11 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
// ECJ 3.46.200 keeps the constructor call in a constructor's statements, which
|
||||
// @SneakyThrows / @NonNull must not wrap or precede (lombok#4089).
|
||||
// Return to Maven Central once an official release includes both.
|
||||
// Source commit, build instructions and checksum: plugins/lombok/README.md.
|
||||
// Source commit, build instructions and checksum: java/lombok/README.md.
|
||||
method: Method::Bundled {
|
||||
filename: "lombok-1.18.48-openidea.2.jar",
|
||||
version: "1.18.48-openidea.2",
|
||||
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.2.jar"),
|
||||
bytes: include_bytes!("../../../java/lombok/lombok-1.18.48-openidea.2.jar"),
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
@@ -680,7 +680,9 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
rule(Linux, Arm64, "kotlin-lsp-*-linux-aarch64.zip"),
|
||||
rule(Windows, X64, "kotlin-lsp-*-win-x64.zip"),
|
||||
],
|
||||
locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"],
|
||||
// `bin/intellij-server` is the launcher; current releases still ship the
|
||||
// `kotlin-lsp.sh` / `kotlin-lsp.cmd` wrappers around it, deprecated, and old ones only those.
|
||||
locate: &["intellij-server", "kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
@@ -1044,6 +1046,25 @@ mod tests {
|
||||
/// The v263 kotlin-server-* release notes: the CLI distribution archives plus
|
||||
/// the VS Code extension and checksums; each platform must pick its native
|
||||
/// distribution and the aarch64 archive must never be picked on x64.
|
||||
#[test]
|
||||
fn kotlin_lsp_runs_its_launcher_rather_than_the_deprecated_wrapper() {
|
||||
let Method::Release { locate, .. } = &find("kotlin-lsp").unwrap().method else { unreachable!() };
|
||||
let names: Vec<String> = locate.iter().map(|n| n.to_string()).collect();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Current releases: `kotlin-lsp.sh` warns that it will go and execs `bin/intellij-server`.
|
||||
let current = tmp.path().join("current");
|
||||
let dist = current.join("kotlin-server-263.7084.0");
|
||||
std::fs::create_dir_all(dist.join("bin")).unwrap();
|
||||
std::fs::write(dist.join("kotlin-lsp.sh"), "").unwrap();
|
||||
std::fs::write(dist.join("bin/intellij-server"), "").unwrap();
|
||||
assert_eq!(crate::archive::locate(¤t, &names), Some(dist.join("bin/intellij-server")));
|
||||
// Older releases have only the script.
|
||||
let old = tmp.path().join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("kotlin-lsp.sh"), "").unwrap();
|
||||
assert_eq!(crate::archive::locate(&old, &names), Some(old.join("kotlin-lsp.sh")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_lsp_picks_the_native_archive_for_each_platform() {
|
||||
use crate::platform::{Platform, select_asset};
|
||||
|
||||
@@ -71,7 +71,7 @@ fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() {
|
||||
let env = Env::new();
|
||||
let data = env.root.parent().unwrap();
|
||||
let agent = managed_bundle(data, "lombok").unwrap();
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
let checksum = include_str!("../../../java/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
|
||||
for contents in [None, Some(b"incomplete jar".as_slice())] {
|
||||
if let Some(contents) = contents {
|
||||
@@ -93,7 +93,7 @@ fn concurrent_windows_get_the_same_complete_bundled_lombok() {
|
||||
let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect();
|
||||
let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect();
|
||||
assert!(paths.iter().all(|path| path == &paths[0]));
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
let checksum = include_str!("../../../java/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! History for Selection: the commits that changed a block of lines
|
||||
//! (`git log -L`), each with the block as it was before and after.
|
||||
|
||||
use crate::text_encoding::{TextEncoding, decode};
|
||||
use crate::{BlockHistory, BlockRevision, LineHunk, Repo, cli};
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use std::path::Path;
|
||||
|
||||
/// Separates commits in the log output; fields of the header are separated by `\x1f`.
|
||||
const RECORD: u8 = 0x1e;
|
||||
const FORMAT: &str = "--format=%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s";
|
||||
|
||||
pub(crate) fn block_history(
|
||||
r: &Repo,
|
||||
path: &Path,
|
||||
current_text: Option<&str>,
|
||||
lines: (u32, u32),
|
||||
encoding: TextEncoding,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BlockHistory> {
|
||||
let rel = r.rel_path(path)?;
|
||||
let head = {
|
||||
let repo = r.gix()?;
|
||||
crate::repo::blob_at(&repo, &rel, "HEAD")?
|
||||
};
|
||||
let Some(head) = head else {
|
||||
return Ok(BlockHistory { head_lines: None, revisions: Vec::new() });
|
||||
};
|
||||
let head = decode(&head, encoding);
|
||||
let current = match current_text {
|
||||
Some(text) => text.to_owned(),
|
||||
None => decode(&std::fs::read(r.abs_path(&rel))?, encoding).into_owned(),
|
||||
};
|
||||
let Some((start, end)) = head_range(&crate::diff::line_hunks(&head, ¤t), lines) else {
|
||||
return Ok(BlockHistory { head_lines: None, revisions: Vec::new() });
|
||||
};
|
||||
let range = format!("-L{},{}:{rel}", start + 1, end + 1);
|
||||
let args = ["log", "--no-color", "--no-ext-diff", "--no-textconv", FORMAT, &range, "HEAD"];
|
||||
let out = cli::run_cancellable(r.root(), &args, cancel)?;
|
||||
Ok(BlockHistory { head_lines: Some((start, end)), revisions: parse_log(&out.stdout, encoding) })
|
||||
}
|
||||
|
||||
/// The HEAD lines (0-based, inclusive) of `lines` in the current text, given the
|
||||
/// hunks between HEAD and it. A selection end inside a changed region takes the
|
||||
/// whole region; None when every selected line is new.
|
||||
pub(crate) fn head_range(hunks: &[LineHunk], (start, end): (u32, u32)) -> Option<(u32, u32)> {
|
||||
let (start, end) = (start.min(end), start.max(end));
|
||||
let to_head = |line: u32, at_end: bool| -> i64 {
|
||||
let mut shift = 0i64;
|
||||
for h in hunks {
|
||||
if line < h.start {
|
||||
break;
|
||||
}
|
||||
if line < h.start + h.lines {
|
||||
return if at_end {
|
||||
i64::from(h.old_start) + i64::from(h.old_lines) - 1
|
||||
} else {
|
||||
i64::from(h.old_start)
|
||||
};
|
||||
}
|
||||
shift += i64::from(h.lines) - i64::from(h.old_lines);
|
||||
}
|
||||
i64::from(line) - shift
|
||||
};
|
||||
let (from, to) = (to_head(start, false), to_head(end, true));
|
||||
(from >= 0 && from <= to).then_some((from as u32, to as u32))
|
||||
}
|
||||
|
||||
/// Commits of `git log -L` output, newest first.
|
||||
pub(crate) fn parse_log(stdout: &[u8], encoding: TextEncoding) -> Vec<BlockRevision> {
|
||||
stdout
|
||||
.split(|b| *b == RECORD)
|
||||
.filter(|r| !r.is_empty())
|
||||
.filter_map(|record| parse_record(record, encoding))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_record(record: &[u8], encoding: TextEncoding) -> Option<BlockRevision> {
|
||||
let newline = record.iter().position(|b| *b == b'\n').unwrap_or(record.len());
|
||||
let header = String::from_utf8_lossy(&record[..newline]);
|
||||
let mut fields = header.split('\x1f');
|
||||
let id = fields.next()?.to_owned();
|
||||
let short_id = fields.next()?.to_owned();
|
||||
let author = fields.next()?.to_owned();
|
||||
let email = fields.next()?.to_owned();
|
||||
let time_ms = fields.next()?.trim().parse::<f64>().ok()? * 1000.0;
|
||||
let summary = fields.next().unwrap_or_default().to_owned();
|
||||
let patch = decode(record.get(newline + 1..).unwrap_or_default(), encoding);
|
||||
let mut revision = BlockRevision {
|
||||
id,
|
||||
short_id,
|
||||
author,
|
||||
email,
|
||||
time_ms,
|
||||
summary,
|
||||
path: String::new(),
|
||||
before: String::new(),
|
||||
after: String::new(),
|
||||
before_line: 0,
|
||||
after_line: 0,
|
||||
};
|
||||
let mut in_hunk = false;
|
||||
for line in patch.lines() {
|
||||
if let Some(path) = line.strip_prefix("+++ ") {
|
||||
revision.path = unquote(path).trim_start_matches("b/").to_owned();
|
||||
} else if let Some(ranges) = line.strip_prefix("@@ ") {
|
||||
in_hunk = true;
|
||||
let mut parts = ranges.split_whitespace();
|
||||
revision.before_line = hunk_start(parts.next().unwrap_or_default());
|
||||
revision.after_line = hunk_start(parts.next().unwrap_or_default());
|
||||
} else if in_hunk {
|
||||
match line.as_bytes().first() {
|
||||
Some(b' ') => {
|
||||
push_line(&mut revision.before, &line[1..]);
|
||||
push_line(&mut revision.after, &line[1..]);
|
||||
}
|
||||
Some(b'-') => push_line(&mut revision.before, &line[1..]),
|
||||
Some(b'+') => push_line(&mut revision.after, &line[1..]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(revision)
|
||||
}
|
||||
|
||||
/// 0-based first line of a hunk side (`-12,3` → 11; `+0,0` for a file that did not exist → 0).
|
||||
fn hunk_start(side: &str) -> u32 {
|
||||
let n = side.trim_start_matches(['-', '+']).split(',').next().unwrap_or_default();
|
||||
n.parse::<u32>().unwrap_or(0).saturating_sub(1)
|
||||
}
|
||||
|
||||
fn push_line(block: &mut String, line: &str) {
|
||||
block.push_str(line);
|
||||
block.push('\n');
|
||||
}
|
||||
|
||||
/// A C-quoted path (`"b/a \"b\".txt"`), as git writes unusual names.
|
||||
fn unquote(path: &str) -> String {
|
||||
let Some(inner) = path.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else {
|
||||
return path.to_owned();
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut chars = inner.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('t') => out.push('\t'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some(other) => out.push(other),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HunkKind;
|
||||
|
||||
fn hunk(start: u32, lines: u32, old_start: u32, old_lines: u32) -> LineHunk {
|
||||
LineHunk { kind: HunkKind::Modified, start, lines, old_start, old_lines, old_text: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_the_selection_to_head_lines() {
|
||||
// Unchanged file.
|
||||
assert_eq!(head_range(&[], (3, 5)), Some((3, 5)));
|
||||
// Two lines inserted at 1: current 4..=6 were HEAD 2..=4; a reversed selection too.
|
||||
let inserted = [hunk(1, 2, 1, 0)];
|
||||
assert_eq!(head_range(&inserted, (4, 6)), Some((2, 4)));
|
||||
assert_eq!(head_range(&inserted, (6, 4)), Some((2, 4)));
|
||||
// Selecting only the inserted lines: nothing committed.
|
||||
assert_eq!(head_range(&inserted, (1, 2)), None);
|
||||
// From an inserted line on: the HEAD lines after the insertion.
|
||||
assert_eq!(head_range(&inserted, (2, 4)), Some((1, 2)));
|
||||
// Three HEAD lines (5..=7) replaced by one: a selection ending in it takes all three.
|
||||
let replaced = [hunk(5, 1, 5, 3)];
|
||||
assert_eq!(head_range(&replaced, (4, 5)), Some((4, 7)));
|
||||
assert_eq!(head_range(&replaced, (6, 6)), Some((8, 8)));
|
||||
// Two HEAD lines deleted at 2: later lines move down by two.
|
||||
let deleted = [hunk(2, 0, 2, 2)];
|
||||
assert_eq!(head_range(&deleted, (2, 3)), Some((4, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_git_log_line_ranges() {
|
||||
let out = [
|
||||
"\x1eaaaa1111\x1faaaa\x1fAda\x1f[email protected]\x1f1700000000\x1fRename greet",
|
||||
"",
|
||||
"diff --git a/src/a.txt b/src/b.txt",
|
||||
"--- a/src/a.txt",
|
||||
"+++ b/src/b.txt",
|
||||
"@@ -2,2 +2,2 @@",
|
||||
" keep",
|
||||
"-hello",
|
||||
"+hi",
|
||||
"\x1ebbbb2222\x1fbbbb\x1fBo\x1f[email protected]\x1f1600000000\x1fAdd",
|
||||
"",
|
||||
"diff --git a/src/a.txt b/src/a.txt",
|
||||
"--- /dev/null",
|
||||
"+++ b/src/a.txt",
|
||||
"@@ -0,0 +2,2 @@",
|
||||
"+keep",
|
||||
"+hello",
|
||||
]
|
||||
.join("\n");
|
||||
let revisions = parse_log(out.as_bytes(), TextEncoding::from(encoding_rs::UTF_8));
|
||||
assert_eq!(revisions.len(), 2);
|
||||
let [newer, older] = &revisions[..] else { unreachable!() };
|
||||
assert_eq!((newer.id.as_str(), newer.short_id.as_str(), newer.author.as_str()), ("aaaa1111", "aaaa", "Ada"));
|
||||
assert_eq!((newer.time_ms, newer.summary.as_str(), newer.path.as_str()), (1.7e12, "Rename greet", "src/b.txt"));
|
||||
assert_eq!((newer.before.as_str(), newer.after.as_str()), ("keep\nhello\n", "keep\nhi\n"));
|
||||
assert_eq!((newer.before_line, newer.after_line), (1, 1));
|
||||
assert_eq!((older.before.as_str(), older.after.as_str()), ("", "keep\nhello\n"));
|
||||
assert_eq!((older.before_line, older.after_line), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquotes_c_quoted_paths() {
|
||||
assert_eq!(unquote(r#""b/a \"q\"\tb.txt""#), "b/a \"q\"\tb.txt");
|
||||
assert_eq!(unquote("b/plain.txt"), "b/plain.txt");
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,35 @@ pub struct BlameLine {
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// History for Selection: the commits that changed a block of lines, newest first.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockHistory {
|
||||
/// The selected lines in HEAD (0-based, inclusive); None when none of them is committed.
|
||||
pub head_lines: Option<(u32, u32)>,
|
||||
pub revisions: Vec<BlockRevision>,
|
||||
}
|
||||
|
||||
/// A commit that changed the block, with the block in its first parent and in it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockRevision {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub author: String,
|
||||
pub email: String,
|
||||
pub time_ms: f64,
|
||||
pub summary: String,
|
||||
/// Repository-relative path of the file in this commit (before a later rename).
|
||||
pub path: String,
|
||||
/// The block before the commit; empty when the commit added it.
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
/// 0-based first line of the block before and after the commit.
|
||||
pub before_line: u32,
|
||||
pub after_line: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Commit {
|
||||
@@ -459,6 +488,7 @@ pub struct ChangelistState {
|
||||
}
|
||||
|
||||
mod blame;
|
||||
mod block_history;
|
||||
pub mod changelists;
|
||||
mod cli;
|
||||
mod compare;
|
||||
@@ -685,6 +715,20 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::blame(self, path, current_text, revision, encoding)
|
||||
}
|
||||
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
|
||||
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
|
||||
pub fn block_history(
|
||||
&self,
|
||||
path: &Path,
|
||||
current_text: Option<&str>,
|
||||
lines: (u32, u32),
|
||||
encoding: impl Into<TextEncoding>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BlockHistory> {
|
||||
let encoding = encoding.into();
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
block_history::block_history(self, path, current_text, lines, encoding, cancel)
|
||||
}
|
||||
/// Commits newest-first in topological order (children before parents).
|
||||
///
|
||||
/// `branch` is any revision; when absent, HEAD plus all local and remote branches are
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! History for Selection (`git log -L`) against throwaway repositories built with the git CLI.
|
||||
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_vcs::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("work");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fx = Fixture { _tmp: tmp, dir };
|
||||
fx.git(&["init", "-q"]);
|
||||
for (k, v) in [("user.name", "Test User"), ("user.email", "[email protected]"), ("commit.gpgsign", "false")] {
|
||||
fx.git(&["config", k, v]);
|
||||
}
|
||||
fx
|
||||
}
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.current_dir(&self.dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "core.autocrlf=false"])
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
fn write(&self, rel: &str, content: &str) {
|
||||
std::fs::write(self.dir.join(rel), content).unwrap();
|
||||
}
|
||||
fn commit(&self, msg: &str) {
|
||||
self.git(&["add", "-A"]);
|
||||
self.git(&["commit", "-q", "-m", msg]);
|
||||
}
|
||||
fn repo(&self) -> Repo {
|
||||
Repo::discover(&self.dir).unwrap().expect("repository found")
|
||||
}
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.dir.join(rel)
|
||||
}
|
||||
}
|
||||
|
||||
fn history(repo: &Repo, path: &Path, text: Option<&str>, lines: (u32, u32)) -> BlockHistory {
|
||||
repo.block_history(path, text, lines, encoding_rs::UTF_8, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
/// a.txt: added, line 2 edited, line 5 edited, renamed to b.txt, line 3 edited.
|
||||
fn renamed_history() -> Fixture {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "one\ntwo\nthree\nfour\nfive\n");
|
||||
fx.commit("Add a");
|
||||
fx.write("a.txt", "one\nTWO\nthree\nfour\nfive\n");
|
||||
fx.commit("Shout two");
|
||||
fx.write("a.txt", "one\nTWO\nthree\nfour\nFIVE\n");
|
||||
fx.commit("Shout five");
|
||||
fx.git(&["mv", "a.txt", "b.txt"]);
|
||||
fx.commit("Rename a to b");
|
||||
fx.write("b.txt", "one\nTWO\nTHREE\nfour\nFIVE\n");
|
||||
fx.commit("Shout three");
|
||||
fx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_the_block_through_edits_and_a_rename() {
|
||||
let fx = renamed_history();
|
||||
let h = history(&fx.repo(), &fx.path("b.txt"), None, (1, 2));
|
||||
assert_eq!(h.head_lines, Some((1, 2)));
|
||||
let shown: Vec<_> = h.revisions.iter().map(|r| (r.summary.as_str(), r.path.as_str())).collect();
|
||||
// Edits outside the block and the pure rename are left out.
|
||||
assert_eq!(shown, [("Shout three", "b.txt"), ("Shout two", "a.txt"), ("Add a", "a.txt")]);
|
||||
let blocks: Vec<_> = h.revisions.iter().map(|r| (r.before.as_str(), r.after.as_str())).collect();
|
||||
assert_eq!(blocks, [("TWO\nthree\n", "TWO\nTHREE\n"), ("two\nthree\n", "TWO\nthree\n"), ("", "two\nthree\n")]);
|
||||
let newest = &h.revisions[0];
|
||||
assert_eq!((newest.before_line, newest.after_line, newest.author.as_str()), (1, 1, "Test User"));
|
||||
assert!(newest.id.starts_with(&newest.short_id) && newest.time_ms > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_an_edited_selection_to_head_lines() {
|
||||
let fx = renamed_history();
|
||||
let repo = fx.repo();
|
||||
// Two unsaved lines inserted at the top: lines 3..=4 are HEAD's 1..=2.
|
||||
let edited = "new\nnew\none\nTWO\nTHREE\nfour\nFIVE\n";
|
||||
let h = history(&repo, &fx.path("b.txt"), Some(edited), (3, 4));
|
||||
assert_eq!(h.head_lines, Some((1, 2)));
|
||||
assert_eq!(h.revisions.len(), 3);
|
||||
// Only new lines selected, or a file Git does not know: nothing committed to show.
|
||||
let h = history(&repo, &fx.path("b.txt"), Some(edited), (0, 1));
|
||||
assert_eq!((h.head_lines, h.revisions.len()), (None, 0));
|
||||
fx.write("untracked.txt", "x\n");
|
||||
let h = history(&repo, &fx.path("untracked.txt"), None, (0, 0));
|
||||
assert_eq!((h.head_lines, h.revisions.len()), (None, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_mode_runs_no_diff_driver_of_the_repository() {
|
||||
let fx = renamed_history();
|
||||
let marker = fx.path("textconv-ran");
|
||||
fx.write(".gitattributes", "*.txt diff=probe\n");
|
||||
fx.commit("Attributes");
|
||||
fx.git(&["config", "diff.probe.textconv", &format!("sh -c 'touch {}; cat \"$0\"'", marker.display())]);
|
||||
let repo = fx.repo();
|
||||
repo.set_execution_allowed(false);
|
||||
let h = history(&repo, &fx.path("b.txt"), None, (1, 2));
|
||||
assert_eq!(h.revisions.len(), 3);
|
||||
assert!(!marker.exists(), "a textconv driver ran in restricted mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_requests_stop() {
|
||||
let fx = renamed_history();
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let err = fx.repo().block_history(&fx.path("b.txt"), None, (1, 2), encoding_rs::UTF_8, &cancel).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Cancelled);
|
||||
}
|
||||
@@ -274,13 +274,14 @@ pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
/// honouring .gitignore, .ignore and `excludes`. Shared by index and search.
|
||||
///
|
||||
/// Hidden files are included; `.git` is always skipped. `.gitignore` applies
|
||||
/// even outside a git repository. Symlinks to files are visited, symlinked
|
||||
/// even outside a git repository; inside one, a nested repository's root is a
|
||||
/// boundary for the outer repository's rules. Symlinks to files are visited, symlinked
|
||||
/// directories are not followed.
|
||||
pub fn walk_files(root: &Path, excludes: &[String], visit: impl Fn(&Path) + Send + Sync) {
|
||||
let excludes = Arc::new(ExcludeMatcher::new(excludes));
|
||||
let base = root.to_path_buf();
|
||||
let mut builder = ignore::WalkBuilder::new(root);
|
||||
builder.hidden(false).require_git(false).follow_links(false);
|
||||
builder.hidden(false).require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false);
|
||||
builder.filter_entry(move |entry| {
|
||||
if entry.depth() > 0 && entry.file_name() == ".git" {
|
||||
return false;
|
||||
@@ -324,6 +325,11 @@ pub struct FsBatch {
|
||||
/// Folders holding changes filtered out as excluded or ignored. Only a view already
|
||||
/// showing such a folder cares, so they don't count as worktree changes.
|
||||
pub excluded_dirs: Vec<PathBuf>,
|
||||
/// Files changed, created or removed that an ignore rule matches while their folder
|
||||
/// is not ignored (`.env`). Kept apart from the worktree changes above (no Local
|
||||
/// History, deployment or `fsChanged`), but the index lists them and open editors
|
||||
/// follow them, like IDEA, where only ignored folders leave the project.
|
||||
pub ignored: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl FsBatch {
|
||||
@@ -333,6 +339,7 @@ impl FsBatch {
|
||||
&& self.removed.is_empty()
|
||||
&& self.vcs.is_empty()
|
||||
&& self.excluded_dirs.is_empty()
|
||||
&& self.ignored.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -531,6 +531,13 @@ impl EventFilter {
|
||||
&& !self.gitignore.is_ignored(path, is_dir)
|
||||
}
|
||||
|
||||
/// Whether a filtered-out file is ignored by a rule on the file itself, in a folder
|
||||
/// that is neither excluded nor ignored.
|
||||
fn is_ignored_file(&self, path: &Path, rel: &Path) -> bool {
|
||||
!self.excludes.read().is_excluded(rel)
|
||||
&& path.parent().is_some_and(|dir| dir == self.root || !self.gitignore.is_ignored(dir, true))
|
||||
}
|
||||
|
||||
fn classify(&self, path: &Path, change: Change, out: &mut BTreeMap<PathBuf, Change>) {
|
||||
let rel = match path.strip_prefix(&self.root) {
|
||||
Ok(rel) if rel.as_os_str().is_empty() => return,
|
||||
@@ -642,6 +649,9 @@ impl EventFilter {
|
||||
if change != Change::Modified {
|
||||
excluded_dirs.extend(path.parent().map(Path::to_path_buf));
|
||||
}
|
||||
if !is_dir && self.is_ignored_file(&path, rel) {
|
||||
batch.ignored.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -700,6 +710,7 @@ mod tests {
|
||||
assert_eq!(batch.vcs, vec![root.join(".git/HEAD"), root.join(".git/refs/heads/feature")]);
|
||||
assert_eq!(batch.modified, vec![root.join("kept.txt")]);
|
||||
assert_eq!(batch.removed, vec![root.join("from.txt"), root.join("gone.txt"), root.join("old.txt")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("debug.log")], "ignored files are reported apart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -751,6 +762,20 @@ mod tests {
|
||||
]);
|
||||
assert!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty(), "{batch:?}");
|
||||
assert_eq!(batch.excluded_dirs, vec![root.clone(), root.join("dist"), root.join("target/debug")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("debug.log")], "only the ignored file outside ignored folders");
|
||||
|
||||
// An ignored file's edit and removal reach the index too (nothing else changes).
|
||||
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
|
||||
let batch = filter.batch(&[ev(modify, vec![root.join("debug.log")])]);
|
||||
assert_eq!(batch.ignored, vec![root.join("debug.log")]);
|
||||
assert!(!batch.is_empty() && batch.excluded_dirs.is_empty());
|
||||
std::fs::remove_file(root.join("debug.log")).unwrap();
|
||||
let batch = filter.batch(&[
|
||||
ev(EventKind::Remove(RemoveKind::File), vec![root.join("debug.log")]),
|
||||
ev(EventKind::Remove(RemoveKind::File), vec![root.join("dist/app.js")]),
|
||||
]);
|
||||
// A removed path can't tell file from folder: it goes out as a removal, as before.
|
||||
assert_eq!(batch.removed, vec![root.join("debug.log")]);
|
||||
|
||||
// Sibling files in unignored directories still come through.
|
||||
let batch = filter.batch(&[ev(create_file, vec![root.join("src/main.rs")])]);
|
||||
@@ -801,6 +826,7 @@ mod tests {
|
||||
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
|
||||
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
|
||||
assert!(rules.is_empty());
|
||||
assert!(!filter.should_watch_dir(&root.join("pkg/gen")));
|
||||
|
||||
@@ -818,7 +844,8 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join(".git/info")).unwrap();
|
||||
std::fs::write(root.join(".git/info/exclude"), "b.tmp\n").unwrap();
|
||||
let (batch, rules) = filter.batch_detailed(&touch(&[".git/info/exclude", "b.tmp"]));
|
||||
assert!(batch.is_empty(), "{batch:?}");
|
||||
assert!(batch.modified.is_empty() && batch.vcs.is_empty(), "{batch:?}");
|
||||
assert_eq!(batch.ignored, vec![root.join("b.tmp")]);
|
||||
assert_eq!(rules, vec![root.clone()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,47 @@ fn walk_honours_ignores() {
|
||||
assert_eq!(found, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_scopes_gitignore_to_each_repository() {
|
||||
let (_t, r) = root();
|
||||
// An outer repository ignores everything under `projects/`, nested repository included (#1254).
|
||||
write(&r.join("outer/.git/HEAD"), "ref: refs/heads/main\n");
|
||||
write(&r.join("outer/top.txt"), "");
|
||||
write(&r.join("outer/projects/.gitignore"), "*\n!.gitignore\n");
|
||||
write(&r.join("outer/projects/nested/.git/HEAD"), "ref: refs/heads/main\n");
|
||||
write(&r.join("outer/projects/nested/.git/info/exclude"), "local.txt\n");
|
||||
write(&r.join("outer/projects/nested/.gitignore"), "*.log\n");
|
||||
write(&r.join("outer/projects/nested/search-test.txt"), "");
|
||||
write(&r.join("outer/projects/nested/src/a.rs"), "");
|
||||
write(&r.join("outer/projects/nested/debug.log"), "");
|
||||
write(&r.join("outer/projects/nested/local.txt"), "");
|
||||
write(&r.join("outer/projects/plain/file.txt"), "");
|
||||
write(&r.join("loose/.gitignore"), "*.log\n");
|
||||
write(&r.join("loose/a.txt"), "");
|
||||
write(&r.join("loose/b.log"), "");
|
||||
|
||||
let walk = |dir: &str| {
|
||||
let base = r.join(dir);
|
||||
let found = Mutex::new(BTreeSet::new());
|
||||
walk_files(&base, &[], |p| {
|
||||
found.lock().insert(p.strip_prefix(&base).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
});
|
||||
found.into_inner().into_iter().collect::<Vec<_>>()
|
||||
};
|
||||
let cases: [(&str, &[&str]); 4] = [
|
||||
// The nested repository's own rules apply; the outer repository's do not.
|
||||
("outer/projects/nested", &[".gitignore", "search-test.txt", "src/a.rs"]),
|
||||
// A plain folder inside the outer repository is still governed by it, as in Git.
|
||||
("outer/projects/plain", &[]),
|
||||
("outer", &["projects/.gitignore", "top.txt"]),
|
||||
// Outside any repository `.gitignore` still applies.
|
||||
("loose", &[".gitignore", "a.txt"]),
|
||||
];
|
||||
for (dir, expected) in cases {
|
||||
assert_eq!(walk(dir), expected, "{dir}");
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::{DocState, Documents, RegEntry, disk_len, keys_for};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, NullSink, WorkspaceEdit, WorkspaceEditPrecondition};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -259,7 +259,9 @@ fn hash_bytes(hash: &mut blake3::Hasher, bytes: &[u8]) {
|
||||
hash.update(bytes);
|
||||
}
|
||||
|
||||
fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> {
|
||||
/// Hashes `path` (recursively for a directory). A file in `unsaved` contributes the bytes its
|
||||
/// buffer would save, not its disk bytes, so saving a buffer leaves the hash unchanged.
|
||||
fn hash_disk(hash: &mut blake3::Hasher, path: &Path, unsaved: &HashMap<PathBuf, Vec<u8>>) -> IdeResult<()> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
@@ -277,6 +279,10 @@ fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> {
|
||||
if meta.file_type().is_symlink() {
|
||||
hash.update(b"link");
|
||||
hash_bytes(hash, fs::read_link(path)?.as_os_str().as_encoded_bytes());
|
||||
} else if let Some(bytes) = unsaved.get(path).filter(|_| meta.is_file()) {
|
||||
hash.update(b"file");
|
||||
hash.update(&(bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
} else if meta.is_file() {
|
||||
hash.update(b"file");
|
||||
hash.update(&meta.len().to_le_bytes());
|
||||
@@ -295,7 +301,7 @@ fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> {
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
hash_bytes(hash, entry.file_name().as_encoded_bytes());
|
||||
hash_disk(hash, &entry.path())?;
|
||||
hash_disk(hash, &entry.path(), unsaved)?;
|
||||
}
|
||||
} else {
|
||||
return Err(IdeError::invalid(format!("{} is not a regular file or directory", path.display())));
|
||||
@@ -303,14 +309,19 @@ fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A reverted edit may leave the dirty flag set even when all saved bytes match.
|
||||
fn differs_from_disk(state: &DocState) -> IdeResult<bool> {
|
||||
/// The bytes saving `state` would write.
|
||||
fn saved_bytes(state: &DocState) -> IdeResult<Vec<u8>> {
|
||||
let text = state.doc.text_for_save();
|
||||
let bytes = if state.native_to_ascii {
|
||||
Ok(if state.native_to_ascii {
|
||||
ide_vfs::encode_properties_ascii(&text).into_bytes()
|
||||
} else {
|
||||
ide_vfs::encode(&text, &state.encoding, state.has_bom)?
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/// A reverted edit may leave the dirty flag set even when all saved bytes match.
|
||||
fn differs_from_disk(state: &DocState) -> IdeResult<bool> {
|
||||
let bytes = saved_bytes(state)?;
|
||||
match fs::read(&state.path) {
|
||||
Ok(disk) => Ok(disk != bytes),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
|
||||
@@ -329,35 +340,31 @@ impl Documents {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What each path holds: disk contents, with unsaved buffers in place of their files. Saving
|
||||
/// a buffer doesn't change it; edits, external writes and file operations do.
|
||||
fn fingerprints(&self, paths: &[PathBuf]) -> IdeResult<Vec<WorkspaceEditPrecondition>> {
|
||||
let mut unsaved = HashMap::new();
|
||||
for id in self.open_documents() {
|
||||
self.with_doc(id, |state| {
|
||||
let file = text_path(&state.path)?;
|
||||
if state.dirty && paths.iter().any(|path| file.starts_with(path)) && differs_from_disk(state)? {
|
||||
unsaved.insert(file, saved_bytes(state)?);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let mut hash = blake3::Hasher::new();
|
||||
hash_disk(&mut hash, path)?;
|
||||
let mut dirty = Vec::new();
|
||||
for id in self.open_documents() {
|
||||
self.with_doc(id, |state| {
|
||||
let file = text_path(&state.path)?;
|
||||
if state.dirty && file.starts_with(path) && differs_from_disk(state)? {
|
||||
dirty.push((
|
||||
file,
|
||||
state.doc.text(),
|
||||
state.encoding.clone(),
|
||||
state.doc.line_ending(),
|
||||
state.has_bom,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
dirty.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (file, text, encoding, ending, bom) in dirty {
|
||||
hash_disk(&mut hash, path, &unsaved)?;
|
||||
// Unsaved files not on disk (yet) or outside what the walk above reached.
|
||||
let mut extra: Vec<_> =
|
||||
unsaved.iter().filter(|(file, _)| file.starts_with(path) && !file.exists()).collect();
|
||||
extra.sort_by(|a, b| a.0.cmp(b.0));
|
||||
for (file, bytes) in extra {
|
||||
hash_bytes(&mut hash, file.as_os_str().as_encoded_bytes());
|
||||
hash_bytes(&mut hash, text.as_bytes());
|
||||
hash_bytes(&mut hash, encoding.as_bytes());
|
||||
hash_bytes(&mut hash, ending.as_str().as_bytes());
|
||||
hash.update(&[u8::from(bom)]);
|
||||
hash_bytes(&mut hash, bytes);
|
||||
}
|
||||
Ok(WorkspaceEditPrecondition { path: path.clone().into(), hash: hash.finalize().to_hex().to_string() })
|
||||
})
|
||||
@@ -395,13 +402,18 @@ impl Documents {
|
||||
return Ok(());
|
||||
}
|
||||
let mut edit = normalize_edit(edit)?;
|
||||
let captured = !edit.preconditions.is_empty();
|
||||
self.prepare_workspace_edit(&mut edit)?;
|
||||
self.validate_preconditions(&edit.preconditions)?;
|
||||
for file in &edit.files {
|
||||
if let FileEdit::Edit { path, version: Some(version), .. } = file {
|
||||
let doc = self.by_path(path).ok_or_else(|| stale(path))?;
|
||||
if i64::from(self.version(doc)?) != i64::from(*version) {
|
||||
return Err(stale(path));
|
||||
match self.by_path(path) {
|
||||
Some(doc) if i64::from(self.version(doc)?) != i64::from(*version) => return Err(stale(path)),
|
||||
Some(_) => {}
|
||||
// Servers version files the editor never opened (gopls renames). The
|
||||
// preconditions captured with the edit already cover its disk contents.
|
||||
None if captured => {}
|
||||
None => return Err(stale(path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,37 @@ fn matching_document_version_is_applied() {
|
||||
assert_eq!(std::fs::read_to_string(a).unwrap(), "new\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_edit_of_a_closed_file_is_guarded_by_its_preconditions() {
|
||||
// gopls versions every file of a rename, including ones the editor never opened.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("closed.txt");
|
||||
std::fs::write(&path, "old\n").unwrap();
|
||||
let docs = documents();
|
||||
let versioned = || {
|
||||
let mut operation = edit(&path, "new");
|
||||
if let FileEdit::Edit { version, .. } = &mut operation {
|
||||
*version = Some(0);
|
||||
}
|
||||
WorkspaceEdit { files: vec![operation], ..Default::default() }
|
||||
};
|
||||
// Without a snapshot of what the server saw, a version for a closed file can't be trusted.
|
||||
assert_eq!(docs.apply_workspace_edit(&versioned()).unwrap_err().kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\n");
|
||||
|
||||
let mut changed = versioned();
|
||||
docs.prepare_workspace_edit(&mut changed).unwrap();
|
||||
std::fs::write(&path, "own\n").unwrap();
|
||||
assert_eq!(docs.apply_workspace_edit(&changed).unwrap_err().kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "own\n");
|
||||
|
||||
std::fs::write(&path, "old\n").unwrap();
|
||||
let mut prepared = versioned();
|
||||
docs.prepare_workspace_edit(&mut prepared).unwrap();
|
||||
docs.apply_workspace_edit(&prepared).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unversioned_preview_rejects_changed_disk_and_dirty_buffers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -144,6 +175,38 @@ fn unversioned_preview_rejects_changed_disk_and_dirty_buffers() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_between_preview_and_apply_keeps_the_edit_valid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A new scratch (empty on disk) and an edited file, saved after the edit was computed.
|
||||
for (name, disk, typed) in [("scratch.c", "", "int\n"), ("a.txt", "old\n", "own")] {
|
||||
let path = tmp.path().join(name);
|
||||
std::fs::write(&path, disk).unwrap();
|
||||
let docs = documents();
|
||||
let info = docs.open(&path).unwrap();
|
||||
let replace = if disk.is_empty() { 0 } else { 3 };
|
||||
docs.edit(
|
||||
info.id,
|
||||
info.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: replace, insert: typed.into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() };
|
||||
docs.prepare_workspace_edit(&mut change).unwrap();
|
||||
docs.save(info.id).unwrap();
|
||||
docs.apply_workspace_edit(&change).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(docs.snapshot(info.id).unwrap().text.starts_with("new"), "{name}");
|
||||
// A real change after the preview still invalidates it.
|
||||
let mut change = WorkspaceEdit { files: vec![edit(&path, "xyz")], ..Default::default() };
|
||||
docs.prepare_workspace_edit(&mut change).unwrap();
|
||||
let version = docs.snapshot(info.id).unwrap().version;
|
||||
docs.edit(info.id, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "!".into() }] })
|
||||
.unwrap();
|
||||
docs.save(info.id).unwrap();
|
||||
assert!(docs.apply_workspace_edit(&change).is_err(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_undo_restores_open_closed_and_unsaved_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -30,7 +30,7 @@ Open two project windows, then use **Window → Merge All Project Windows**. The
|
||||
|
||||
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
|
||||
|
||||
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
|
||||
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. Hidden members are kept at the selected window's frame and display (and follow it after a move or resize), so switching projects only changes which window is visible. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Markdown in agent replies, in the real conversation: browser defaults don't suit a narrow chat. */
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TASK = {
|
||||
id: "t1",
|
||||
title: "Repro task",
|
||||
status: "ready" as const,
|
||||
isolation: "local" as const,
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
const REPLY = [
|
||||
"# Heading one",
|
||||
"",
|
||||
"## Heading two",
|
||||
"",
|
||||
"##### Heading five",
|
||||
"",
|
||||
"> A quote",
|
||||
"",
|
||||
"- [x] done",
|
||||
"- [ ] open",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"| Path | Owner | Status | Notes |",
|
||||
"| --- | --- | --- | --- |",
|
||||
"| src/features/agents/conversation/very/long/path/file.tsx | someone | modified | a fairly long note |",
|
||||
"",
|
||||
"```ts",
|
||||
"const a = 1;",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
async function openReply(page: Page, text: string) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, text }) => {
|
||||
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const store = await import(storePath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text }]]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
},
|
||||
{ task: TASK, text },
|
||||
);
|
||||
const reply = page.locator("[class*='_markdown_']").first();
|
||||
await reply.locator("h1, p").first().waitFor();
|
||||
return reply;
|
||||
}
|
||||
|
||||
const css = (reply: ReturnType<Page["locator"]>, selector: string, property: string) =>
|
||||
reply
|
||||
.locator(selector)
|
||||
.first()
|
||||
.evaluate((el, name) => getComputedStyle(el).getPropertyValue(name), property);
|
||||
const px = (value: string) => Number.parseFloat(value);
|
||||
|
||||
test("styles tables so a reply reads as a table", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
expect(await css(reply, "td", "border-top-width")).toBe("1px");
|
||||
expect(await css(reply, "td", "padding-left")).toBe("8px");
|
||||
expect(await css(reply, "th", "border-top-width")).toBe("1px");
|
||||
// A narrow chat must not squeeze a column to a few letters.
|
||||
const widths = await reply
|
||||
.locator("td")
|
||||
.evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width));
|
||||
for (const width of widths) expect(width).toBeGreaterThanOrEqual(70);
|
||||
});
|
||||
|
||||
test("scrolls a wide table inside the reply", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const sizes = await reply.evaluate((el) => {
|
||||
const table = el.querySelector("table")!;
|
||||
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
|
||||
});
|
||||
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
|
||||
expect(sizes.content).toBeGreaterThan(sizes.table);
|
||||
});
|
||||
|
||||
test("sizes headings for a chat and keeps small ones readable", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const body = px(await css(reply, "td", "font-size"));
|
||||
const h1 = px(await css(reply, "h1", "font-size"));
|
||||
expect(h1).toBeGreaterThan(body);
|
||||
expect(h1).toBeLessThanOrEqual(body * 1.2);
|
||||
expect(px(await css(reply, "h2", "font-size"))).toBeLessThan(h1);
|
||||
expect(px(await css(reply, "h5", "font-size"))).toBe(body);
|
||||
});
|
||||
|
||||
test("starts and ends a reply without stray margins", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
expect(await css(reply, "> :first-child", "margin-top")).toBe("0px");
|
||||
expect(await css(reply, "> :last-child", "margin-bottom")).toBe("0px");
|
||||
});
|
||||
|
||||
test("styles quotes, rules, images and task lists", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
expect(await css(reply, "blockquote", "margin-left")).toBe("0px");
|
||||
expect(await css(reply, "blockquote", "border-left-width")).toBe("3px");
|
||||
expect(await css(reply, "hr", "border-left-width")).toBe("0px");
|
||||
expect(await css(reply, "hr", "border-top-width")).toBe("1px");
|
||||
expect(await css(reply, "img", "max-width")).toBe("100%");
|
||||
// A checkbox stands in for the bullet.
|
||||
expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none");
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Code intelligence through the editor: completion, navigation, usages, rename, quick fixes and
|
||||
* diagnostics. The language server is stubbed at the IPC layer; the servers themselves are covered
|
||||
* by `crates/ide-lsp/tests`.
|
||||
*/
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const FILE = `${ROOT}/build.gradle.kts`;
|
||||
|
||||
const at = (line: number, col: number) => ({ line, col });
|
||||
const range = (line: number, from: number, to: number) => ({ start: at(line, from), end: at(line, to) });
|
||||
|
||||
/** Puts the caret just before `word` on the line containing `text`. */
|
||||
async function caretAt(editor: Locator, page: Page, text: string, word: string) {
|
||||
const line = editor.locator(".cm-line", { hasText: text });
|
||||
await line.click();
|
||||
await page.keyboard.press("Home");
|
||||
const content = (await line.textContent()) ?? "";
|
||||
const indent = content.length - content.trimStart().length;
|
||||
for (let i = indent; i < content.indexOf(word); i++) await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
|
||||
async function openSample(page: Page) {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await stubDesktop(page);
|
||||
return editor;
|
||||
}
|
||||
|
||||
test("completion lists the server's items and inserts the chosen one", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(() => {
|
||||
const item = (label: string, kind: string, detail: string, insertText: string) => ({
|
||||
label,
|
||||
labelDetail: null,
|
||||
detail,
|
||||
kind,
|
||||
documentation: null,
|
||||
sortText: null,
|
||||
filterText: null,
|
||||
preselect: false,
|
||||
deprecated: false,
|
||||
insertText,
|
||||
isSnippet: false,
|
||||
replaceRange: null,
|
||||
replaceRangeTab: null,
|
||||
additionalEdits: [],
|
||||
commitCharacters: [],
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
});
|
||||
window.__ipc.handlers.lsp_completion = () => ({
|
||||
isIncomplete: false,
|
||||
items: [item("toDto", "method", "OrderDto", "toDto()"), item("total", "property", "BigDecimal", "total")],
|
||||
});
|
||||
});
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("order.");
|
||||
await page.keyboard.press("Control+Space");
|
||||
const popup = page.locator(".cm-tooltip-autocomplete");
|
||||
await expect(popup.getByText("total", { exact: true })).toBeVisible();
|
||||
await expect(popup.getByText("toDto", { exact: true })).toBeVisible();
|
||||
await page.keyboard.type("toD");
|
||||
await expect(popup.getByText("total", { exact: true })).toBeHidden();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(popup).toBeHidden();
|
||||
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*order\.toDto\(\)$/);
|
||||
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ path, target }) => {
|
||||
window.__ipc.handlers.lsp_goto = () => [{ path, range: target }];
|
||||
},
|
||||
{ path: `${ROOT}/settings.gradle.kts`, target: range(20, 12, 17) },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "service");
|
||||
await page.keyboard.press("Meta+b");
|
||||
const tabs = page.getByRole("tablist", { name: "Editor tabs" });
|
||||
await expect(tabs.getByRole("tab", { name: "settings.gradle.kts" })).toHaveAttribute("aria-selected", "true");
|
||||
const target = page.locator(".cm-editor").filter({ has: page.locator(".cm-activeLine") });
|
||||
await expect(target.locator(".cm-activeLine")).toContainText("val order: Order = service.create(req)");
|
||||
const [args] = await ipcCalls(page, "lsp_goto");
|
||||
expect(args).toMatchObject({ pos: at(20, 27), kind: "definition" });
|
||||
});
|
||||
|
||||
test("Find Usages lists every usage by file and opens one", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, other }) => {
|
||||
const loc = (path: string, line: number, from: number, to: number) => ({
|
||||
path,
|
||||
range: { start: { line, col: from }, end: { line, col: to } },
|
||||
});
|
||||
window.__ipc.handlers.lsp_references = () => ({
|
||||
locations: [loc(file, 16, 8, 15), loc(file, 20, 27, 34), loc(other, 16, 8, 15)],
|
||||
incomplete: null,
|
||||
});
|
||||
},
|
||||
{ file: FILE, other: `${ROOT}/settings.gradle.kts` },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "service");
|
||||
await page.keyboard.press("Alt+F7");
|
||||
const usages = page.getByRole("tree", { name: /usages/i });
|
||||
await expect(usages.getByRole("treeitem", { name: "Found usages 3 results" })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: "build.gradle.kts 2 usages" })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: /^17 service\.find\(status/ })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: /^21 val order: Order = service\.create/ })).toBeVisible();
|
||||
const other = usages.getByRole("treeitem", { name: "settings.gradle.kts 1 usage" });
|
||||
await expect(other).toBeVisible();
|
||||
// Double-clicking a usage in another file opens that file.
|
||||
await usages.getByRole("treeitem", { name: /^17/ }).last().dblclick();
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "settings.gradle.kts" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("⇧F6 renames in place and sends the server's edit to be applied", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, prepared, uses }) => {
|
||||
window.__ipc.handlers.lsp_prepare_rename = () => prepared;
|
||||
window.__ipc.handlers.lsp_rename = (args) => ({
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: file,
|
||||
edits: uses.map((range) => ({ range, new_text: args.newName })),
|
||||
},
|
||||
],
|
||||
},
|
||||
incomplete: null,
|
||||
});
|
||||
},
|
||||
{ file: FILE, prepared: range(20, 12, 17), uses: [range(20, 12, 17), range(21, 15, 20)] },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "order");
|
||||
await page.keyboard.press("Shift+F6");
|
||||
await expect(page.getByText(/Press ⇧F6 to show dialog/)).toBeVisible();
|
||||
// The name starts selected; occurrences follow as it's typed.
|
||||
await page.keyboard.type("placed");
|
||||
await expect(editor.locator(".cm-line", { hasText: "val placed: Order" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => ipcCalls(page, "apply_workspace_edit")).toHaveLength(1);
|
||||
const [rename] = await ipcCalls(page, "lsp_rename");
|
||||
expect(rename).toMatchObject({ pos: at(20, 12), newName: "placed" });
|
||||
const [apply] = await ipcCalls(page, "apply_workspace_edit");
|
||||
expect(JSON.stringify(apply)).toContain('"new_text":"placed"');
|
||||
});
|
||||
|
||||
test("diagnostics underline the range and ⌥⏎ applies the preferred quick fix", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, bad }) => {
|
||||
window.__ipc.handlers.lsp_code_actions = () => [
|
||||
{
|
||||
title: "Import 'com.acme.orders.model.Order'",
|
||||
kind: "quickfix",
|
||||
isPreferred: true,
|
||||
disabledReason: null,
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: file,
|
||||
edits: [
|
||||
{
|
||||
range: { start: { line: 4, col: 0 }, end: { line: 4, col: 0 } },
|
||||
new_text: "import com.acme.orders.model.Order\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
},
|
||||
{
|
||||
title: "Create class 'Order'",
|
||||
kind: "quickfix",
|
||||
isPreferred: false,
|
||||
disabledReason: null,
|
||||
edit: null,
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
},
|
||||
];
|
||||
window.__ipc.emit({
|
||||
type: "diagnostics",
|
||||
path: file,
|
||||
source: "kotlin",
|
||||
items: [
|
||||
{ range: bad, severity: "error", message: "Unresolved reference: Order", source: "kotlin", code: null },
|
||||
],
|
||||
});
|
||||
},
|
||||
{ file: FILE, bad: range(20, 19, 24) },
|
||||
);
|
||||
const squiggle = editor.locator(".cm-lintRange-error");
|
||||
await expect(squiggle).toHaveText("Order");
|
||||
await caretAt(editor, page, "val order: Order", "Order ");
|
||||
await page.keyboard.press("Alt+Enter");
|
||||
const fix = page.getByRole("option", { name: /Import 'com\.acme\.orders\.model\.Order'/ });
|
||||
await expect(fix).toBeVisible();
|
||||
await expect(page.getByRole("option", { name: /Create class 'Order'/ })).toBeVisible();
|
||||
await fix.click();
|
||||
await expect.poll(() => ipcCalls(page, "lsp_execute_code_action")).toHaveLength(1);
|
||||
const [applied] = await ipcCalls(page, "lsp_execute_code_action");
|
||||
expect(applied).toMatchObject({ action: { title: "Import 'com.acme.orders.model.Order'" } });
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Committing from the Commit tool window. Git is stubbed at the IPC layer; the repository side is
|
||||
* covered by `crates/ide-vcs` against real repositories.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
/** A repository with a modified tracked file and an untracked one; `vcs_commit` clears both. */
|
||||
async function stubRepository(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
let committed = false;
|
||||
const changes = [
|
||||
{
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
},
|
||||
{ path: `${root}/notes.txt`, relPath: "notes.txt", staged: null, unstaged: "untracked", renamedFrom: null },
|
||||
];
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => (committed ? [] : changes);
|
||||
h.vcs_stage = () => null;
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = () => {
|
||||
committed = true;
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
}, ROOT);
|
||||
}
|
||||
|
||||
test("⌘K commits the checked changes with the typed message", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
await stubRepository(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const panel = page.getByRole("region", { name: "Commit", exact: true });
|
||||
const commit = panel.getByRole("button", { name: "Commit", exact: true });
|
||||
const changes = panel.getByRole("tree", { name: "Changes" });
|
||||
// Tracked changes start included; unversioned files start excluded.
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeChecked();
|
||||
await expect(changes.getByRole("checkbox", { name: "Include Unversioned Files in commit" })).not.toBeChecked();
|
||||
const message = panel.getByRole("textbox", { name: "Commit message" });
|
||||
await expect(message).toBeFocused();
|
||||
|
||||
// Committing without a message is refused.
|
||||
await commit.click();
|
||||
await expect(panel.getByRole("alert")).toHaveText("Specify commit message");
|
||||
expect(await ipcCalls(page, "vcs_commit")).toHaveLength(0);
|
||||
|
||||
await message.fill("Return order DTOs from create");
|
||||
await commit.click();
|
||||
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
|
||||
const [committed] = await ipcCalls(page, "vcs_commit");
|
||||
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
|
||||
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
|
||||
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A debugging session: a breakpoint set in the editor reaches the adapter, the session stops there
|
||||
* with its frames and variables, and stepping and resuming go back to the adapter. The adapter is
|
||||
* stubbed at the IPC layer; `crates/ide-dap/tests` drives real ones.
|
||||
*/
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const FILE = `${ROOT}/build.gradle.kts`;
|
||||
const SESSION = 3;
|
||||
|
||||
test("stops at a breakpoint, shows frames and variables, steps and resumes", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ file, root, session }) => {
|
||||
const config = {
|
||||
id: "python:app",
|
||||
name: "app",
|
||||
kind: "python",
|
||||
command: "python3",
|
||||
args: ["app.py"],
|
||||
cwd: root,
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: "debugpy",
|
||||
};
|
||||
const h = window.__ipc.handlers;
|
||||
const debug = (event: Record<string, unknown>) => window.__ipc.emit({ type: "debug", session, event });
|
||||
const stopAt = (line: number) => {
|
||||
h.debug_stack_trace = () => [
|
||||
{ id: 11, name: "create", path: file, line, column: 0, presentationHint: null },
|
||||
{ id: 12, name: "handle", path: file, line: 16, column: 0, presentationHint: null },
|
||||
];
|
||||
debug({ kind: "stopped", threadId: 1, reason: "breakpoint", description: null, allThreadsStopped: true });
|
||||
};
|
||||
h.run_configs = () => [config];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.debug_set_breakpoints = (args) =>
|
||||
(args.breakpoints as { line: number }[]).map((b, i) => ({
|
||||
id: i + 1,
|
||||
verified: true,
|
||||
line: b.line,
|
||||
message: null,
|
||||
}));
|
||||
h.debug_start = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "started", name: config.name, adapter: "debugpy", parent: null });
|
||||
debug({ kind: "initialized" });
|
||||
stopAt(20);
|
||||
}, 50);
|
||||
return session;
|
||||
};
|
||||
h.debug_threads = () => [{ id: 1, name: "MainThread" }];
|
||||
h.debug_scopes = () => [{ name: "Locals", variablesReference: 100, expensive: false }];
|
||||
h.debug_variables = () => [
|
||||
{
|
||||
name: "req",
|
||||
value: "CreateOrderRequest(sku='A-1', qty=2)",
|
||||
typeName: "CreateOrderRequest",
|
||||
variablesReference: 0,
|
||||
evaluateName: "req",
|
||||
},
|
||||
{ name: "qty", value: "2", typeName: "int", variablesReference: 0, evaluateName: "qty" },
|
||||
];
|
||||
h.debug_step = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "continued", threadId: 1, allThreadsContinued: true });
|
||||
stopAt(21);
|
||||
}, 50);
|
||||
return null;
|
||||
};
|
||||
h.debug_resume = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "continued", threadId: 1, allThreadsContinued: true });
|
||||
debug({ kind: "exited", code: 0 });
|
||||
debug({ kind: "terminated" });
|
||||
}, 50);
|
||||
return null;
|
||||
};
|
||||
const runModule = "/src/features/run/store.ts";
|
||||
const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ runModule);
|
||||
await loadConfigs();
|
||||
selectConfig(config.id);
|
||||
},
|
||||
{ file: FILE, root: ROOT, session: SESSION },
|
||||
);
|
||||
|
||||
// Breakpoint and execution lines carry CSS-module classes (`_bpLine_<hash>`).
|
||||
const line = (text: string) => editor.locator(".cm-line", { hasText: text });
|
||||
await line("val order: Order").click();
|
||||
await page.keyboard.press("Meta+F8");
|
||||
await expect(line("val order: Order")).toHaveClass(/_bpLine_/);
|
||||
|
||||
await page.keyboard.press("Control+d");
|
||||
await expect.poll(() => ipcCalls(page, "debug_start")).toHaveLength(1);
|
||||
const set = (await ipcCalls(page, "debug_set_breakpoints")).filter((a) => a.path === FILE).at(-1);
|
||||
expect(set?.breakpoints).toEqual([expect.objectContaining({ line: 20 })]);
|
||||
|
||||
const debug = page.getByRole("region", { name: "Debug", exact: true });
|
||||
await expect(debug.getByText("create", { exact: true })).toBeVisible();
|
||||
await expect(debug.getByText("handle", { exact: true })).toBeVisible();
|
||||
await expect(debug.getByText("CreateOrderRequest(sku='A-1', qty=2)")).toBeVisible();
|
||||
await expect(line("val order: Order")).toHaveClass(/_execLine_/);
|
||||
|
||||
await page.keyboard.press("F8");
|
||||
await expect.poll(() => ipcCalls(page, "debug_step")).toEqual([{ session: SESSION, threadId: 1, kind: "over" }]);
|
||||
await expect(line("return order.toDto()")).toHaveClass(/_execLine_/);
|
||||
await expect(line("val order: Order")).not.toHaveClass(/_execLine_/);
|
||||
|
||||
await page.keyboard.press("F9");
|
||||
await expect.poll(() => ipcCalls(page, "debug_resume")).toHaveLength(1);
|
||||
await expect(debug.getByText("CreateOrderRequest(sku='A-1', qty=2)")).toBeHidden();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
test("opens a file from the project tree and types into it", async ({ workbench: page }) => {
|
||||
@@ -25,6 +26,49 @@ test("⌘F opens the find bar and highlights matches; Escape returns to the edit
|
||||
await expect(editor.locator(".cm-content")).toBeFocused();
|
||||
});
|
||||
|
||||
test("each file keeps its own find bar, query and caret", async ({ workbench: page }) => {
|
||||
const find = page.getByRole("textbox", { name: "Find" });
|
||||
const caret = page.getByRole("button", { name: /^\d+:\d+/ });
|
||||
const tab = (name: string) => page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name });
|
||||
|
||||
const a = await openFromTree(page, "build.gradle.kts");
|
||||
await a.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+f");
|
||||
await find.fill("Order");
|
||||
await expect(caret).toHaveText(/\(5 chars\)/);
|
||||
const caretA = await caret.textContent();
|
||||
|
||||
// Another file has no bar until ⌘F; opening one starts from the last search without moving the caret.
|
||||
const b = await openFromTree(page, "settings.gradle.kts");
|
||||
await expect(find).toBeHidden();
|
||||
await b.locator(".cm-line", { hasText: "@PostMapping" }).click();
|
||||
const caretB = await caret.textContent();
|
||||
await page.keyboard.press("Meta+f");
|
||||
await expect(find).toHaveValue("Order");
|
||||
await expect(caret).toHaveText(caretB!);
|
||||
await find.fill("status");
|
||||
await expect(caret).toHaveText(/\(6 chars\)/);
|
||||
const caretB2 = await caret.textContent();
|
||||
|
||||
// A third file starts from the latest search.
|
||||
await openFromTree(page, "README.md");
|
||||
await expect(find).toBeHidden();
|
||||
await page.keyboard.press("Meta+f");
|
||||
await expect(find).toHaveValue("status");
|
||||
|
||||
// Switching back shows each file's own query, caret untouched.
|
||||
await tab("build.gradle.kts").click();
|
||||
await expect(find).toHaveValue("Order");
|
||||
await expect(caret).toHaveText(caretA!);
|
||||
await tab("settings.gradle.kts").click();
|
||||
await expect(find).toHaveValue("status");
|
||||
await expect(caret).toHaveText(caretB2!);
|
||||
await find.press("Escape");
|
||||
await expect(find).toBeHidden();
|
||||
await tab("build.gradle.kts").click();
|
||||
await expect(find).toHaveValue("Order");
|
||||
});
|
||||
|
||||
test("opening several files creates tabs and ⌘W closes the active one", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
await openFromTree(page, "settings.gradle.kts");
|
||||
@@ -59,3 +103,44 @@ test("breadcrumbs follow the caret into the symbol under it", async ({ workbench
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(bar).toBeHidden();
|
||||
});
|
||||
|
||||
/** What the in-memory backend has saved for `path`. */
|
||||
const saved = (page: Page, path: string) =>
|
||||
page.evaluate(async (path) => {
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const { docBackend } = await import(/* @vite-ignore */ backendModule);
|
||||
return (docBackend() as { files: Map<string, string> }).files.get(path) ?? null;
|
||||
}, path);
|
||||
|
||||
test("an edit marks the tab modified and ⌘S saves it", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
// The only tab; its modified dot sits next to its close button.
|
||||
const tabs = page.getByRole("tablist", { name: "Editor tabs" });
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(0);
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("// saved by e2e");
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(1);
|
||||
expect(await saved(page, "/dev/acme-api/build.gradle.kts")).toBeNull();
|
||||
await page.keyboard.press("Meta+s");
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(0);
|
||||
expect(await saved(page, "/dev/acme-api/build.gradle.kts")).toContain("// saved by e2e");
|
||||
});
|
||||
|
||||
test("⌘R replaces every match in the file", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+r");
|
||||
const bar = page.getByRole("search", { name: "Replace in file" });
|
||||
await bar.getByRole("textbox", { name: "Find" }).fill("OrderDto");
|
||||
await bar.getByRole("textbox", { name: "Replace" }).fill("OrderView");
|
||||
await bar.getByRole("button", { name: "Replace All" }).click();
|
||||
const content = editor.locator(".cm-content");
|
||||
await expect(content).not.toContainText("OrderDto");
|
||||
await expect(editor.locator(".cm-line", { hasText: "OrderView" })).toHaveCount(2);
|
||||
// One undo step takes the whole replacement back.
|
||||
await content.click();
|
||||
await page.keyboard.press("Meta+z");
|
||||
await expect(editor.locator(".cm-line", { hasText: "OrderDto" })).toHaveCount(2);
|
||||
});
|
||||
@@ -23,6 +23,97 @@ export const test = base.extend<{ workbench: Page }>({
|
||||
|
||||
export { expect };
|
||||
|
||||
/** What `stubDesktop` installs on `window.__ipc`; handlers are defined inside `page.evaluate`. */
|
||||
export interface IpcStub {
|
||||
/** Command name as the backend registers it (`lsp_goto`) → its answer; throw `{ kind, message }` for an `IdeError`. */
|
||||
handlers: Record<string, (args: Record<string, unknown>) => unknown>;
|
||||
calls: { cmd: string; args: Record<string, unknown> }[];
|
||||
/** Sends one message down a `Channel` passed to a command. */
|
||||
send(channel: unknown, message: unknown): void;
|
||||
/** Emits an `IdeEvent` the way the backend does. */
|
||||
emit(event: { type: string } & Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ipc: IpcStub;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the page into desktop mode over a stubbed native layer: commands with a handler answer,
|
||||
* every other command fails as unavailable, the way a backend without that feature does. Documents
|
||||
* keep their in-memory backend; the project tree keeps its sample only while it isn't reloaded, so
|
||||
* open files from it before stubbing.
|
||||
*/
|
||||
export async function stubDesktop(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
// Documents stay in memory: the editor's backend is chosen once, on first use.
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const { docBackend } = await import(/* @vite-ignore */ backendModule);
|
||||
docBackend();
|
||||
const callbacks = new Map<number, (message: unknown) => void>();
|
||||
const channelIndex = new Map<number, number>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
let nextCallback = 1;
|
||||
const ipc: IpcStub = {
|
||||
// This window is the only one.
|
||||
handlers: { "plugin:window|get_all_windows": () => [] },
|
||||
calls: [],
|
||||
send(channel, message) {
|
||||
const id = (channel as { id: number }).id;
|
||||
const index = channelIndex.get(id) ?? 0;
|
||||
channelIndex.set(id, index + 1);
|
||||
callbacks.get(id)?.({ index, message });
|
||||
},
|
||||
emit(event) {
|
||||
for (const l of listeners)
|
||||
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
|
||||
},
|
||||
};
|
||||
window.__ipc = ipc;
|
||||
Object.defineProperty(window, "__TAURI_EVENT_PLUGIN_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: { unregisterListener: () => {} },
|
||||
});
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: (message: unknown) => void) {
|
||||
const id = nextCallback++;
|
||||
callbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
unregisterCallback(id: number) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
convertFileSrc: (path: string) => path,
|
||||
async invoke(cmd: string, args: Record<string, unknown> = {}) {
|
||||
ipc.calls.push({ cmd, args });
|
||||
if (cmd === "plugin:event|listen") {
|
||||
listeners.push({ event: args.event as string, handler: args.handler as number });
|
||||
return listeners.length;
|
||||
}
|
||||
const handler = ipc.handlers[cmd];
|
||||
// Tauri plugin calls (windows, events) succeed quietly; app commands are what tests stub.
|
||||
if (!handler && cmd.startsWith("plugin:")) return null;
|
||||
if (!handler) throw { kind: "unavailable", message: `${cmd} is not stubbed`, detail: null };
|
||||
return handler(args);
|
||||
},
|
||||
},
|
||||
});
|
||||
const ipcModule = "/src/platform/ipc.ts";
|
||||
const { ideEventsReady } = await import(/* @vite-ignore */ ipcModule);
|
||||
await ideEventsReady();
|
||||
});
|
||||
}
|
||||
|
||||
/** The commands the page has invoked so far under `stubDesktop`, by name. */
|
||||
export async function ipcCalls(page: Page, cmd: string): Promise<Record<string, unknown>[]> {
|
||||
return page.evaluate((cmd) => window.__ipc.calls.filter((c) => c.cmd === cmd).map((c) => c.args), cmd);
|
||||
}
|
||||
|
||||
/** Tool windows currently laid out (visible) in the workbench. */
|
||||
export async function visibleToolWindows(page: Page): Promise<string[]> {
|
||||
return page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the
|
||||
* Java side is covered by plugins/java-usages/tests/hierarchy.py against a real JDT LS.
|
||||
* Java side is covered by java/java-usages/tests/hierarchy.py against a real JDT LS.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Closing a project returns to the Welcome screen, and a recent project opens from there into the
|
||||
* workbench. The backend's project handling is stubbed at the IPC layer; `crates/ide-host/tests`
|
||||
* covers opening and closing real projects.
|
||||
*/
|
||||
import { doubleShift, expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const BILLING = "/dev/billing";
|
||||
|
||||
test("Close Project shows the Welcome screen; a recent project opens from it once trusted", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate((root) => {
|
||||
const entry = (name: string, kind: string) => ({
|
||||
name,
|
||||
path: `${root}/${name}`,
|
||||
kind,
|
||||
size: 0,
|
||||
mtimeMs: null,
|
||||
ignored: false,
|
||||
excluded: false,
|
||||
});
|
||||
const h = window.__ipc.handlers;
|
||||
h.close_project = () => null;
|
||||
h.app_info = () => ({ version: "1.0.0", platform: "macos", configDir: "/Users/dev/.openidea", wayland: false });
|
||||
h.recent_projects = () => [
|
||||
{ path: root, name: "billing", openedAtMs: Date.now() - 3_600_000, branch: "main" },
|
||||
{ path: "/dev/acme-api", name: "acme-api", openedAtMs: Date.now(), branch: "main" },
|
||||
];
|
||||
let trusted = false;
|
||||
h.project_trust_status = (args) => ({ root: args.path, trusted, decided: trusted });
|
||||
h.project_trust_set = (args) => {
|
||||
trusted = Boolean(args.trusted);
|
||||
return null;
|
||||
};
|
||||
h.open_project = (args) => ({
|
||||
trusted: true,
|
||||
root: args.path,
|
||||
name: String(args.path).split("/").pop(),
|
||||
isGit: false,
|
||||
settings: {},
|
||||
});
|
||||
h.fs_read_dir = (args) =>
|
||||
args.path === root ? [entry("src", "dir"), entry("package.json", "file"), entry("README.md", "file")] : [];
|
||||
}, BILLING);
|
||||
|
||||
await doubleShift(page);
|
||||
const search = page.getByRole("dialog", { name: "Search Everywhere" });
|
||||
await search.getByRole("combobox").fill("Close Project");
|
||||
await expect(search.getByRole("option", { name: /^Close Project/ }).first()).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projects = page.getByRole("region", { name: "Projects" });
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toBeHidden();
|
||||
await expect(projects.getByRole("option", { name: /^billing/ })).toBeVisible();
|
||||
expect(await ipcCalls(page, "close_project")).toHaveLength(1);
|
||||
|
||||
await projects.getByRole("option", { name: /^billing/ }).click();
|
||||
// A project not yet trusted asks first.
|
||||
const trust = page.getByRole("dialog", { name: "Trust and Open Project?" });
|
||||
await expect(trust).toBeVisible();
|
||||
expect(await ipcCalls(page, "open_project")).toHaveLength(0);
|
||||
await trust.getByRole("button", { name: "Trust Project" }).click();
|
||||
await expect(trust).toBeHidden();
|
||||
expect(await ipcCalls(page, "project_trust_set")).toEqual([{ path: BILLING, trusted: true, folder: false }]);
|
||||
await expect.poll(() => ipcCalls(page, "open_project")).toEqual([{ path: BILLING }]);
|
||||
const tree = page.getByRole("tree", { name: "Project" });
|
||||
await expect(tree.getByRole("treeitem", { name: /^billing/ })).toBeVisible();
|
||||
await expect(tree.getByRole("treeitem", { name: "package.json" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Running a configuration: output streams into the Run tool window, the exit shows, and Stop ends
|
||||
* the process. Processes are stubbed at the IPC layer; `crates/ide-run` covers spawning them.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const RUN_ID = 7;
|
||||
|
||||
/** One saved `npm run dev` configuration; `run_start` prints `output` and exits with `exit` unless null. */
|
||||
async function stubRun(page: Page, output: { kind: string; text: string }[], exit: number | null) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ output, exit, runId }) => {
|
||||
const config = {
|
||||
id: "npm:dev",
|
||||
name: "dev",
|
||||
kind: "npm",
|
||||
command: "npm",
|
||||
args: ["run", "dev"],
|
||||
cwd: "/dev/acme-api",
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
};
|
||||
const h = window.__ipc.handlers;
|
||||
h.run_configs = () => [config];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.run_start = (args) => {
|
||||
setTimeout(() => {
|
||||
for (const chunk of output) window.__ipc.send(args.onOutput, chunk);
|
||||
if (exit !== null) setTimeout(() => window.__ipc.emit({ type: "runExited", run: runId, code: exit }), 50);
|
||||
}, 50);
|
||||
return { id: runId, configId: config.id, name: config.name, pid: 4242, running: true, pty: false };
|
||||
};
|
||||
h.run_stop = () => {
|
||||
setTimeout(() => window.__ipc.emit({ type: "runExited", run: runId, code: 143 }), 50);
|
||||
return null;
|
||||
};
|
||||
const runModule = "/src/features/run/store.ts";
|
||||
const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ runModule);
|
||||
await loadConfigs();
|
||||
selectConfig(config.id);
|
||||
},
|
||||
{ output, exit, runId: RUN_ID },
|
||||
);
|
||||
}
|
||||
|
||||
test("⌃R runs the selected configuration and shows its output and exit", async ({ workbench: page }) => {
|
||||
await stubRun(
|
||||
page,
|
||||
[
|
||||
{ kind: "stdout", text: "> [email protected] dev\n" },
|
||||
{ kind: "stdout", text: "Server listening on http://localhost:8080\n" },
|
||||
{ kind: "stderr", text: "warning: config/dev.json not found, using defaults\n" },
|
||||
],
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Control+r");
|
||||
const run = page.getByRole("region", { name: "Run", exact: true });
|
||||
await expect(run).toBeVisible();
|
||||
await expect(run.getByText("Server listening on http://localhost:8080")).toBeVisible();
|
||||
await expect(run.getByText("warning: config/dev.json not found, using defaults")).toBeVisible();
|
||||
// Once the process exits, Stop is off and the session's tab tells the exit code.
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeDisabled();
|
||||
await expect(run.getByRole("tab", { name: "dev" })).toHaveAttribute("data-tooltip", /exit code 0$/);
|
||||
const [started] = await ipcCalls(page, "run_start");
|
||||
expect(started).toMatchObject({ config: { id: "npm:dev", command: "npm", args: ["run", "dev"] } });
|
||||
});
|
||||
|
||||
test("⌘F2 stops a running process", async ({ workbench: page }) => {
|
||||
await stubRun(page, [{ kind: "stdout", text: "Watching for changes…\n" }], null);
|
||||
await page.keyboard.press("Control+r");
|
||||
const run = page.getByRole("region", { name: "Run", exact: true });
|
||||
await expect(run.getByText("Watching for changes…")).toBeVisible();
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeEnabled();
|
||||
await page.keyboard.press("Meta+F2");
|
||||
await expect.poll(() => ipcCalls(page, "run_stop")).toEqual([{ id: RUN_ID }]);
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeDisabled();
|
||||
await expect(run.getByRole("tab", { name: "dev" })).toHaveAttribute("data-tooltip", /exit code 143$/);
|
||||
});
|
||||
+83
-6
@@ -1,4 +1,4 @@
|
||||
import { doubleShift, expect, test } from "./fixtures";
|
||||
import { doubleShift, expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("double Shift opens Search Everywhere with its tabs; Escape closes it", async ({ workbench: page }) => {
|
||||
await doubleShift(page);
|
||||
@@ -23,15 +23,92 @@ test("Search Everywhere finds and runs an action", async ({ workbench: page }) =
|
||||
await expect(page.getByRole("list", { name: /language servers/i })).toBeVisible();
|
||||
});
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const SERVICE = `${ROOT}/src/main/kotlin/com/acme/orders/service/OrderService.kt`;
|
||||
|
||||
test("Search Everywhere finds a file and opens it", async ({ workbench: page }) => {
|
||||
// TODO(search): plain-browser mode has no in-memory `findFiles`, so the Files
|
||||
// source returns nothing. Un-fixme once search/everywhere gets browser deps
|
||||
// (e.g. backed by the project-tree sample file system).
|
||||
test.fixme(true, "Search Everywhere has no in-memory file source in browser mode");
|
||||
// The project's file index is stubbed at the IPC layer.
|
||||
await stubDesktop(page);
|
||||
await page.evaluate((path) => {
|
||||
window.__ipc.handlers.find_files = (args) =>
|
||||
String(args.query).startsWith("OrderSer")
|
||||
? [{ path, relPath: path.slice("/dev/acme-api/".length), score: 100, indices: [] }]
|
||||
: [];
|
||||
}, SERVICE);
|
||||
await doubleShift(page);
|
||||
const dialog = page.getByRole("dialog", { name: "Search Everywhere" });
|
||||
await dialog.getByRole("combobox").fill("OrderService");
|
||||
await expect(dialog.getByRole("option", { name: /OrderService\.kt/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("tab", { name: "OrderService.kt" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "OrderService.kt" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("textbox", { name: `Editor for ${SERVICE}` })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Find in Files streams matches by file and opens the chosen one at its line", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
({ root, service }) => {
|
||||
const match = (line: number, text: string, word: string) => ({
|
||||
line,
|
||||
text,
|
||||
ranges: [[text.indexOf(word), text.indexOf(word) + word.length]],
|
||||
textOffset: 0,
|
||||
});
|
||||
window.__ipc.handlers.search_validate = () => null;
|
||||
window.__ipc.handlers.search_start = (args) => {
|
||||
const send = (event: unknown) => window.__ipc.send(args.onEvent, event);
|
||||
setTimeout(() => {
|
||||
send({
|
||||
type: "file",
|
||||
file: {
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
matches: [
|
||||
match(7, "class OrderController(private val service: OrderService) {", "OrderService"),
|
||||
match(3, "import com.acme.orders.service.OrderService", "OrderService"),
|
||||
],
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: "file",
|
||||
file: {
|
||||
path: service,
|
||||
relPath: service.slice(root.length + 1),
|
||||
matches: [match(0, "// OrderService", "OrderService")],
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: "done",
|
||||
summary: { files: 2, matches: 3, limitHit: false, cancelled: false, elapsedMs: 4 },
|
||||
});
|
||||
}, 20);
|
||||
return null;
|
||||
};
|
||||
},
|
||||
{ root: ROOT, service: SERVICE },
|
||||
);
|
||||
await page.keyboard.press("Meta+Shift+f");
|
||||
const dialog = page.getByRole("dialog", { name: "Find in Files" });
|
||||
await dialog.getByRole("textbox", { name: "Text to find" }).fill("OrderService");
|
||||
const matches = dialog.getByRole("listbox", { name: "Matches" });
|
||||
await expect(matches.getByRole("option")).toHaveCount(3);
|
||||
await expect
|
||||
.poll(async () => (await ipcCalls(page, "search_start")).at(-1)?.query)
|
||||
.toMatchObject({
|
||||
pattern: "OrderService",
|
||||
});
|
||||
await matches.getByRole("option", { name: /import com\.acme\.orders\.service\.OrderService/ }).dblclick();
|
||||
await expect(page.getByRole("tab", { name: "build.gradle.kts" })).toHaveAttribute("aria-selected", "true");
|
||||
const caretLine = () =>
|
||||
page.evaluate((label) => {
|
||||
const content = document.querySelector(`.cm-content[aria-label="${label}"]`) as unknown as {
|
||||
cmTile: {
|
||||
view: { state: { doc: { lineAt(pos: number): { text: string } }; selection: { main: { head: number } } } };
|
||||
};
|
||||
} | null;
|
||||
if (!content) return null;
|
||||
const { state } = content.cmTile.view;
|
||||
return state.doc.lineAt(state.selection.main.head).text;
|
||||
}, `Editor for ${ROOT}/build.gradle.kts`);
|
||||
await expect.poll(caretLine).toBe("import com.acme.orders.service.OrderService");
|
||||
});
|
||||
+73
-1
@@ -1,4 +1,4 @@
|
||||
import { expect, test, visibleToolWindows } from "./fixtures";
|
||||
import { expect, ipcCalls, stubDesktop, test, visibleToolWindows } from "./fixtures";
|
||||
|
||||
test("⌘1 focuses, hides and re-shows the Project tool window", async ({ workbench: page }) => {
|
||||
const project = page.getByRole("region", { name: "Project" });
|
||||
@@ -31,6 +31,34 @@ test("⌥F12 opens the Terminal tool window and toggles it closed", async ({ wor
|
||||
await expect(terminal).toBeHidden();
|
||||
});
|
||||
|
||||
test("the first ⌥F12 focuses the terminal input once the lazily loaded view mounts", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Alt+F12");
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
|
||||
await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused();
|
||||
// Reopened with the view already mounted, it focuses the input as well.
|
||||
await page.keyboard.press("Alt+F12");
|
||||
await expect(terminal).toBeHidden();
|
||||
await page.keyboard.press("Alt+F12");
|
||||
await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused();
|
||||
});
|
||||
|
||||
test("a terminal view that loads late doesn't take focus moved elsewhere meanwhile", async ({ workbench: page }) => {
|
||||
let release!: () => void;
|
||||
const loaded = new Promise<void>((resolve) => (release = resolve));
|
||||
await page.route(/\/src\/features\/terminal\/runtime\.ts/, async (route) => {
|
||||
await loaded;
|
||||
await route.continue();
|
||||
});
|
||||
await page.keyboard.press("Alt+F12");
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
|
||||
await expect(terminal).toBeVisible();
|
||||
const tree = page.getByRole("tree", { name: "Project" });
|
||||
await tree.getByRole("treeitem", { name: "README.md" }).click();
|
||||
release();
|
||||
await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeAttached();
|
||||
await expect(tree).toBeFocused();
|
||||
});
|
||||
|
||||
test("Split Right gives the Terminal a tab group with its own header; Unsplit merges it back", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
@@ -80,3 +108,47 @@ test("⌘⇧F12 hides all tool windows and restores them", async ({ workbench: p
|
||||
await expect(page.getByRole("region", { name: "Project" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Problems" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the Terminal sends typed commands to the shell and shows its output", async ({ workbench: page }) => {
|
||||
// The shell is stubbed at the IPC layer: it echoes input and answers `echo`.
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(() => {
|
||||
let output: unknown = null;
|
||||
let line = "";
|
||||
const out = (text: string) => window.__ipc.send(output, text);
|
||||
const h = window.__ipc.handlers;
|
||||
h.term_default_shell = () => "/bin/zsh";
|
||||
h.term_available_shells = () => ["/bin/zsh"];
|
||||
h.term_spawn = (args) => {
|
||||
output = args.onOutput;
|
||||
setTimeout(() => out("acme-api % "), 20);
|
||||
return { id: 5, title: "zsh", pid: 99 };
|
||||
};
|
||||
h.term_resize = () => null;
|
||||
h.term_is_busy = () => false;
|
||||
h.term_write = (args) => {
|
||||
for (const ch of String(args.data)) {
|
||||
if (ch === "\r") {
|
||||
const said = /^echo (.*)$/.exec(line);
|
||||
out(`\r\n${said ? `${said[1]}\r\n` : ""}acme-api % `);
|
||||
line = "";
|
||||
} else {
|
||||
line += ch;
|
||||
out(ch);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await page.keyboard.press("Alt+F12");
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
|
||||
const rows = terminal.locator(".xterm-rows");
|
||||
await expect(rows).toContainText("acme-api %");
|
||||
await terminal.locator(".xterm-screen").click();
|
||||
await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused();
|
||||
await page.keyboard.type("echo hello from e2e");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(rows.locator("div", { hasText: /^hello from e2e\s*$/ })).toHaveCount(1);
|
||||
const written = (await ipcCalls(page, "term_write")).map((a) => a.data).join("");
|
||||
expect(written).toBe("echo hello from e2e\r");
|
||||
});
|
||||
@@ -12,11 +12,16 @@ something to show.
|
||||
| `python-app/` | Python 3.10+, pytest | `python -m tasks.cli` (from `src/`) | `pytest` | `OPENIDEA_FAIL=1 pytest` | unused imports in `models.py` |
|
||||
| `go-app/` | Go 1.22+ | `go run ./cmd/server` | `go test ./...` | `OPENIDEA_FAIL=1 go test ./...` | unused `fmt.Sprintf` result in `internal/store/store.go` |
|
||||
| `kotlin-gradle/` | JDK 21, Gradle 8 (no wrapper) | `gradle run` | `gradle test` | `gradle test -PopenideaFail` | unused variable in `App.kt` |
|
||||
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
|
||||
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
|
||||
|
||||
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
`fixtures/eslint.config.js` keeps them out of the IDE's own lint run.
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers: when you change a fixture, run it for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
Work on a **copy** so git and edits never touch this repository:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>openidea-fixture-orders</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<description>OpenIDEA QA fixture: a tiny order book</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- `mvn test -DopenideaFail=true` runs the intentionally failing test. -->
|
||||
<openideaFail>false</openideaFail>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<openidea.fail>${openideaFail}</openidea.fail>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<configuration>
|
||||
<mainClass>com.example.orders.App</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.orders;
|
||||
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
OrderBook book = new OrderBook();
|
||||
book.place("alice", "Mechanical keyboard", 1, 8999);
|
||||
book.place("bob", "Wireless mouse", 2, 2999);
|
||||
book.place("alice", "USB-C hub", 1, 3999);
|
||||
|
||||
int unused = 42; // intentional warning: unused variable
|
||||
// Set a breakpoint here (QA: debugger) and inspect `book`.
|
||||
long revenue = book.revenueCents();
|
||||
System.out.printf("%d orders, revenue %.2f%n", book.size(), revenue / 100.0);
|
||||
book.largest().ifPresent(o -> System.out.println("Largest: " + o.product()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.orders;
|
||||
|
||||
/** A customer order: a product, how many and the unit price in cents. */
|
||||
public record Order(String customer, String product, int quantity, long unitPriceCents) {
|
||||
|
||||
public Order {
|
||||
if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
|
||||
}
|
||||
|
||||
/** The order's total in cents. */
|
||||
public long totalCents() {
|
||||
return unitPriceCents * quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.orders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Keeps the orders placed so far, in the order they were placed. */
|
||||
public class OrderBook {
|
||||
private final List<Order> orders = new ArrayList<>();
|
||||
|
||||
/** Places an order and returns it. */
|
||||
public Order place(String customer, String product, int quantity, long unitPriceCents) {
|
||||
Order order = new Order(customer, product, quantity, unitPriceCents);
|
||||
orders.add(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
public List<Order> byCustomer(String customer) {
|
||||
return orders.stream().filter(o -> o.customer().equals(customer)).toList();
|
||||
}
|
||||
|
||||
public Optional<Order> largest() {
|
||||
return orders.stream().max(Comparator.comparingLong(Order::totalCents));
|
||||
}
|
||||
|
||||
// TODO: discounts for repeat customers
|
||||
public long revenueCents() {
|
||||
return orders.stream().mapToLong(Order::totalCents).sum();
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return orders.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.orders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
class OrderBookTest {
|
||||
@Test
|
||||
void sumsRevenueOverAllOrders() {
|
||||
OrderBook book = new OrderBook();
|
||||
book.place("alice", "Keyboard", 2, 1000);
|
||||
book.place("bob", "Mouse", 1, 500);
|
||||
assertEquals(2500, book.revenueCents());
|
||||
assertEquals(1, book.byCustomer("bob").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyOrders() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new Order("alice", "Keyboard", 0, 1000));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledIfSystemProperty(named = "openidea.fail", matches = "true")
|
||||
void intentionallyFails() {
|
||||
assertEquals(1, new OrderBook().size(), "QA: an intentionally failing test");
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,10 @@ runtime or resolve profile/precedence rules.
|
||||
Use the JDT LS distribution / Java version documented in [README](README.md).
|
||||
|
||||
```sh
|
||||
python3 plugins/java-usages/build.py /path/to/jdtls
|
||||
python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_CONFIGURATION_STRESS=1 python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 plugins/java-usages/tests/configuration.py /path/to/jdtls
|
||||
python3 java/java-usages/build.py /path/to/jdtls
|
||||
python3 java/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_CONFIGURATION_STRESS=1 python3 java/java-usages/tests/configuration.py /path/to/jdtls
|
||||
OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 java/java-usages/tests/configuration.py /path/to/jdtls
|
||||
cargo test -p ide-language -p ide-lsp
|
||||
```
|
||||
|
||||
File renamed without changes.
@@ -78,7 +78,7 @@ Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SH
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
python3 plugins/java-usages/build.py /path/to/jdtls
|
||||
python3 java/java-usages/build.py /path/to/jdtls
|
||||
```
|
||||
|
||||
## Manual upgrades
|
||||
@@ -100,10 +100,10 @@ not rebuild this jar. A maintainer must:
|
||||
## Real server verification
|
||||
|
||||
```sh
|
||||
python3 plugins/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 plugins/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 271 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user