Compare commits

..
Author SHA1 Message Date
l0ng-ai 418422547d fix(debugger): coloured Spring Boot output, Soft-Wrap and Scroll to End in the debug console
Debugging a Spring Boot configuration showed its log uncoloured: Run adds
-Dspring.output.ansi.enabled=always (Spring only colours output for a
terminal, the process runs on pipes), the java-debug and Kotlin debug
launches built their VM options without it. They now add the same console
VM options as Run, unless the configuration sets the property itself.

The debug console also gets the Run console's Soft-Wrap toggle (default
from Settings › Console › Use soft wraps) and Scroll to End, which
follows new output, turns off when scrolling up and on again at the end.

Fixes #1262
2026-11-05 02:58:37 +08:00
173 changed files with 647 additions and 6221 deletions

No files matched your search

+15 -17
View File
@@ -15,8 +15,6 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
rust:
@@ -27,10 +25,10 @@ jobs:
os: [macos-latest, ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
@@ -64,7 +62,7 @@ jobs:
name: Remote server (linux musl)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-musl }
- uses: Swatinem/rust-cache@v2
@@ -81,7 +79,7 @@ jobs:
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
@@ -102,9 +100,9 @@ jobs:
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
@@ -128,7 +126,7 @@ jobs:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
@@ -137,10 +135,10 @@ jobs:
name: Frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
@@ -156,16 +154,16 @@ jobs:
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm e2e
env:
HOME: /root
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
+4 -31
View File
@@ -2,12 +2,6 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
on:
push:
branches: [main]
@@ -20,19 +14,12 @@ on:
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -66,10 +53,9 @@ jobs:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
uses: actions/setup-node@v4
with: { node-version: 22 }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
@@ -109,16 +95,3 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
+11 -11
View File
@@ -28,7 +28,7 @@ jobs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- name: Tag matches app version
run: |
@@ -52,7 +52,7 @@ jobs:
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const tag = process.env.TAG;
@@ -99,11 +99,11 @@ jobs:
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -173,7 +173,7 @@ jobs:
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -220,7 +220,7 @@ jobs:
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
- uses: actions/checkout@v4
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
@@ -229,10 +229,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
@@ -277,7 +277,7 @@ jobs:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
@@ -337,7 +337,7 @@ jobs:
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
+2 -7
View File
@@ -69,11 +69,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
@@ -121,8 +116,8 @@ Several worktrees can be developed and run side by side.
default MCP session belongs to this worktree.
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
`target` symlinks at a shared directory.
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
Generated
+32 -277
View File
@@ -123,17 +123,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
@@ -167,27 +156,6 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "ast-grep-core"
version = "0.40.1"
@@ -1632,7 +1600,6 @@ dependencies = [
"lock_api",
"once_cell",
"parking_lot_core",
"serde",
]
[[package]]
@@ -1828,15 +1795,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -2205,6 +2163,15 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -4153,7 +4120,7 @@ version = "0.1.36"
dependencies = [
"ide-core",
"ide-run",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"serde_json",
"specta",
@@ -4305,7 +4272,7 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"quick-xml 0.42.0",
"quick-xml",
"regex-syntax",
"reqwest",
"rustix",
@@ -4547,7 +4514,7 @@ dependencies = [
"libc",
"parking_lot",
"portable-pty",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"serde_json",
"specta",
@@ -4618,8 +4585,6 @@ dependencies = [
"dirs 6.0.0",
"flate2",
"ide-core",
"keyring",
"keyring-core",
"parking_lot",
"russh",
"russh-config",
@@ -4630,7 +4595,6 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -4670,7 +4634,6 @@ dependencies = [
"proptest",
"ropey",
"serde",
"similar",
"specta",
]
@@ -4727,7 +4690,7 @@ dependencies = [
"globset",
"ide-core",
"ignore",
"notify",
"notify-debouncer-full",
"parking_lot",
"serde",
"serde_json",
@@ -5252,33 +5215,6 @@ dependencies = [
"serde",
]
[[package]]
name = "keyring"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
dependencies = [
"chrono",
"dashmap",
"log",
"regex",
"ron",
"serde",
"uuid",
]
[[package]]
name = "kqueue"
version = "1.2.1"
@@ -5701,6 +5637,19 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
@@ -5729,30 +5678,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint 0.4.8",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
@@ -5765,15 +5690,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -5789,27 +5705,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint 0.4.8",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -6698,7 +6593,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
dependencies = [
"base64 0.23.1",
"indexmap 2.14.2",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"time",
]
@@ -6743,12 +6638,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "poly1305"
version = "0.9.1"
@@ -7009,15 +6898,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -7295,18 +7175,18 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7327,19 +7207,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
dependencies = [
"bitflags 2.13.2",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "ropey"
version = "1.6.1"
@@ -7406,7 +7273,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint 0.5.1",
"num-bigint",
"p256",
"p384",
"p521",
@@ -7737,12 +7604,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -7793,25 +7654,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secret-service"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hybrid-array",
"num",
"once_cell",
"serde",
"sha2 0.11.1",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -9300,7 +9142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -9488,7 +9330,6 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -10825,66 +10666,6 @@ dependencies = [
"wasmtime",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.106"
@@ -11170,19 +10951,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -11709,7 +11477,6 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
@@ -11720,17 +11487,6 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
@@ -11936,7 +11692,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
+1 -9
View File
@@ -76,6 +76,7 @@ ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
trash = "5"
blake3 = "1"
dunce = "1"
@@ -105,9 +106,6 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
keyring = "4.2"
keyring-core = "1"
# testing
insta = { version = "1", features = ["yaml"] }
@@ -117,14 +115,8 @@ tempfile = "3"
[profile.dev]
# Keep debug builds snappy while giving dependencies decent speed.
opt-level = 0
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
# Dependency frames keep function names only. Re-enable one with
# `--config 'profile.dev.package.<crate>.debug=true'`.
debug = false
[profile.release]
codegen-units = 1
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -128,20 +111,4 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
-6
View File
@@ -716,12 +716,6 @@ pub struct PhpSettings {
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+1 -7
View File
@@ -72,13 +72,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
&["-m", "debugpy.adapter"],
"stdio",
"pip install debugpy",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
"js-debug" => cfg(
"js-debug-adapter",
// Without a host it listens on `localhost`, which may resolve to ::1 only.
+6 -18
View File
@@ -589,12 +589,11 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
@@ -652,19 +651,8 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
+6 -10
View File
@@ -111,8 +111,8 @@ impl Session {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
let native = windows_source_path(&path.to_string_lossy());
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
@@ -133,11 +133,7 @@ impl Session {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
windows_source_path(path)
} else {
PathBuf::from(path)
};
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
@@ -269,8 +265,8 @@ impl Session {
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
@@ -278,7 +274,7 @@ fn is_windows_source_path(path: &str) -> bool {
|| path.starts_with(r"\\")
}
fn windows_source_path(path: &str) -> PathBuf {
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
-48
View File
@@ -698,37 +698,6 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
@@ -1141,23 +1110,6 @@ async fn kotlin_official_release_ten_no_delay_runs() {
}
}
#[cfg(windows)]
#[tokio::test]
async fn netcoredbg_windows_source_paths_round_trip() {
let client = "D:/work/LoginController.cs";
let native = r"D:\work\LoginController.cs";
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "netcoredbg".into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
-1
View File
@@ -29,4 +29,3 @@ uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+15 -153
View File
@@ -213,8 +213,6 @@ pub struct DbManager {
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
global: Option<(PathBuf, SecretStore)>,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
@@ -341,7 +339,6 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -354,42 +351,8 @@ impl DbManager {
}
}
/// Also lists the global data sources stored in `dir` (the app data directory).
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
let passwords = self.saved_passwords.scoped(&dir);
self.global = Some((dir, passwords));
self
}
/// The project's data sources, then the global ones (a project one wins on the same id).
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
let mut all = source::load(&self.root)?;
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
all.extend(global);
Ok(all)
}
fn global_sources(&self) -> Vec<DataSource> {
let Some((dir, _)) = &self.global else { return Vec::new() };
source::load_global(dir).unwrap_or_else(|e| {
tracing::warn!("global data sources: {}", e.message);
Vec::new()
})
}
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
fn store_of(&self, source: &DataSource) -> &SecretStore {
match &self.global {
Some((_, store)) if source.global => store,
_ => &self.saved_passwords,
}
source::load(&self.root)
}
/// The connection settings of target `target` ([`DataSource::resolved`]), with
@@ -409,43 +372,18 @@ impl DbManager {
}
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
source::save(&self.root, &sources)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
gone_or_changed => {
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
match gone_or_changed {
None => {
for k in &keys {
self.session_passwords.lock().remove(k);
self.store_of(&o).delete(k);
}
if gone_or_changed.is_none() {
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
self.session_passwords.lock().remove(&k);
self.saved_passwords.delete(&k);
}
// Made global or project-level: its saved passwords move along.
Some(n) if n.global != o.global => {
for k in &keys {
if let Some(p) = self.store_of(&o).get(k) {
self.store_of(n).set(k, &p)?;
self.store_of(&o).delete(k);
}
}
}
Some(_) => {}
}
self.disconnect_source(&o.id).await;
}
@@ -457,7 +395,7 @@ impl DbManager {
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
self.remember(id, id.to_owned(), password, save).await
self.remember(id.to_owned(), password, save).await
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
@@ -469,10 +407,10 @@ impl DbManager {
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(id, secrets::ssh_key(id), secret, save).await
self.remember(secrets::ssh_key(id), secret, save).await
}
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
match &secret {
Some(p) => {
self.session_passwords.lock().insert(key.clone(), p.clone());
@@ -481,11 +419,10 @@ impl DbManager {
self.session_passwords.lock().remove(&key);
}
}
let store = self.saved_for(id);
let result = match (secret, save) {
(Some(p), true) => store.set(&key, &p),
(Some(p), true) => self.saved_passwords.set(&key, &p),
_ => {
store.delete(&key);
self.saved_passwords.delete(&key);
Ok(())
}
};
@@ -496,18 +433,18 @@ impl DbManager {
Ok(())
}
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
async fn secret(&self, key: String, saved: bool) -> Option<String> {
if let Some(p) = self.session_passwords.lock().get(&key) {
return Some(p.clone());
}
if !saved {
return None;
}
self.store_of(source).get(&key)
self.saved_passwords.get(&key)
}
async fn password(&self, source: &DataSource) -> Option<String> {
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
@@ -563,7 +500,7 @@ impl DbManager {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
};
let target = SshTarget {
host: ssh.host.clone(),
@@ -1161,81 +1098,6 @@ impl DbManager {
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::files_containing;
use std::path::Path;
fn manager(dir: &Path) -> DbManager {
ide_ssh::secrets::testing::use_memory_store();
let data = dir.join("data");
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
}
#[tokio::test]
async fn saved_passwords_stay_off_disk() {
let tmp = tempfile::tempdir().unwrap();
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
let reopened = manager(tmp.path());
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
assert_eq!(
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
Some("hunter2-tunnel")
);
}
#[tokio::test]
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project").to_string_lossy().into_owned();
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
let m = manager(tmp.path());
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
}
#[tokio::test]
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
let manager = |root: &std::path::Path| {
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
.with_global_sources(app.clone())
};
let (a, b) = (manager(&one), manager(&two));
let source = |id: &str, global: bool| DataSource {
id: id.into(),
name: id.into(),
driver: DbDriver::Postgres,
save_password: true,
global,
..Default::default()
};
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
a.set_password("shared", Some("pw".into()), true).await.unwrap();
// Made global: listed in the other project too, with its saved password.
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
assert_eq!(ids(&b), [("shared".into(), true)]);
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
// Made project-level again: gone from the other project, the password stays with it.
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
assert!(b.sources().unwrap().is_empty());
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
}
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
+55 -31
View File
@@ -1,15 +1,20 @@
//! Saved data source passwords, in the OS credential store like the other saved
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
//! keyed by project root, then credential key.
//! Saved data source passwords, in the app's data directory rather than the OS
//! credential store (no system prompts). One JSON file for all projects, keyed
//! by project root, then credential key; owner-only on Unix.
use ide_core::IdeResult;
use ide_ssh::secrets::SecretFile;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Project root → credential key → secret.
type Contents = BTreeMap<String, BTreeMap<String, String>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct SecretStore {
path: PathBuf,
file: SecretFile,
project: String,
}
@@ -18,41 +23,61 @@ pub fn ssh_key(id: &str) -> String {
format!("{id}#ssh")
}
fn file_key(project: &str, key: &str) -> String {
format!("{project}|{key}")
}
/// Older versions' file: project root → credential key → secret.
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
Some(
all.into_iter()
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
.collect(),
)
}
impl SecretStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
let file = SecretFile::with_legacy(path.clone(), legacy);
Self { path, file, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
Self { path, project: root.to_string_lossy().into_owned() }
}
pub fn get(&self, key: &str) -> Option<String> {
self.file.get(&file_key(&self.project, key))
let _g = LOCK.lock();
self.read().remove(&self.project)?.remove(key)
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
self.file.set(&file_key(&self.project, key), secret)
let _g = LOCK.lock();
let mut all = self.read();
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
self.write(&all)
}
pub fn delete(&self, key: &str) {
self.file.delete(&file_key(&self.project, key))
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return };
if project.remove(key).is_none() {
return;
}
if project.is_empty() {
all.remove(&self.project);
}
if let Err(e) = self.write(&all) {
tracing::warn!("saved passwords: {}", e.message);
}
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
}
let tmp = self.path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
@@ -62,7 +87,6 @@ mod tests {
#[test]
fn keeps_projects_apart_and_drops_empty_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("db-passwords.json");
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
+10 -42
View File
@@ -1,7 +1,6 @@
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
//! the others only in memory for the session.
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
//! only in memory for the session.
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
use serde::{Deserialize, Serialize};
@@ -182,9 +181,6 @@ pub struct DataSource {
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
}
impl Default for DataSource {
@@ -213,7 +209,6 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -402,32 +397,9 @@ struct SourcesFile {
sources: Vec<DataSource>,
}
/// The project's data sources.
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
read(&project_dir::path(root, FILE))
}
/// Saves the project's data sources.
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&project_dir::path(root, FILE), sources)
}
/// The global data sources stored in `dir` (the app data directory).
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
let mut sources = read(&dir.join(FILE))?;
for s in &mut sources {
s.global = true;
}
Ok(sources)
}
/// Saves the global data sources in `dir`.
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&dir.join(FILE), sources)
}
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
let text = match std::fs::read_to_string(path) {
let path = project_dir::path(root, FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
@@ -436,23 +408,19 @@ fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
Ok(file.sources)
}
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
for s in sources {
if s.id.is_empty() || s.name.trim().is_empty() {
return Err(IdeError::invalid("every data source needs an id and a name"));
}
}
let path = project_dir::path(root, FILE);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
table.remove("global");
}
}
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
.map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(path, text)?;
Ok(())
}
+1 -23
View File
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(hashed_path(rel).as_bytes());
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
@@ -372,12 +372,6 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
fn hashed_path(rel: &Path) -> String {
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -486,20 +480,4 @@ mod tests {
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
#[test]
fn hashes_paths_with_forward_slashes_on_every_os() {
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
#[cfg(windows)]
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("web")).unwrap();
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
// A walked path (native separators) hashes like the manifest's `/` spelling.
assert_eq!(
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
);
}
}
+49 -110
View File
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
use ide_run::ProcessManager;
use ide_terminal::TerminalManager;
use ide_workspace::SettingsStore;
use parking_lot::{Mutex, RwLock};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
@@ -74,8 +74,6 @@ pub struct Host {
pub environment: environment::Environment,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
@@ -84,13 +82,8 @@ pub struct Host {
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
}
/// How long exit waits for project shutdown before giving up.
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
@@ -110,7 +103,6 @@ impl Host {
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
@@ -134,7 +126,6 @@ impl Host {
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
};
host.apply_proxy();
host
@@ -188,69 +179,46 @@ impl Host {
}
}
/// Closes `window`'s project and waits until its language servers, kernels and
/// debug sessions have shut down. Never call this on the UI event-loop thread:
/// the shutdown runs on the async runtime, whose workers may themselves be
/// waiting for the event loop (IPC responses, window queries). Use
/// [`Self::close_project_detached`] there.
pub fn close_project(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
self.wait_blocking(shutdown);
}
}
/// Like [`Self::close_project`], but returns once the project is detached from
/// the window; its workers finish shutting down in the background, and
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
pub fn close_project_detached(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
let mut closing = self.closing.lock();
closing.retain(|handle| !handle.is_finished());
closing.push(shutdown);
}
}
/// Detaches the project from `window` and stops what it started, synchronously,
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if !project.light {
self.environment.project_closed();
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and its language
// servers shut down.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
Some(self.spawn_project_shutdown(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
))
}
/// Project/window destruction must finish owned kernel cleanup, not enqueue
/// work which can be lost as the desktop runtime exits: the returned thread is
/// joined by the closer or, for a detached close, before exit.
fn spawn_project_shutdown(
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) -> std::thread::JoinHandle<()> {
) {
for bridge in &bridges {
bridge.stop();
}
@@ -258,37 +226,25 @@ impl Host {
service.stop_admission();
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
})
}
/// Waits for detached closes, so a project opened next never races the
/// language servers of one still shutting down (e.g. over a shared workspace).
pub(crate) fn wait_detached_closes(&self) {
let pending: Vec<_> = self.closing.lock().drain(..).collect();
for shutdown in pending {
self.wait_blocking(shutdown);
}
}
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
let wait = move || {
if let Err(error) = shutdown.join() {
tracing::error!("project shutdown thread failed: {error:?}");
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
@@ -300,32 +256,15 @@ impl Host {
}
}
/// Called before the desktop runtime exits, including update/restart exits, on
/// the event-loop thread: waits for detached closes and the open projects'
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
/// stuck on the event loop cannot keep the process from exiting.
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
pending.push(self.spawn_project_shutdown(
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
if std::time::Instant::now() >= deadline {
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for handle in pending {
if let Err(error) = handle.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
}
);
}
/// Called before desktop exit, which may bypass document destructors.
+1 -6
View File
@@ -175,12 +175,7 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
-18
View File
@@ -142,21 +142,6 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -323,9 +308,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
-40
View File
@@ -34,42 +34,6 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -180,10 +144,6 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
-11
View File
@@ -667,17 +667,6 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
-10
View File
@@ -165,16 +165,6 @@ impl PluginHost {
self.save_state();
}
/// The marketplace's plugins: read again when `refresh`, else the last index
/// read. Reads it when none has been read yet (first run, nothing cached).
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
self.refresh_market()
} else {
Ok(self.market_plugins())
}
}
/// Reads the index again, keeps it, and applies its revocations. Announces
/// updates of installed plugins it hasn't announced yet.
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
@@ -114,7 +114,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
-2
View File
@@ -192,8 +192,6 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
-10
View File
@@ -378,19 +378,9 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+6 -12
View File
@@ -88,7 +88,6 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
@@ -210,7 +209,6 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
@@ -521,15 +519,12 @@ impl Host {
root: &Path,
project_key: &str,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(
ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
)
.with_global_sources(self.data_dir.clone()),
);
let db = Arc::new(ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
));
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
@@ -571,7 +566,6 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
let effective = settings.effective();
+4 -66
View File
@@ -1,7 +1,6 @@
//! System Settings › HTTP Proxy on the host: the global settings made current for
//! connections and child processes, the password kept in the saved passwords
//! (the OS credential store) instead of `settings.toml`, or for the session
//! when there is no credential store.
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
use crate::Host;
use ide_core::IdeResult;
@@ -13,9 +12,7 @@ impl Host {
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
// A password typed into settings.toml by hand counts too, unless one was saved.
let session = || self.proxy_session_password.lock().clone();
// The session copy is the latest write that could not be saved.
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
settings.proxy.password = password;
}
settings
@@ -43,70 +40,11 @@ impl Host {
_ => return Ok(Some(value)),
};
if let Some(password) = password {
let password = password.as_str().filter(|p| !p.is_empty());
let mut session = self.proxy_session_password.lock();
*session = None;
match password {
Some(p) => {
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
*session = Some(p.to_owned());
}
}
match password.as_str().filter(|p| !p.is_empty()) {
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
None => self.proxy_password.delete(PASSWORD_KEY),
}
}
Ok((key != "proxy.password").then_some(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
use std::sync::Arc;
/// A machine without a credential store (a headless Linux host).
struct NoStore;
impl Vault for NoStore {
fn available(&self) -> bool {
false
}
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
Err(StoreError::NoDefaultStore)
}
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
}
#[tokio::test]
async fn without_a_credential_store_the_password_lasts_the_session() {
let tmp = tempfile::tempdir().unwrap();
let mut host = Host::new(crate::HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let path = tmp.path().join("data/proxy-password.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
assert_eq!(left, None);
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
}
}
-51
View File
@@ -5,7 +5,6 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
@@ -53,53 +52,3 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
host.terminals.kill_key("other");
});
}
/// The desktop closes a project on the event-loop thread while runtime workers may be
/// blocked waiting for that thread (IPC responses, window queries): closing must not
/// wait for workers that need the runtime, and exit still waits for them once free.
#[test]
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// Occupy the only runtime worker until "the event loop" answers.
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
runtime.spawn(async move {
started_tx.send(()).unwrap();
let _ = release_rx.recv();
});
started_rx.recv().unwrap();
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
let closer = host.clone();
std::thread::spawn(move || {
closer.close_project_detached("main");
let _ = closed_tx.send(());
});
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
release_tx.send(()).unwrap();
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
assert!(host.project("main").is_err(), "the window no longer has the project");
host.open_project("main", &root).expect("the window opens a project once the close has finished");
let started = Instant::now();
host.shutdown_notebooks_on_exit();
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
}
-22
View File
@@ -196,28 +196,6 @@ fn write_index(
path.to_string_lossy().into_owned()
}
/// First run: nothing cached yet, so listing the marketplace reads its index
/// instead of showing an empty one; later listings use the index read.
#[test]
fn the_first_marketplace_listing_reads_the_index() {
let f = fixture();
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let url = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(url.clone()));
let listed = f.host.market_listing(false).unwrap();
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
std::fs::remove_file(&url).unwrap();
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
}
#[test]
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
let f = fixture();
+2 -282
View File
@@ -1,7 +1,6 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! 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
@@ -69,35 +68,6 @@ struct Checks {
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
@@ -462,146 +432,6 @@ impl Smoke {
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
@@ -610,17 +440,7 @@ impl Smoke {
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
@@ -660,22 +480,6 @@ const TYPESCRIPT: Checks = Checks {
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -717,19 +521,6 @@ const PYTHON: Checks = Checks {
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -767,16 +558,6 @@ const RUST: Checks = Checks {
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -816,16 +597,6 @@ const GO: Checks = Checks {
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -882,37 +653,6 @@ const JAVA: Checks = Checks {
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -965,26 +705,6 @@ const KOTLIN: Checks = Checks {
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-77
View File
@@ -2045,83 +2045,6 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
-285
View File
@@ -1,285 +0,0 @@
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
//! the source and test roots detected from its build file, the index (files and symbols),
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
//! come and go, and the Maven / Gradle model.
//!
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
//! java, kotlin and python jobs):
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
use ide_host::{Host, HostConfig, ops};
use ide_index::SymbolFilter;
use ide_workspace::roots::RootKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Fixture {
dir: &'static str,
/// Detected roots: (project-relative path, kind).
roots: &'static [(&'static str, RootKind)],
/// Search Everywhere › Files: (query, file found).
file: (&'static str, &'static str),
/// Go to Class: (name, file declaring it).
class: (&'static str, &'static str),
/// Go to Symbol: (name, file declaring it).
symbol: (&'static str, &'static str),
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
added: (&'static str, &'static str),
}
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
async fn open(fixture: &str) -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
Opened { host, root, _tmp: tmp }
}
/// Polls `check` until it holds; fails with its last answer.
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
let deadline = Instant::now() + timeout;
loop {
match check() {
Ok(()) => return,
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
impl Opened {
fn files(&self, query: &str) -> Vec<String> {
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
found.into_iter().map(|f| f.rel_path).collect()
}
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
}
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
let found = self.symbols(name, filter);
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
}
}
async fn smoke(f: Fixture) {
let p = open(f.dir).await;
until("indexing", Duration::from_secs(30), || {
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
})
.await;
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
for (path, kind) in f.roots {
assert!(
roots.iter().any(|r| r.path == *path && r.kind == *kind),
"{}: no {kind:?} root {path:?} in {roots:?}",
f.dir
);
}
let files = p.files(f.file.0);
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
}
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
}
// The index follows the file system while the project is open.
let (added, text) = f.added;
std::fs::write(p.root.join(added), text).unwrap();
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
.await;
std::fs::remove_file(p.root.join(added)).unwrap();
until("a deleted file's class", Duration::from_secs(20), || {
let found = p.symbols("FreshWidget", SymbolFilter::Types);
if found.is_empty() { Ok(()) } else { Err(found) }
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rust_app() {
smoke(Fixture {
dir: "rust-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("pars", "src/parser.rs"),
class: ("Inventory", "src/lib.rs"),
symbol: ("low_stock", "src/lib.rs"),
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ts_app() {
smoke(Fixture {
dir: "ts-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("cart", "src/cart.ts"),
class: ("Cart", "src/cart.ts"),
symbol: ("formatMoney", "src/money.ts"),
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn python_app() {
smoke(Fixture {
dir: "python-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("models", "src/tasks/models.py"),
class: ("TaskList", "src/tasks/models.py"),
symbol: ("build_demo", "src/tasks/cli.py"),
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn go_app() {
smoke(Fixture {
dir: "go-app",
roots: &[("", RootKind::Source)],
file: ("store", "internal/store/store.go"),
class: ("Store", "internal/store/store.go"),
symbol: ("writeJSON", "cmd/server/main.go"),
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn java_app() {
smoke(Fixture {
dir: "java-app",
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
added: (
"src/main/java/com/example/orders/FreshWidget.java",
"package com.example.orders;\n\npublic class FreshWidget {}\n",
),
})
.await;
let p = open("java-app").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let maven = model.maven.expect("a Maven model");
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn kotlin_gradle() {
smoke(Fixture {
dir: "kotlin-gradle",
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
})
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let gradle = model.gradle.expect("a Gradle model");
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
}
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
let p = open(fixture).await;
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
let model =
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
model
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Maven and a JDK"]
async fn maven_sync() {
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
assert_eq!(maven.sync_error, None);
assert!(maven.synced_at.is_some());
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
// The resolved tree, not only the declaration.
assert!(!junit.children.is_empty(), "{junit:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
assert_eq!(gradle.sync_error, None);
assert!(gradle.synced_at.is_some());
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
// The build's own tasks: the application and Kotlin plugins' too.
for task in ["run", "compileKotlin", "test"] {
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs python3"]
async fn python_venv() {
let p = open("python-app").await;
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
assert!(made.unwrap().success());
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs JDK 21"]
async fn jdk() {
let p = open("java-app").await;
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
}
+1 -2
View File
@@ -43,7 +43,6 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
assert_eq!(proxy().mode, ProxyMode::Auto);
@@ -62,7 +61,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
assert!(saved.contains("s3cret"));
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
// A whole-table write keeps the password out of the file too; an empty one forgets it.
-16
View File
@@ -141,19 +141,3 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
-209
View File
@@ -1,209 +0,0 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
-51
View File
@@ -114,54 +114,3 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
-1
View File
@@ -105,7 +105,6 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
-15
View File
@@ -640,21 +640,6 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Binary file not shown.
@@ -1 +1 @@
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
Binary file not shown.
@@ -1 +1 @@
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
-1
View File
@@ -12,7 +12,6 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
-2
View File
@@ -64,8 +64,6 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
@@ -1 +1 @@
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
+10 -82
View File
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,41 +442,15 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
if o.enabled != Some(true) {
return None;
}
Some(match o.severity {
Some(InspectionLevel::Error) => "error",
None | Some(InspectionLevel::Warning) => "warning",
Some(_) => "info",
})
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
/// Each variant has its own file, so changing an inspection restarts the server with it.
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let (name, content) = match serial {
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
Some(level) => (
format!("compiler-defaults-serial-{level}.prefs"),
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
),
};
let file = data_dir.join("lsp/jdtls").join(name);
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
}
Ok(())
};
@@ -638,18 +612,9 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(level) = crate::php::language_level(root, s)
&& let Some(version) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
merge_init_options(cfg, json!({ "stubs": stubs }));
}
let include_paths = crate::php::include_paths(root, s);
if !include_paths.is_empty() {
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
}
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
}
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
@@ -1121,50 +1086,13 @@ mod java_compiler_tests {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn serial_version_inspection_sets_the_compiler_option() {
let tmp = tempfile::tempdir().unwrap();
let url = |inspections: &InspectionSettings| {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
configure_java_compiler(&mut cfg, tmp.path(), inspections);
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
};
let set = |o: ide_core::settings::InspectionOverride| {
let mut s = InspectionSettings::default();
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
s
};
let off = url(&InspectionSettings::default());
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
assert_ne!(on, off, "a new file, so the server restarts with it");
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
let error = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
let weak = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::WeakWarning),
..Default::default()
}));
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
let severity_only = url(&set(ide_core::settings::InspectionOverride {
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert_eq!(severity_only, off, "off by default");
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
configure_java_compiler(&mut cfg, &cache);
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1175,7 +1103,7 @@ mod java_compiler_tests {
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
configure_java_compiler(&mut cfg, &cache);
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1185,7 +1113,7 @@ mod java_compiler_tests {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
configure_java_compiler(&mut cfg, tmp.path());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
+1
View File
@@ -369,6 +369,7 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+11 -177
View File
@@ -1,53 +1,24 @@
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
//! and the Intelephense storage that goes with it.
use crate::apply::{merge, merge_init_options, setting_path};
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;
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
/// 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(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
return version(explicit).map(dotted);
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
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`).
@@ -77,74 +48,10 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
}
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
/// `stubs` replaces them, so additions are sent together with these.
#[rustfmt::skip]
const DEFAULT_STUBS: &[&str] = &[
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
];
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
/// extension name.
const MIXED_CASE_STUBS: &[&str] =
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
fn stub_key(name: &str) -> String {
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
/// that adds nothing to the defaults, so the server keeps its own.
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
let composer: Option<Value> =
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
let extensions = ["require", "require-dev"]
.into_iter()
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
.flat_map(|deps| deps.keys())
.filter_map(|dep| dep.strip_prefix("ext-"))
.map(|ext| {
let key = stub_key(ext);
MIXED_CASE_STUBS
.iter()
.find(|stub| stub_key(stub) == key)
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
});
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
for stub in extensions.chain(configured) {
if !stub.is_empty() && known.insert(stub_key(&stub)) {
stubs.push(stub);
}
}
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
}
/// The folders in Settings › PHP › Include paths, resolved against the project.
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
s.languages
.php
.include_paths
.iter()
.filter_map(|p| setting_path(root, p))
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Version = (u32, u32, u32);
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
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.
@@ -198,18 +105,13 @@ mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
(language_level(tmp.path(), &s), composer_level(tmp.path()))
language_level(tmp.path(), &s)
}
fn required(constraint: &str) -> Option<String> {
@@ -299,74 +201,6 @@ mod tests {
}
}
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
}
let mut s = Settings::default();
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
intelephense_stubs(tmp.path(), &s)
}
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
stubs[DEFAULT_STUBS.len()..].to_vec()
}
#[test]
fn required_extensions_and_configured_stubs_follow_the_defaults() {
let composer = json!({
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
});
assert_eq!(
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
["redis", "imagick", "FFI", "wordpress"]
);
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
}
#[test]
fn nothing_beyond_the_defaults_sends_nothing() {
assert_eq!(stubs(None, &[]), None);
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
let defaults =
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
}
#[test]
fn include_paths_resolve_against_the_project() {
let root = Path::new("/work/module");
let mut s = Settings::default();
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
assert_eq!(
include_paths(root, &s),
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
-24
View File
@@ -1325,30 +1325,6 @@ fn intelephense_gets_the_php_language_level() {
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
}
#[test]
fn intelephense_gets_the_required_stubs_and_include_paths() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
let mut s = Settings::default();
s.languages.php.stubs = vec!["wordpress".into()];
s.languages.php.include_paths = vec!["../platform".into()];
let mut cfg = server("intelephense");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(stubs.len(), 70);
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
assert_eq!(
options["environment"],
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
);
let mut other = server("phpactor");
configure_server(&mut other, tmp.path(), tmp.path(), &s);
assert_eq!(other.initialization_options, None);
}
#[test]
fn go_constraints_change_only_protocol_settings() {
use serde_json::json;
+2 -7
View File
@@ -14,20 +14,15 @@ tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
dirs.workspace = true
uuid.workspace = true
keyring.workspace = true
keyring-core.workspace = true
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
russh-config = "0.58.0"
russh-sftp = "2.4"
flate2 = "1"
[features]
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
# and an in-memory credential store in place of the OS one.
testing = ["keyring-core/sample"]
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
testing = []
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
keyring-core = { workspace = true, features = ["sample"] }
-1
View File
@@ -216,7 +216,6 @@ mod tests {
use super::*;
fn store() -> (tempfile::TempDir, GitCredentials) {
crate::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
+31 -499
View File
@@ -1,289 +1,61 @@
//! Saved passwords (SSH passwords / key passphrases by configuration id, git HTTPS
//! credentials, the HTTP proxy's) in the OS credential store: Windows Credential
//! Manager, the macOS Keychain, the Secret Service on Linux. The file at `path` only
//! indexes them (an id and the keys, never a secret); owner-only on Unix.
//!
//! Older versions kept the secrets themselves in that file: they are moved to the
//! credential store on first use. Without a credential store (a headless Linux
//! host) such a file is still read and forgetting a secret rewrites it without
//! that one, but no new secret is written to disk: saving fails and callers keep
//! the secret for the session only.
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
//! proxy's), in the app's data directory rather than the OS credential store (no
//! system prompts). One JSON object, key → secret; owner-only on Unix.
use ide_core::{IdeError, IdeResult};
pub use keyring_core::{Error as StoreError, Result as StoreResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
const SERVICE: &str = "ai.l0ng.openidea";
/// Serialises read-modify-write of the index files (several stores may share one).
static LOCK: Mutex<()> = Mutex::new(());
/// Reads the secrets of an older version's file, by key (`None`: not that format).
pub type Legacy = fn(serde_json::Value) -> Option<BTreeMap<String, String>>;
/// Where the secrets are kept: the default credential store, or a stand-in in tests.
pub trait Vault: Send + Sync {
fn available(&self) -> bool;
fn get(&self, service: &str, key: &str) -> StoreResult<String>;
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()>;
fn delete(&self, service: &str, key: &str) -> StoreResult<()>;
}
/// `keyring_core`'s default store: the OS's, unless a test installed another one.
struct DefaultStore;
impl DefaultStore {
fn entry(&self, service: &str, key: &str) -> StoreResult<keyring_core::Entry> {
if !self.available() {
return Err(StoreError::NoDefaultStore);
}
keyring_core::Entry::new(service, key)
}
}
impl Vault for DefaultStore {
fn available(&self) -> bool {
if keyring_core::get_default_store().is_some() {
return true;
}
static WARNED: std::sync::Once = std::sync::Once::new();
match keyring::Entry::store_status() {
Ok(()) => true,
Err(e) => {
WARNED.call_once(|| tracing::warn!("saved passwords: no credential store: {e}"));
false
}
}
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
self.entry(service, key)?.get_password()
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
self.entry(service, key)?.set_password(secret)
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
self.entry(service, key)?.delete_credential()
}
}
pub struct SecretFile {
path: PathBuf,
legacy: Legacy,
vault: Arc<dyn Vault>,
}
/// The file's contents. `id` keeps the credentials of different data directories
/// (and of tests) apart in the one OS store.
#[derive(Serialize, Deserialize)]
struct Index {
id: String,
keys: BTreeSet<String>,
}
enum Contents {
Index(Index),
/// An older version's file that could not be moved, and why.
Plaintext(BTreeMap<String, String>, String),
}
fn service(id: &str) -> String {
format!("{SERVICE}/{id}")
}
fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
lock: Mutex<()>,
}
impl SecretFile {
pub fn new(path: PathBuf) -> Self {
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
}
pub fn with_legacy(path: PathBuf, legacy: Legacy) -> Self {
Self::with_vault(path, legacy, Arc::new(DefaultStore))
}
pub fn with_vault(path: PathBuf, legacy: Legacy, vault: Arc<dyn Vault>) -> Self {
Self { path, legacy, vault }
Self { path, lock: Mutex::new(()) }
}
pub fn get(&self, key: &str) -> Option<String> {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
}
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
},
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
let _g = self.lock.lock();
self.read().remove(key)
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
let _g = self.lock.lock();
let mut all = self.read();
all.insert(key.to_owned(), secret.to_owned());
self.write(&all)
}
pub fn delete(&self, key: &str) {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
}
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
}
index.keys.remove(key);
self.write_logged(&index);
}
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
let _g = self.lock.lock();
let mut all = self.read();
if all.remove(key).is_some()
&& let Err(e) = self.write(&all)
{
tracing::warn!("{}", e.message);
}
}
/// Every stored key, sorted (the secrets themselves stay private).
pub fn keys(&self) -> Vec<String> {
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
let _g = self.lock.lock();
self.read().into_keys().collect()
}
fn read(&self) -> BTreeMap<String, String> {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
BTreeMap::new()
}),
Err(_) => BTreeMap::new(),
}
}
fn load(&self) -> Contents {
let value = match std::fs::read(&self.path) {
Err(_) => return Contents::Index(Index { id: new_id(), keys: BTreeSet::new() }),
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(v) => v,
Err(e) => return self.set_aside(&e.to_string()),
},
};
if let Ok(index) = serde_json::from_value::<Index>(value.clone()) {
return Contents::Index(index);
}
match (self.legacy)(value) {
Some(old) => self.migrate(old),
None => self.set_aside("unknown format"),
}
}
/// An unreadable file is renamed rather than overwritten (it may index
/// credentials, or hold secrets); the store starts over.
fn set_aside(&self, why: &str) -> Contents {
let aside = self.path.with_extension(format!("json.unreadable-{}", new_id()));
tracing::warn!("saved passwords: {}: {why}; moved to {}", self.path.display(), aside.display());
if let Err(e) = std::fs::rename(&self.path, &aside) {
// Not overwritten either: nothing is saved until it can be moved.
return Contents::Plaintext(BTreeMap::new(), format!("{}: {e}", self.path.display()));
}
Contents::Index(Index { id: new_id(), keys: BTreeSet::new() })
}
fn pending_path(&self) -> PathBuf {
self.path.with_extension("json.migrating")
}
/// The id of a move that has not finished (see [`SecretFile::migrate`]).
fn pending_id(&self) -> Option<String> {
let id = std::fs::read_to_string(self.pending_path()).ok()?;
Some(id.trim().to_owned()).filter(|id| !id.is_empty())
}
/// Moves an older version's secrets to the credential store, key by key; a secret
/// the store refuses for good (too long, say) is forgotten, and the user is asked
/// for it again. The old file is replaced only when everything else was moved.
/// Until then the id waits in `<file>.migrating`, so a retry after a failure or a
/// crash overwrites the same credentials instead of copying them again.
fn migrate(&self, old: BTreeMap<String, String>) -> Contents {
if !self.vault.available() {
return Contents::Plaintext(old, "no credential store".into());
}
let pending = self.pending_path();
let id = match self.pending_id() {
Some(id) => id,
None => {
let id = new_id();
if let Err(e) = std::fs::write(&pending, &id) {
return Contents::Plaintext(old, format!("{}: {e}", pending.display()));
}
id
}
};
let mut index = Index { id, keys: BTreeSet::new() };
for (key, secret) in &old {
match self.vault.set(&service(&index.id), key, secret) {
Ok(()) => {
index.keys.insert(key.clone());
}
Err(e @ (StoreError::TooLong(..) | StoreError::Invalid(..))) => {
tracing::warn!("saved passwords: {key} does not fit the credential store and is forgotten: {e}");
}
Err(e) => {
tracing::warn!("saved passwords: could not move {key} to the credential store: {e}");
return Contents::Plaintext(old, e.to_string());
}
}
}
if let Err(e) = self.write(&index) {
return Contents::Plaintext(old, e.message);
}
let _ = std::fs::remove_file(&pending);
Contents::Index(index)
}
fn write_logged(&self, contents: &impl Serialize) {
if let Err(e) = self.write(contents) {
tracing::warn!("{}", e.message);
}
}
fn write(&self, contents: &impl Serialize) -> IdeResult<()> {
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
@@ -293,258 +65,18 @@ impl SecretFile {
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(contents).map_err(|e| IdeError::internal(e.to_string()))?;
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
fn unavailable(why: &str) -> IdeError {
IdeError::unavailable(format!("saved passwords: {why}"))
}
/// Helpers for tests of crates that save secrets (feature `testing`).
#[cfg(any(test, feature = "testing"))]
pub mod testing {
use std::path::{Path, PathBuf};
/// Makes an in-memory credential store the default, so that the test never
/// touches the user's keychain. Call it before the first secret is saved.
pub fn use_memory_store() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| keyring_core::set_default_store(keyring_core::sample::Store::new().expect("memory store")));
}
/// Every file under `dir` whose bytes contain `needle`.
pub fn files_containing(dir: &Path, needle: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![dir.to_path_buf()];
while let Some(d) = pending.pop() {
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if std::fs::read(&path).is_ok_and(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes())) {
found.push(path);
}
}
}
found
}
}
#[cfg(test)]
mod tests {
use super::testing::{files_containing, use_memory_store};
use super::*;
use std::collections::HashMap;
/// A credential store that can be absent, or fail on chosen keys.
#[derive(Default)]
struct FakeVault {
absent: bool,
entries: Mutex<HashMap<(String, String), String>>,
failing: Mutex<HashMap<String, fn() -> StoreError>>,
}
impl FakeVault {
fn fail(&self, key: &str, error: fn() -> StoreError) {
self.failing.lock().insert(key.to_owned(), error);
}
fn heal(&self) {
self.failing.lock().clear();
}
fn check(&self, key: &str) -> StoreResult<()> {
if self.absent {
return Err(StoreError::NoDefaultStore);
}
self.failing.lock().get(key).map_or(Ok(()), |e| Err(e()))
}
fn secrets(&self) -> Vec<String> {
let mut all: Vec<_> = self.entries.lock().values().cloned().collect();
all.sort();
all
}
}
impl Vault for FakeVault {
fn available(&self) -> bool {
!self.absent
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
self.check(key)?;
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
self.check(key)?;
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
Ok(())
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
self.check(key)?;
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
}
}
fn platform() -> StoreError {
StoreError::PlatformFailure("locked".into())
}
fn with_fake(path: &std::path::Path, vault: &Arc<FakeVault>) -> SecretFile {
SecretFile::with_vault(path.to_path_buf(), |v| serde_json::from_value(v).ok(), vault.clone())
}
const OLD: &str = r#"{"https://a.example": "me\nold-a", "https://b.example": "me\nold-b"}"#;
#[test]
fn saved_secrets_never_reach_disk() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
SecretFile::new(path.clone()).set("a", "hunter2-ssh").unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-ssh"), Vec::<PathBuf>::new());
let reopened = SecretFile::new(path);
assert_eq!(reopened.get("a").as_deref(), Some("hunter2-ssh"));
assert_eq!(reopened.keys(), ["a"]);
}
#[test]
fn moves_plaintext_files_of_older_versions_off_disk() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, r#"{"https://example.com": "me\nold-pw"}"#).unwrap();
let file = SecretFile::new(path.clone());
assert_eq!(file.keys(), ["https://example.com"]);
assert_eq!(files_containing(tmp.path(), "old-pw"), Vec::<PathBuf>::new());
assert_eq!(SecretFile::new(path).get("https://example.com").as_deref(), Some("me\nold-pw"));
}
#[test]
fn a_failed_move_keeps_the_old_file_and_the_retry_converges() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", platform);
let file = with_fake(&path, &vault);
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), OLD, "the old file is untouched");
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("locked"));
vault.heal();
assert_eq!(file.keys(), ["https://a.example", "https://b.example"]);
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
assert_eq!(vault.secrets(), ["me\nold-a", "me\nold-b"], "the retry overwrote, not copied");
assert!(!path.with_extension("json.migrating").exists());
assert_eq!(with_fake(&path, &vault).get("https://b.example").as_deref(), Some("me\nold-b"));
}
#[test]
fn forgetting_during_a_pending_move_forgets_the_copy_too() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", platform);
let file = with_fake(&path, &vault);
file.delete("https://a.example");
vault.heal();
assert_eq!(file.keys(), ["https://b.example"]);
assert_eq!(vault.secrets(), ["me\nold-b"]);
}
#[test]
fn a_secret_the_store_refuses_for_good_is_forgotten_and_does_not_block_saving() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", || StoreError::TooLong("password".into(), 2560));
let file = with_fake(&path, &vault);
assert_eq!(file.keys(), ["https://a.example"]);
assert_eq!(files_containing(tmp.path(), "old-b"), Vec::<PathBuf>::new());
file.set("https://c.example", "me\nnew").unwrap();
assert_eq!(file.get("https://c.example").as_deref(), Some("me\nnew"));
}
#[test]
fn without_a_credential_store_the_old_file_is_read_and_nothing_new_is_saved() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault { absent: true, ..Default::default() });
let file = with_fake(&path, &vault);
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("no credential store"));
assert_eq!(files_containing(tmp.path(), "me\nnew"), Vec::<PathBuf>::new());
assert!(!path.with_extension("json.migrating").exists());
file.delete("https://a.example");
assert_eq!(file.keys(), ["https://b.example"]);
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
}
#[test]
fn forgets_only_what_the_store_forgot() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
file.set("a", "pw").unwrap();
vault.fail("a", platform);
file.delete("a");
assert_eq!(file.keys(), ["a"], "still listed, so that forgetting can be retried");
vault.heal();
file.delete("a");
assert_eq!(file.keys(), Vec::<String>::new());
assert_eq!(vault.secrets(), Vec::<String>::new());
}
#[test]
fn a_secret_removed_outside_the_app_is_no_longer_listed() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
file.set("a", "pw").unwrap();
vault.entries.lock().clear();
assert_eq!(file.get("a"), None);
assert_eq!(file.keys(), Vec::<String>::new());
}
#[test]
fn a_failed_index_write_takes_the_secret_back_out_of_the_store() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
std::fs::create_dir(&path).unwrap();
let vault = Arc::new(FakeVault::default());
assert!(with_fake(&path, &vault).set("a", "pw").is_err());
assert_eq!(vault.secrets(), Vec::<String>::new());
}
#[test]
fn an_unreadable_file_is_set_aside_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
std::fs::write(&path, "{ not json").unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&path, &vault);
assert_eq!(file.keys(), Vec::<String>::new());
file.set("a", "pw").unwrap();
std::fs::write(&path, "{ not json either").unwrap();
assert_eq!(file.keys(), Vec::<String>::new());
assert_eq!(files_containing(tmp.path(), "{ not json").len(), 2, "both kept, neither overwritten");
}
#[test]
fn stores_and_forgets_secrets() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
file.set("a", "pw").unwrap();
@@ -1,44 +0,0 @@
//! Saved passwords against the real OS credential store (Windows Credential
//! Manager, macOS Keychain, Secret Service).
use ide_ssh::secrets::SecretFile;
/// Forgets every key of the file, also when the test panics.
struct Forget<'a>(&'a SecretFile);
impl Drop for Forget<'_> {
fn drop(&mut self) {
for key in self.0.keys() {
self.0.delete(&key);
}
}
}
#[test]
#[ignore = "uses the OS credential store"]
fn saves_moves_and_forgets_in_the_os_store() {
assert!(keyring::Entry::store_status().is_ok(), "no OS credential store");
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, r#"{"https://old.example": "me\nold-os-pw"}"#).unwrap();
let file = SecretFile::new(path.clone());
let forget = Forget(&file);
file.set("https://new.example", "me\nnew-os-pw").unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("old-os-pw") && !on_disk.contains("new-os-pw"), "{on_disk}");
let reopened = SecretFile::new(path);
assert_eq!(reopened.get("https://old.example").as_deref(), Some("me\nold-os-pw"));
assert_eq!(reopened.get("https://new.example").as_deref(), Some("me\nnew-os-pw"));
#[cfg(windows)]
{
let index: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
let service = format!("ai.l0ng.openidea/{}", index["id"].as_str().unwrap());
let listed = std::process::Command::new("cmdkey").arg("/list").output().unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert_eq!(listed.matches(service.as_str()).count(), 2, "{listed}");
}
drop(forget);
assert_eq!(reopened.keys(), Vec::<String>::new());
assert_eq!(reopened.get("https://old.example"), None);
}
-1
View File
@@ -12,7 +12,6 @@ ide-core.workspace = true
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
ropey = { version = "1.6", default-features = false, features = ["simd"] }
serde.workspace = true
similar = { version = "2.7", default-features = false }
specta.workspace = true
[dev-dependencies]
-4
View File
@@ -73,10 +73,6 @@ fn bench(c: &mut Criterion) {
BatchSize::SmallInput,
)
});
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
let every_line = text.replace("compute", "calculate");
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
c.bench_function("text/offset_to_pos_round_trip", |b| {
let mut rng = Lcg(3);
let len = base.len_utf16();
+31 -124
View File
@@ -14,7 +14,6 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
use serde::{Deserialize, Serialize};
use specta::Type;
use std::borrow::Cow;
use std::time::{Duration, Instant};
pub use ropey;
@@ -67,36 +66,6 @@ impl LineEnding {
}
}
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
const DIFF_BUDGET: Duration = Duration::from_millis(50);
type LineRange = std::ops::Range<usize>;
/// Byte lengths of the common prefix and suffix; they never overlap.
fn common_ends(a: &str, b: &str) -> (usize, usize) {
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
};
let prefix = shared(&mut a.chars(), &mut b.chars());
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
(prefix, suffix)
}
/// Byte offset of each line, then the total length.
fn line_starts(lines: &[&str]) -> Vec<usize> {
let mut at = 0;
let mut starts = vec![0];
starts.extend(lines.iter().map(|line| {
at += line.len();
at
}));
starts
}
fn utf16_len(text: &str) -> u32 {
text.chars().map(|c| c.len_utf16() as u32).sum()
}
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
if !text.contains('\r') {
@@ -365,63 +334,42 @@ impl Document {
cs
}
/// The change set turning the current text into `text` (after line-ending
/// normalization), one change per changed region. Empty when equal. A single change
/// spanning two distant edits would drag the editor's caret and view to the first one.
/// The minimal single-change (common prefix/suffix) change set turning the
/// current text into `text` (after line-ending normalization). Empty when equal.
pub fn diff(&self, text: &str) -> ChangeSet {
self.diff_until(text, Instant::now() + DIFF_BUDGET)
}
/// Past `deadline` the regions get coarser, never wrong.
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
let new = normalize_line_endings(text);
let old = self.rope.to_string();
let (prefix, suffix) = common_ends(&old, &new);
let old_rest = &old[prefix..old.len() - suffix];
let new_rest = &new[prefix..new.len() - suffix];
let old_chars = self.rope.len_chars();
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
let old_starts = line_starts(&old_lines);
let new_starts = line_starts(&new_lines);
let mut changes = Vec::new();
let mut utf16_at = utf16_len(&old[..prefix]);
let mut old_at = 0;
let mut region: Option<(LineRange, LineRange)> = None;
let ops =
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
let mut flush = |region: Option<(LineRange, LineRange)>| {
let Some((old_range, new_range)) = region else { return };
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
let (head, tail) = common_ends(region_old, region_new);
let start = old_starts[old_range.start] + head;
let end = old_starts[old_range.end] - tail;
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
let to = from + utf16_len(&old_rest[start..end]);
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
utf16_at = to;
old_at = end;
};
// Positions come from the lengths: `similar` can report a stale start for the empty
// side of an op (an insert after an equal run still names the run's first old line).
let (mut old_line, mut new_line) = (0, 0);
for op in ops {
let old_end = old_line + op.old_range().len();
let new_end = new_line + op.new_range().len();
if op.tag() == similar::DiffTag::Equal {
flush(region.take());
} else {
region = Some(match region.take() {
Some((o, n)) => (o.start..old_end, n.start..new_end),
None => (old_line..old_end, new_line..new_end),
});
let mut prefix_chars = 0usize;
let mut prefix_bytes = 0usize;
for (a, b) in self.rope.chars().zip(new.chars()) {
if a != b {
break;
}
(old_line, new_line) = (old_end, new_end);
prefix_chars += 1;
prefix_bytes += b.len_utf8();
}
flush(region);
ChangeSet { changes }
let new_rest = &new[prefix_bytes..];
let new_rest_chars = new_rest.chars().count();
if prefix_chars == old_chars && new_rest_chars == 0 {
return ChangeSet::default();
}
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
let mut suffix_chars = 0usize;
let mut suffix_bytes = 0usize;
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
if a != b {
break;
}
suffix_chars += 1;
suffix_bytes += b.len_utf8();
}
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
ChangeSet { changes: vec![TextChange { from, to, insert }] }
}
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
@@ -658,45 +606,4 @@ mod tests {
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
assert_eq!(doc.text(), "😀x😀");
}
fn numbered_lines(n: usize) -> String {
(0..n).map(|i| format!("line {i}\n")).collect()
}
#[test]
fn diff_keeps_distant_edits_apart() {
let old = numbered_lines(400);
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
let set = Document::new(&old).diff(&new);
assert_eq!(
set.changes,
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
);
}
#[test]
fn diff_handles_added_and_removed_lines() {
let old = "a\nb\nc\nd\ne\nf\n";
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
}
#[test]
fn diff_counts_utf16_past_astral_text() {
let old = "😀 one\nsame\n😀 two\n";
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
}
#[test]
fn diff_past_its_deadline_is_coarser_but_still_correct() {
let old = numbered_lines(5000);
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
let doc = Document::new(&old);
let set = doc.diff_until(&new, std::time::Instant::now());
let mut replayed = Document::new(&old);
replayed.apply(0, &set).unwrap();
assert_eq!(replayed.text(), new);
}
}
@@ -5,4 +5,3 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
+1 -1
View File
@@ -176,7 +176,7 @@ proptest! {
let set = doc.replace_all(&b);
prop_assert_eq!(doc.text(), b.clone());
prop_assert_eq!(naive_apply(&a, &set), b.clone());
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
prop_assert!(set.changes.len() <= 1);
prop_assert_eq!(set.changes.is_empty(), a == b);
}
+1 -36
View File
@@ -8,12 +8,6 @@
//!
//! Language servers find managed tools once [`extend_process_path`] has put
//! [`tools_bin_dirs`] on the process `PATH` at startup.
//!
//! GitHub API downloads prefer `GITHUB_TOKEN`, then `GH_TOKEN`, then the active
//! `github.com` account from `gh auth token --hostname github.com`. The CLI is
//! optional; absent credentials retain anonymous downloads. Tokens are passed
//! to curl over stdin, never stored in tool manifests or command arguments, and
//! are not sent to other download origins. CLI lookup is cancellable and bounded.
pub mod archive;
mod bundled;
@@ -395,11 +389,6 @@ pub fn parse_version(text: &str) -> Option<String> {
None
}
/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`).
fn is_app_execution_alias(path: &Path) -> bool {
path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps"))
}
/// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable.
fn is_bundle(command: &str) -> bool {
command.ends_with(".jar")
@@ -496,32 +485,8 @@ impl Tools {
cmd
}
/// The interpreter that installs and probes Python tools. Windows installs `python.exe`
/// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution
/// Aliases that only open the Store unless the Store's Python is installed, so they count
/// only when they run.
fn python(&self) -> Option<PathBuf> {
if self.platform.os != Os::Windows {
return self.which_any(&["python3", "python"]);
}
["python", "py", "python3"]
.into_iter()
.flat_map(|command| {
std::env::split_paths(&self.system_path).filter_map(move |dir| {
if dir.as_os_str().is_empty() {
return None;
}
let search = std::env::join_paths([dir]).ok()?;
ide_core::tool_path::find_command(command, &search, &self.root)
})
})
.find(|p| {
!is_app_execution_alias(p) || {
let mut c = self.python_command(p);
c.arg("--version");
run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok)
}
})
self.which_any(&["python3", "python"])
}
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
+17 -346
View File
@@ -6,9 +6,9 @@ use ide_core::error::ErrorKind;
use ide_core::output_encoding::decode_tool_output;
use ide_core::process::{kill_tree, own_group};
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
@@ -144,99 +144,6 @@ fn strip_ansi(s: &str) -> String {
out
}
const MAX_GITHUB_TOKEN: usize = 4096;
fn valid_github_token(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
.then(|| value.to_owned())
}
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
}
/// Ask gh for the active github.com account on each request, so switching or
/// logging out takes effect without persisting a second copy of the credential.
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = Command::new(gh);
own_group(ide_core::process::prepare(&mut cmd));
cmd.args(["auth", "token", "--hostname", "github.com"])
.env("GH_PROMPT_DISABLED", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
let stdout = child.stdout.take().expect("piped");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
let _ = tx.send(bytes);
});
let started = Instant::now();
let mut status = None;
let mut output = None;
let bytes = loop {
if cancel.is_cancelled() || started.elapsed() >= timeout {
kill_tree(&mut child);
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(_) => {
kill_tree(&mut child);
return Ok(None);
}
}
}
if let Some(status) = status {
if !status.success() {
kill_tree(&mut child);
return Ok(None);
}
if let Some(bytes) = output.take() {
break bytes;
}
}
match rx.recv_timeout(Duration::from_millis(15)) {
Ok(bytes) => output = Some(bytes),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
}
};
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
return Ok(None);
}
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
}
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
if let Some(token) = token {
let result = child
.stdin
.take()
.expect("authenticated request has piped stdin")
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
if let Err(error) = result {
kill_tree(child);
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
}
}
Ok(())
}
fn redact_auth(message: &str, token: Option<&str>) -> String {
match token {
Some(token) => message.replace(token, "[redacted]"),
None => message.to_owned(),
}
}
/// Whether `curl` checks certificate revocation with Schannel and takes
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
@@ -267,8 +174,6 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
/// mainstream Linux), so the IDE carries no TLS stack of its own.
pub struct Http {
curl: std::path::PathBuf,
gh: Option<std::path::PathBuf>,
explicit_github_token: Option<String>,
https_only: bool,
revoke_best_effort: bool,
}
@@ -277,10 +182,8 @@ impl Http {
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
let curl = which::which_in("curl", Some(search_path), ".")
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
let gh = which::which_in("gh", Some(search_path), ".").ok();
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
Ok(Self { curl, gh, explicit_github_token, https_only: false, revoke_best_effort })
Ok(Self { curl, https_only: false, revoke_best_effort })
}
/// Refuses every protocol but HTTPS, redirects included.
@@ -289,28 +192,8 @@ impl Http {
self
}
fn base(&self, url: &str, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let github = url.starts_with("https://api.github.com/");
let token = if github {
match &self.explicit_github_token {
Some(token) => Some(token.clone()),
None => match &self.gh {
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
None => None,
},
}
} else {
None
};
fn base(&self, url: &str) -> Command {
let mut cmd = Command::new(&self.curl);
// A user curlrc must not enable verbose token logging or forwarding
// Authorization across origins on authenticated API requests.
if token.is_some() {
cmd.arg("-q");
}
own_group(ide_core::process::prepare(&mut cmd));
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
@@ -321,14 +204,13 @@ impl Http {
if self.revoke_best_effort {
cmd.arg("--ssl-revoke-best-effort");
}
cmd.stdin(Stdio::null());
if token.is_some() {
// Keep credentials out of process arguments, inherited environment,
// and files. curl strips Authorization on cross-origin redirects.
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
if url.starts_with("https://api.github.com/")
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
&& !token.is_empty()
{
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
}
Ok((cmd, token))
cmd
}
/// GETs `url` as text.
@@ -353,16 +235,15 @@ impl Http {
empty_on_404: bool,
cancel: &CancellationToken,
) -> IdeResult<String> {
let (mut cmd, token) = self.base(url, cancel)?;
let mut cmd = self.base(url);
if empty_on_404 {
cmd.args(["--write-out", "\n%{http_code}"]);
}
if let Some(max) = max_bytes {
cmd.arg("--max-filesize").arg(max.to_string());
}
cmd.arg(url).stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
send_auth(&mut child, token.as_deref())?;
let stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
// Past the limit the pipe closes, which stops curl.
@@ -399,10 +280,7 @@ impl Http {
let mut err = Vec::new();
let _ = stderr.read_to_end(&mut err);
let err = decode_tool_output(&err);
return Err(IdeError::new(
ErrorKind::Io,
format!("GET {url} failed: {}", redact_auth(err.trim(), token.as_deref())),
));
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
}
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
}
@@ -416,10 +294,9 @@ impl Http {
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let (mut cmd, token) = self.base(url, cancel)?;
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
let mut cmd = self.base(url);
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
send_auth(&mut child, token.as_deref())?;
let mut stderr = child.stderr.take().expect("piped");
let err_t = std::thread::spawn(move || {
let mut s = Vec::new();
@@ -450,10 +327,7 @@ impl Http {
if !status.success() {
return Err(IdeError::new(
ErrorKind::Io,
format!(
"download of {url} failed: {}",
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
),
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
));
}
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
@@ -616,13 +490,7 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let curl = tmp.path().join("curl");
let http = Http {
curl: curl.clone(),
gh: None,
explicit_github_token: None,
https_only: false,
revoke_best_effort: false,
};
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
{
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
@@ -727,200 +595,3 @@ mod input_tests {
);
}
}
#[cfg(test)]
mod github_auth_tests {
use super::*;
#[test]
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
assert_eq!(
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
Some("second")
);
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
assert!(valid_github_token(bad.into()).is_none());
}
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
}
#[cfg(unix)]
mod unix {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
fn quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
}
struct Fixture {
dir: tempfile::TempDir,
http: Http,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let curl = dir.path().join("curl");
let gh = dir.path().join("gh");
let fixture = Self {
dir,
http: Http {
curl,
gh: Some(gh),
explicit_github_token: None,
https_only: false,
revoke_best_effort: false,
},
};
fixture.script(
"gh",
&format!(
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
quote(&fixture.path("gh-args")),
quote(&fixture.path("token"))
),
);
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
fixture.curl("printf '{\"ok\":true}'");
fixture
}
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn script(&self, name: &str, body: &str) {
let path = self.path(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
fn curl(&self, body: &str) {
self.script(
"curl",
&format!(
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
quote(&self.path("curl-args")),
quote(&self.path("headers"))
),
);
}
fn get(&self, url: &str) -> IdeResult<String> {
self.http.get_text(url, &CancellationToken::new())
}
}
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
#[test]
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
let f = Fixture::new();
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
let args = fs::read_to_string(f.path("curl-args")).unwrap();
assert!(!args.contains("gho_fake"));
assert!(args.starts_with("-q\n"));
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
assert!(!args.contains("--location-trusted"));
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
}
#[test]
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
let mut f = Fixture::new();
f.http.explicit_github_token = Some("explicit_fake".into());
f.get(API).unwrap();
assert!(!f.path("gh-args").exists());
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
f.http.explicit_github_token = None;
for url in [
"https://example.com/file",
"http://api.github.com/repos/x",
"https://api.github.com.evil.example/file",
"https://[email protected]/file",
"https://github.com/example/project/releases/download/v1/file",
] {
f.get(url).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
assert!(!f.path("gh-args").exists());
}
}
#[test]
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
let mut f = Fixture::new();
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
f.script("gh", body);
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
}
f.http.gh = None;
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
}
#[test]
fn download_auth_and_errors_do_not_expose_the_token() {
let f = Fixture::new();
let dest = f.path("download");
f.curl(&format!("printf downloaded > {}", quote(&dest)));
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
let error = f.get(API).unwrap_err().to_string();
assert!(error.contains("[redacted]"));
assert!(!error.contains("gho_fake_first"));
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
assert!(!error.contains("gho_fake_first"));
}
#[test]
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
let f = Fixture::new();
// The parent exits but a child keeps stdout open: joining a reader
// without checking the deadline would hang here.
f.script("gh", "/bin/sleep 5 &\nexit 0");
let started = Instant::now();
assert!(
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
.unwrap()
.is_none()
);
assert!(started.elapsed() < Duration::from_secs(3));
f.script("gh", "/bin/sleep 5");
let cancel = CancellationToken::new();
let trigger = cancel.clone();
let thread = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
trigger.cancel();
});
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
thread.join().unwrap();
assert!(!f.path("curl-args").exists());
}
#[test]
#[ignore = "requires an active gh github.com login and network access"]
fn live_github_release_uses_authenticated_quota() {
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
assert!(http.gh.is_some());
let cancel = CancellationToken::new();
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
let body = http
.get_text_limited(
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
Some(1024 * 1024),
&cancel,
)
.unwrap();
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(response["tag_name"].is_string());
}
}
}
-34
View File
@@ -37,12 +37,6 @@ impl Env {
fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
archive::make_executable(&p).unwrap();
}
#[cfg(windows)]
fn script(&self, dir: &Path, name: &str, body: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap();
}
}
fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
@@ -706,34 +700,6 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
assert!(!env.root.join("rust-analyzer").exists());
}
/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count
/// when the Store's Python behind them runs.
#[test]
fn windows_python_skips_store_aliases_that_do_not_run() {
let env = Env::new();
let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps");
env.script(&aliases, "python", "exit 9009");
env.script(&aliases, "python3", "exit 9009");
env.script(&env.sys, "py", "echo Python 3.12.0");
let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 };
let found = || {
let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap();
Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| {
let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy())
})
};
assert_eq!(found().as_deref(), Some("sysbin/py"));
fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap();
assert_eq!(found(), None, "only broken aliases are available");
env.script(&env.sys, "python3", "echo Python 3.12.0");
assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback");
env.script(&env.sys, "python", "echo Python 3.12.0");
assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter");
env.script(&aliases, "python", "echo Python 3.13.0");
assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs");
}
#[cfg(unix)]
#[test]
fn python_probes_run_in_an_empty_private_directory() {
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
specta.workspace = true
ignore.workspace = true
globset.workspace = true
notify.workspace = true
notify-debouncer-full.workspace = true
encoding_rs.workspace = true
chardetng.workspace = true
trash.workspace = true
+92 -163
View File
@@ -12,8 +12,9 @@
use crate::FsBatch;
use crate::rules::{ExcludeMatcher, GitignoreStack};
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
use parking_lot::{Condvar, Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Component, Path, PathBuf};
@@ -61,9 +62,10 @@ pub struct Watcher {
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
struct Inner {
state: Arc<DirState>,
thread: Option<JoinHandle<()>>,
enum Inner {
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
}
impl Watcher {
@@ -129,32 +131,80 @@ impl Watcher {
/// Number of OS-level watches (1 in recursive and flat mode).
pub fn watch_count(&self) -> usize {
self.inner.state.watched.lock().len()
match &self.inner {
Inner::Recursive(_) | Inner::Polling(_) => 1,
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
}
}
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
let config = notify::Config::default();
match mode {
WatchMode::Recursive => {
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
}
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
}
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
}
}
/// Polls per directory unless flat: a recursive poll would rescan
/// `node_modules` and `target` every round.
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
match mode {
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
}
WatchMode::Recursive | WatchMode::PerDirectory => {
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
}
}
}
fn start_queued<W: notify::Watcher + Send + 'static>(
fn start_debounced<W: notify::Watcher>(
root: &Path,
filter: EventFilter,
emit: Emit,
recursive: RecursiveMode,
config: notify::Config,
) -> IdeResult<Debouncer<W, NoCache>> {
let polled = W::kind() == WatcherKind::PollWatcher;
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
// which made opening large projects block for seconds. Renames arrive as remove + create.
let handler = move |result: DebounceEventResult| match result {
Ok(mut events) => {
if polled {
events.retain(|e| !is_dir_touch(&e.event));
}
let (batch, _) = filter.batch_detailed(&events);
if !batch.is_empty() {
emit(&batch);
}
}
Err(errors) => {
for e in errors {
tracing::warn!("file watcher: {e}");
}
}
};
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
Ok(debouncer)
}
fn start_per_directory<W: notify::Watcher + Send + 'static>(
root: &Path,
filter: EventFilter,
emit: Emit,
mode: WatchMode,
config: notify::Config,
) -> IdeResult<Inner> {
let polled = W::kind() == WatcherKind::PollWatcher;
let state = Arc::new(DirState {
filter,
mode,
watcher: Mutex::new(None),
watched: Mutex::default(),
queue: Mutex::default(),
@@ -178,11 +228,8 @@ impl Watcher {
)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
*state.watcher.lock() = Some(Box::new(watcher));
if mode == WatchMode::PerDirectory {
state.watch_tree(root, false, &mut Vec::new());
} else {
state.add_watch(root);
}
let mut created = Vec::new();
state.watch_tree(root, false, &mut created);
if state.watched.lock().is_empty() {
return Err(io_error(format!("cannot watch {}", root.display())));
}
@@ -193,22 +240,20 @@ impl Watcher {
.spawn(move || state.run(&*emit))
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
};
Ok(Inner { state, thread: Some(thread) })
Ok(Inner::PerDirectory { state, thread: Some(thread) })
}
}
impl Drop for Inner {
impl Drop for Watcher {
fn drop(&mut self) {
let state = &self.state;
{
let _queue = state.queue.lock();
if let Inner::PerDirectory { state, thread } = &mut self.inner {
state.stop.store(true, Ordering::SeqCst);
state.wake.notify_all();
}
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = self.thread.take() {
let _ = t.join();
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = thread.take() {
let _ = t.join();
}
}
}
}
@@ -223,10 +268,10 @@ fn is_dir_touch(event: &notify::Event) -> bool {
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
}
/// Shared with the notify callback (weakly) and the debounce thread.
/// State of the per-directory strategy, shared with the notify callback
/// (weakly) and the debounce thread.
struct DirState {
filter: EventFilter,
mode: WatchMode,
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
watched: Mutex<HashSet<PathBuf>>,
queue: Mutex<Vec<(notify::Event, Instant)>>,
@@ -265,13 +310,9 @@ impl DirState {
}
std::mem::take(&mut *q)
};
// A scan may publish a path before its create event is delivered:
// keep create + remove pairs so the consumer still sees the removal.
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
if self.mode == WatchMode::PerDirectory {
self.update_watches(&mut batch, &rule_dirs);
}
self.update_watches(&mut batch, &rule_dirs);
if !batch.is_empty() && !self.stopped() {
emit(&batch);
}
@@ -322,9 +363,7 @@ impl DirState {
}
let mut guard = self.watcher.lock();
let Some(w) = guard.as_mut() else { return false };
let recursive =
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
match w.watch(dir, recursive) {
match w.watch(dir, RecursiveMode::NonRecursive) {
Ok(()) => {
self.watched.lock().insert(dir.to_path_buf());
true
@@ -516,17 +555,17 @@ impl EventFilter {
}
#[cfg(test)]
pub fn batch(&self, events: &[Event]) -> FsBatch {
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
self.batch_detailed(events).0
}
/// The coalesced, filtered batch plus the directories whose ignore rules
/// changed (their cached matchers have been rebuilt already).
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
for ev in events {
let paths = &ev.paths;
match ev.kind {
let paths = &ev.event.paths;
match ev.event.kind {
EventKind::Access(_) => {}
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
@@ -562,7 +601,7 @@ impl EventFilter {
rule_dirs.insert(dir.to_path_buf());
}
}
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
self.gitignore.reload_repo_level();
rule_dirs.insert(self.root.clone());
}
@@ -630,125 +669,11 @@ impl EventFilter {
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, DataChange, RemoveKind};
use notify_debouncer_full::notify::Event;
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event { kind, paths, attrs: Default::default() }
}
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
/// Delivers a whole debounce window without depending on OS scheduling.
struct TransientWatcher(Box<dyn notify::EventHandler>);
impl notify::Watcher for TransientWatcher {
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
Ok(Self(Box::new(handler)))
}
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
for rel in TRANSIENT_PATHS {
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
EventKind::Remove(RemoveKind::File),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
}
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
}
for (rel, kind) in [
("from.py", EventKind::Create(CreateKind::File)),
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
("to.py", EventKind::Remove(RemoveKind::File)),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
}
Ok(())
}
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
Ok(())
}
fn kind() -> WatcherKind {
WatcherKind::NullWatcher
}
}
#[test]
fn debounce_preserves_transient_removals_and_atomic_replacements() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
let _watcher = Watcher::start_queued::<TransientWatcher>(
&root,
EventFilter::new(root.clone(), &[]),
emit,
mode,
notify::Config::default(),
)
.unwrap();
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
let expected =
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
assert_eq!(batch.modified, vec![root.join("kept.py")]);
assert!(batch.created.is_empty());
}
}
#[cfg(windows)]
#[test]
fn native_watch_reports_quickly_removed_files() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
let watcher = {
let batches = batches.clone();
Watcher::start_with(
&root,
Vec::new(),
Arc::new(ide_core::NullSink),
Box::new(move |b| batches.lock().push(b.clone())),
mode,
)
.unwrap()
};
assert!(watcher.polling().is_none());
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
let paths: Vec<_> = TRANSIENT_PATHS
.iter()
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
.map(|rel| root.join(rel))
.collect();
for path in &paths {
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
std::fs::remove_file(path).unwrap();
}
assert!(
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
"{mode:?}: {:?}",
batches.lock()
);
drop(watcher);
let count = batches.lock().len();
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
std::thread::sleep(DEBOUNCE * 2);
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
}
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
}
#[test]
@@ -897,7 +822,8 @@ mod tests {
}
let filter = EventFilter::new(root.clone(), &[]);
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
let touch =
|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");
@@ -947,7 +873,10 @@ mod tests {
}
fn watcher_state(w: &Watcher) -> &DirState {
&w.inner.state
match &w.inner {
Inner::PerDirectory { state, .. } => state,
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
}
}
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
-22
View File
@@ -344,28 +344,6 @@ fn backend_edits_emit_patches() {
assert_eq!(fs::read_to_string(&path).unwrap(), "ONE\r\n2\r\nTHREE\r\n");
}
#[test]
fn external_change_in_two_places_patches_each() {
let f = fixture();
let path = f.root.join("two.txt");
let old: String = (0..400).map(|i| format!("line {i}\n")).collect();
fs::write(&path, &old).unwrap();
let id = f.docs.open(&path).unwrap().id;
std::thread::sleep(std::time::Duration::from_millis(10));
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
fs::write(&path, &new).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.docs.snapshot(id).unwrap().text, new);
let events = f.events.lock();
let changes = events.iter().find_map(|e| match e {
IdeEvent::DocPatched { changes, .. } => Some(changes),
_ => None,
});
assert_eq!(changes.map(Vec::len), Some(2));
}
#[test]
fn external_changes() {
let f = fixture();
+1 -2
View File
@@ -318,7 +318,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -420,7 +420,6 @@ a token.
| `--syn-number` | `#2aacb8` | `#1750eb` |
| `--syn-punct` | `#bcbec4` | `#080808` |
| `--syn-variable` | `#bcbec4` | `#080808` |
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
+1 -4
View File
@@ -20,10 +20,7 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
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, `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
them for that language.
language servers: when you change a fixture, run it for that language.
## Setup
-1
View File
@@ -9,7 +9,6 @@ const config: KnipConfig = {
"scripts/**/*.ts",
"perf/**/*.ts",
"e2e/**/*.ts",
"smoke/**/*.ts",
],
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
+1 -2
View File
@@ -12,12 +12,11 @@
"tauri": "node scripts/tauri.ts",
"test": "vitest run",
"test:watch": "vitest",
"smoke": "vitest run --config smoke/vitest.config.ts",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
-2
View File
@@ -62,8 +62,6 @@
"text/open_1mb": 2000,
"text/apply_keystroke_1mb": 20,
"text/apply_100_changes_1mb": 1000,
"text/diff_two_edits_1mb": 10000,
"text/diff_every_line_1mb": 100000,
"text/offset_to_pos_round_trip": 5,
"text/pos_to_byte": 5,
"text/text_for_save_crlf_1mb": 1000,
+17 -18
View File
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
@@ -313,7 +313,6 @@ then run `openidea-plugin check`.
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
@@ -18,7 +18,6 @@
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-global": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
-8
View File
@@ -245,10 +245,6 @@ mod imp {
wit::views::open_terminal(title, program, args).map_err(error)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
wit::views::open_location(path, line, column).map_err(error)
}
pub fn setting(key: &str) -> Option<String> {
wit::settings::get(key)
}
@@ -391,10 +387,6 @@ pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(),
imp::open_terminal(title, program, args)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
imp::open_location(path, line, column)
}
/// One of the plugin's settings (`[contributes.settings]`) as JSON: the
/// user's value (project over global) or the schema's default.
pub fn setting(key: &str) -> Option<String> {
-13
View File
@@ -74,7 +74,6 @@ struct State {
refreshed: Vec<String>,
view_messages: Vec<(String, String)>,
terminals: Vec<MockTerminal>,
locations: Vec<(String, u32, u32)>,
settings: BTreeMap<String, String>,
}
@@ -221,10 +220,6 @@ impl MockHost {
self.state.borrow().view_messages.clone()
}
pub fn opened_locations(&self) -> Vec<(String, u32, u32)> {
self.state.borrow().locations.clone()
}
/// Terminals the plugin opened.
pub fn terminals(&self) -> Vec<MockTerminal> {
self.state.borrow().terminals.clone()
@@ -600,14 +595,6 @@ pub mod current {
})
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
s.locations.push((path.into(), line, column));
Ok(())
})
}
pub fn setting(key: &str) -> Option<String> {
with(|s| s.settings.get(key).cloned())
}
-4
View File
@@ -133,10 +133,6 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
-251
View File
@@ -1,251 +0,0 @@
/**
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { get } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { Channel } from "@tauri-apps/api/core";
import { commands, type InstallEvent } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { openProject, useProject } from "@/platform/project";
import { activate as activateBuildTools } from "@/features/build-tools";
import { useBuild } from "@/features/build-tools/store";
import { activate as activateDebugger } from "@/features/debugger";
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
import { activate as activateRun } from "@/features/run";
import { resetRuns, useRun } from "@/features/run/store";
import { activateWorkbench } from "@/workbench/activate";
import { useNotifications } from "@/workbench/notificationStore";
import { connectDesktop } from "./desktop";
import { repoRoot, Server } from "./server";
interface Case {
fixture: string;
/** Runs in the project copy first (installs its dependencies). */
setup?: (root: string) => void;
/** The IDE's tool that debugs it. */
debugger: string;
/** The entry point, debugged from its context. */
file: string;
/** A variable of the paused frame. */
variable: string;
/** An expression evaluated there (Evaluate Expression), and its value. */
evaluate: [string, RegExp];
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
request?: string;
}
const CASES: Record<string, Case> = {
typescript: {
fixture: "ts-app",
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
debugger: "js-debug",
file: "src/index.ts",
variable: "cart",
evaluate: ["cart.items.length", /^2$/],
},
python: {
fixture: "python-app",
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
debugger: "debugpy",
file: "src/tasks/cli.py",
variable: "tasks",
evaluate: ["len(tasks.pending())", /^3$/],
},
rust: {
fixture: "rust-app",
debugger: "codelldb",
file: "src/main.rs",
variable: "inventory",
evaluate: ["inventory", /\S/],
},
go: {
fixture: "go-app",
debugger: "delve",
file: "cmd/server/main.go",
variable: "r",
evaluate: ["r.Method", /^"GET"$/],
request: "http://127.0.0.1:8080/notes?q=openidea",
},
java: {
fixture: "java-app",
debugger: "java-debug",
file: "src/main/java/com/example/orders/App.java",
variable: "book",
evaluate: ["book.size()", /^3$/],
},
kotlin: {
fixture: "kotlin-gradle",
debugger: "kotlin-debug-adapter",
file: "src/main/kotlin/com/example/library/App.kt",
variable: "library",
evaluate: ["library", /Library/],
},
};
let deactivate: (() => void) | null = null;
let server: Server | null = null;
let disconnect: (() => void) | null = null;
let base = "";
/** Backend events received, for failure messages. */
const events: string[] = [];
beforeAll(() => {
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
deactivate = () => disposers.reverse().forEach((d) => d());
});
afterAll(() => deactivate?.());
afterEach(() => {
resetRuns();
disconnect?.();
server?.close();
if (base) rmSync(base, { recursive: true, force: true });
});
async function open(c: Case): Promise<string> {
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
const root = join(base, c.fixture);
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
c.setup?.(root);
server = await Server.start(join(base, "home"));
disconnect = connectDesktop(server);
await openProject(root);
const trusted = await commands.projectTrustSet(root, true, false);
if (trusted.status !== "ok") throw new Error(trusted.error.message);
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
await until(
"the build model",
() => {
const error = useBuild.getState().error;
if (error) throw new Error(`the build model did not load: ${error}`);
return useBuildModel.getState().root === root && useBuildModel.getState().model;
},
5 * 60_000,
);
return root;
}
async function install(tool: string) {
const tools = await call(commands.toolsList());
if (tools.find((t) => t.name === tool)?.status === "installed") return;
const channel = new Channel<InstallEvent>();
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
expect(info.status, `${tool} installs`).toBe("installed");
}
/** What the Debug and Run tool windows show, for failure messages. */
function shown(): string {
const { sessions, console } = useDebug.getState();
const debug = Object.values(sessions).map(
(s) =>
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
(console[s.id] ?? [])
.slice(-40)
.map((l) => ` ${l.text}`)
.join(""),
);
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
}
/** Waits for `value` to be truthy; fails with what the tool windows show. */
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
const end = Date.now() + timeoutMs;
for (;;) {
const v = value();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
await new Promise((r) => setTimeout(r, 200));
}
}
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
async function requestWhenListening(url: string) {
for (let i = 0; i < 600; i++) {
const connected = await new Promise<boolean>((resolve) => {
const req = get(url, (res) => {
res.resume();
resolve(true);
});
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
req.on("error", () => resolve(false));
});
if (connected) return;
await new Promise((r) => setTimeout(r, 500));
}
}
const paused = () => {
const s = activeSession();
return s?.status === "paused" && s.frames.length > 0 ? s : null;
};
/** Paused, failing as soon as every session has ended instead. */
const pausedOrEnded = () => {
const sessions = Object.values(useDebug.getState().sessions);
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
return paused();
};
async function smoke(language: string) {
const c = CASES[language]!;
const root = await open(c);
await install(c.debugger);
const path = join(root, c.file);
const content = readFileSync(path, "utf8");
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
const line = marker + 1;
addBreakpoint(path, line);
await executeAction("run.debugContext", { path, content });
if (c.request) void requestWhenListening(c.request);
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
const top = s.frames[0]!;
expect(top.path).toBe(path);
expect(top.line).toBe(line);
const scopes = await call(commands.debugScopes(s.id, top.id));
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
expect(locals.map((v) => v.name)).toContain(c.variable);
const value = await evaluate(c.evaluate[0], "repl", s);
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
// (and its breakpoint) again.
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
const stops = useDebug.getState().stopSeq;
await executeAction("debug.stepOver");
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
}
const after = paused()!;
expect(after.stopReason, shown()).toBe("step");
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
await executeAction("debug.resume");
if (c.request) {
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
await executeAction("debug.stop");
}
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
}
for (const language of Object.keys(CASES)) {
test(language, () => smoke(language));
}
-77
View File
@@ -1,77 +0,0 @@
/**
* The frontend's view of the desktop app, with a real backend: the window's Tauri runtime
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
*/
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, there from the start as in the app: the frontend registers its event
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
// across the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
{
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
const id = nextCallback++;
callbacks.set(id, cb);
return id;
},
unregisterCallback(id: number) {
callbacks.delete(id);
},
convertFileSrc: (path: string) => path,
// Only the window's own plugins reach this: app commands take the route below.
async invoke(cmd: string, args: Record<string, unknown> = {}) {
if (cmd === "plugin:event|listen") {
listeners.push({ event: args.event as string, handler: args.handler as number });
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
};
Object.assign(window, {
__TAURI_INTERNALS__: internals,
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
});
}
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
for (const l of listeners) {
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
}
};
setInvokeRoute((cmd, args) => {
if (cmd.startsWith("plugin:")) return null;
const { json, channels: used } = encodeArgs(args);
for (const [id, channel] of used) channels.set(id, channel as unknown as { onmessage: (data: unknown) => void });
return server.invoke(cmd, JSON.parse(json));
});
return () => {
setInvokeRoute(null);
server.onChannel = () => {};
server.onEvent = () => {};
};
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more