дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Порівняти коміти
180
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
60c6f49701 | ||
|
|
66c36a7361 | ||
|
|
8daeb21005 | ||
|
|
44b2d11b2d | ||
|
|
f8b3ef6840 | ||
|
|
55836e1b49 | ||
|
|
a94b4bff42 | ||
|
|
3193fd583f | ||
|
|
995ea886ab | ||
|
|
630d436e18 | ||
|
|
a61a89fe86 | ||
|
|
50212e7397 | ||
|
|
08fd134bdb | ||
|
|
ecb3134460 | ||
|
|
5b63a2b95c | ||
|
|
ac426c8881 | ||
|
|
eef24ea785 | ||
|
|
f84ad80305 | ||
|
|
1f383c4e0e | ||
|
|
8ff8f22940 | ||
|
|
a10e3c8898 | ||
|
|
96be40cd84 | ||
|
|
429e8ffc82 | ||
|
|
443bb51bba | ||
|
|
70f10e741c | ||
|
|
faf666d125 | ||
|
|
93f87f49c1 | ||
|
|
6e4d927ff0 | ||
|
|
c77d1a6de7 | ||
|
|
0222c84ace | ||
|
|
45c7f43c02 | ||
|
|
3a24597e58 | ||
|
|
75b5f6f972 | ||
|
|
44b021392a | ||
|
|
415ade72e7 | ||
|
|
9ac4c379a4 | ||
|
|
bb151b1d86 | ||
|
|
b3b4cbbe1a | ||
|
|
c6a4361513 | ||
|
|
01ecf9866e | ||
|
|
58dbb8eb1d | ||
|
|
357b728f1d | ||
|
|
48ac2094fc | ||
|
|
025e0019d2 | ||
|
|
8eb4834a48 | ||
|
|
d7ff91760f | ||
|
|
60f2f47547 | ||
|
|
b5bc1501a0 | ||
|
|
cf421e4fcb | ||
|
|
8d4c8885ca | ||
|
|
2fe6b7b87b | ||
|
|
6de6b33449 | ||
|
|
c3b64304ac | ||
|
|
14f72fc307 | ||
|
|
a065189516 | ||
|
|
9258185517 | ||
|
|
8568ff001b | ||
|
|
2a8e5e791d | ||
|
|
3b608355a3 | ||
|
|
50308a956d | ||
|
|
c666cee075 | ||
|
|
a971483162 | ||
|
|
2616e3fe6d | ||
|
|
b5bd8e3935 | ||
|
|
24330192c0 | ||
|
|
9d8280104f | ||
|
|
8ec35c94ec | ||
|
|
482f391fd1 | ||
|
|
866af3d9cb | ||
|
|
145f168525 | ||
|
|
f73c0a6b1d | ||
|
|
2c576418cb | ||
|
|
b70d487a92 | ||
|
|
413564efcf | ||
|
|
f675dd2730 | ||
|
|
4100ab3341 | ||
|
|
fdd98fbfe4 | ||
|
|
5d2c89b0dd | ||
|
|
d3f68ed180 | ||
|
|
e3dae02de7 | ||
|
|
2eaa6cb0c7 | ||
|
|
b4742b730b | ||
|
|
566b4fdeb1 | ||
|
|
12418e0184 | ||
|
|
7f0fd870e8 | ||
|
|
575749f2ac | ||
|
|
580f0efc28 | ||
|
|
dcdb0e48e4 | ||
|
|
17400ade3d | ||
|
|
b91402ef7d | ||
|
|
56fdc005a8 | ||
|
|
beab7992f9 | ||
|
|
2e0c74e31d | ||
|
|
e16253a890 | ||
|
|
7146779f6c | ||
|
|
cac95273d3 | ||
|
|
55f42d2771 | ||
|
|
30310810e6 | ||
|
|
b2fec4ae30 | ||
|
|
aa5821bb46 | ||
|
|
eda8cae0a1 | ||
|
|
e150101244 | ||
|
|
642b80cef0 | ||
|
|
b4a3e866c6 | ||
|
|
5670c2e8dd | ||
|
|
843127f4a3 | ||
|
|
1127263ea8 | ||
|
|
de84a643aa | ||
|
|
6dfd82de4c | ||
|
|
b79522fd03 | ||
|
|
d7c27c1184 | ||
|
|
6b9a2ec67e | ||
|
|
d3feb056aa | ||
|
|
1e00ea5ee2 | ||
|
|
294198a727 | ||
|
|
82b6980a59 | ||
|
|
81e0e8e2d0 | ||
|
|
87b463990c | ||
|
|
046fd37064 | ||
|
|
cacddee19d | ||
|
|
c8cea62eab | ||
|
|
4a2d5c9599 | ||
|
|
059f25f13e | ||
|
|
a765959d48 | ||
|
|
efbb48c3cb | ||
|
|
bb9486c758 | ||
|
|
5c0c3a009c | ||
|
|
ef667f6e21 | ||
|
|
81b43a52b4 | ||
|
|
0bb730039d | ||
|
|
709e1eb855 | ||
|
|
4473324822 | ||
|
|
5d5b3e4791 | ||
|
|
c5b4b71e30 | ||
|
|
0e299ad936 | ||
|
|
5b8e0b9659 | ||
|
|
3d8a6f07b4 | ||
|
|
99b4f8bdf6 | ||
|
|
86f1f666d7 | ||
|
|
be49490153 | ||
|
|
1f5fcb477d | ||
|
|
1bb454bd2f | ||
|
|
f38f63e397 | ||
|
|
08d87c3903 | ||
|
|
1652866769 | ||
|
|
be5a6f62ad | ||
|
|
f5e8d77dcf | ||
|
|
c3ea376899 | ||
|
|
fde075457b | ||
|
|
135723bca6 | ||
|
|
03a660354c | ||
|
|
1572fc36ea | ||
|
|
4f74f22624 | ||
|
|
1309ce10e4 | ||
|
|
49be259232 | ||
|
|
6a8016b888 | ||
|
|
b8f422d451 | ||
|
|
c868ec8b68 | ||
|
|
059a5193ff | ||
|
|
0f7008391c | ||
|
|
c59f93988b | ||
|
|
76d33f0306 | ||
|
|
cd55bc8f7f | ||
|
|
deec86fab9 | ||
|
|
29e89f2525 | ||
|
|
b1bb4a69cd | ||
|
|
ee99b3641f | ||
|
|
19aee5f262 | ||
|
|
acf6931017 | ||
|
|
555ea8901f | ||
|
|
769c1248d6 | ||
|
|
61ab1851fa | ||
|
|
c6a1a36676 | ||
|
|
6165cb0174 | ||
|
|
79f6caeaa8 | ||
|
|
dbfe66a119 | ||
|
|
a66f01ec87 | ||
|
|
bf06ca7832 | ||
|
|
63a03d1c08 | ||
|
|
4be1b152ef |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Repository-wide oxfmt formatting (#135)
|
||||
55836e1b4961c9d87df8503ec374e624c81d6bf6
|
||||
@@ -7,7 +7,8 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
# A newer push supersedes the run in progress, on main too.
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -29,7 +30,10 @@ jobs:
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
# Only main saves the cache (saving takes minutes on Windows); PRs restore main's.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
@@ -45,9 +49,25 @@ jobs:
|
||||
# Test fixtures write LF files; keep Git from converting them on checkout.
|
||||
- run: git config --global core.autocrlf false
|
||||
- run: cargo nextest run --workspace --no-fail-fast
|
||||
# nextest ran export_bindings, which rewrites src/bindings.ts.
|
||||
- name: Bindings are up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm gen:bindings && git diff --exit-code src/bindings.ts
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: server-musl }
|
||||
- run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
@@ -61,10 +81,25 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-24.04
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- 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@v4
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
@@ -131,8 +131,49 @@ jobs:
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
publish:
|
||||
needs: [create-release, build]
|
||||
needs: [create-release, build, server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
+4
-2
@@ -13,8 +13,7 @@ dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
@@ -34,3 +33,6 @@ playwright-report/
|
||||
# Local AI agent files
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-user IDE state
|
||||
/shelf/
|
||||
/changelists.json
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
"crates/**/tests/fixtures"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.md"],
|
||||
"options": {
|
||||
"embeddedLanguageFormatting": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "react"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"dist",
|
||||
"target",
|
||||
"src-tauri",
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
"playwright-report",
|
||||
".claude"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unassigned-vars": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"preserve-caught-error": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/static-components": "error",
|
||||
"react/use-memo": "error",
|
||||
"react/preserve-manual-memoization": "error",
|
||||
"react/incompatible-library": "warn",
|
||||
"react/immutability": "error",
|
||||
"react/globals": "error",
|
||||
"react/refs": "error",
|
||||
"react/set-state-in-effect": "error",
|
||||
"react/error-boundaries": "error",
|
||||
"react/purity": "error",
|
||||
"react/set-state-in-render": "error",
|
||||
"react/unsupported-syntax": "warn",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
||||
"rules": {
|
||||
"constructor-super": "off",
|
||||
"getter-return": "off",
|
||||
"no-class-assign": "off",
|
||||
"no-const-assign": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-dupe-keys": "off",
|
||||
"no-func-assign": "off",
|
||||
"no-import-assign": "off",
|
||||
"no-new-native-nonconstructor": "off",
|
||||
"no-obj-calls": "off",
|
||||
"no-redeclare": "off",
|
||||
"no-setter-return": "off",
|
||||
"no-this-before-super": "off",
|
||||
"no-unreachable": "off",
|
||||
"no-unsafe-negation": "off",
|
||||
"no-var": "error",
|
||||
"no-with": "off",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
dist
|
||||
target
|
||||
src/bindings.ts
|
||||
pnpm-lock.yaml
|
||||
@@ -1 +0,0 @@
|
||||
{ "printWidth": 120, "semi": true, "singleQuote": false, "trailingComma": "all" }
|
||||
Vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
Створено
+239
-28
@@ -3461,6 +3461,18 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -3960,9 +3972,27 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-vcs",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -3976,7 +4006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -3989,21 +4019,26 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.22"
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"thiserror 2.0.21",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4017,7 +4052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4034,14 +4069,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-ssh",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4054,20 +4088,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4079,9 +4114,50 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
"ide-ssh",
|
||||
"ide-syntax",
|
||||
"ide-terminal",
|
||||
"ide-text",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4099,7 +4175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4121,8 +4197,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4136,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4145,6 +4222,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
@@ -4157,6 +4235,7 @@ dependencies = [
|
||||
"tree-sitter-css",
|
||||
"tree-sitter-dart",
|
||||
"tree-sitter-elixir",
|
||||
"tree-sitter-gdscript",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-graphql",
|
||||
"tree-sitter-haskell",
|
||||
@@ -4188,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
@@ -4211,7 +4290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4229,7 +4308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4240,14 +4319,32 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"ide-ssh",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4256,27 +4353,31 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4284,6 +4385,7 @@ dependencies = [
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"insta",
|
||||
"memchr",
|
||||
@@ -4293,9 +4395,41 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"spellbook",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4310,7 +4444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4323,7 +4457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4335,7 +4469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4355,7 +4489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"gix",
|
||||
@@ -4372,7 +4506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4388,11 +4522,28 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4409,6 +4560,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5816,33 +5968,42 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
"ide-ssh",
|
||||
"ide-syntax",
|
||||
"ide-terminal",
|
||||
"ide-text",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
@@ -6927,6 +7088,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-sftp"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"gloo-timers",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-util"
|
||||
version = "0.52.0"
|
||||
@@ -7260,6 +7441,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
@@ -7701,6 +7892,16 @@ dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spellbook"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -9221,6 +9422,16 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-gdscript"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7a37fe8c0a10c0c39ecd5b2f7db53933f691488f5572409a4d3c0dfeb3f6108"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-go"
|
||||
version = "0.25.0"
|
||||
|
||||
+13
-3
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.1.22"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -32,6 +32,13 @@ ide-coverage = { path = "crates/ide-coverage" }
|
||||
ide-profiler = { path = "crates/ide-profiler" }
|
||||
ide-http = { path = "crates/ide-http" }
|
||||
ide-db = { path = "crates/ide-db" }
|
||||
ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -39,6 +46,7 @@ serde_json = "1"
|
||||
toml = "0.9"
|
||||
toml_edit = "0.25"
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
specta-typescript = "0.0.12"
|
||||
|
||||
# async & concurrency
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] }
|
||||
@@ -100,5 +108,7 @@ opt-level = 2
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
# Keep the symbol table: crash reports and logged backtraces name functions.
|
||||
strip = "debuginfo"
|
||||
@@ -6,6 +6,10 @@ contributors as part of the IntelliJ Platform (https://github.com/JetBrains/inte
|
||||
https://github.com/JetBrains/intellij-plugins), licensed under the Apache License 2.0.
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors.
|
||||
|
||||
Some Simplified Chinese UI terms follow the Visual Studio Code Simplified Chinese
|
||||
language pack (https://github.com/microsoft/vscode-loc), Copyright (c) Microsoft
|
||||
Corporation, licensed under the MIT License.
|
||||
|
||||
OpenIDEA is not affiliated with, endorsed by or sponsored by JetBrains s.r.o.
|
||||
IntelliJ and IntelliJ IDEA are trademarks of JetBrains s.r.o.
|
||||
|
||||
|
||||
+29
-1
@@ -9,13 +9,41 @@
|
||||
The IDE you know, rebuilt in Rust.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool windows,
|
||||
its search-everything habits — to a native app with a Rust core.
|
||||
|
||||
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
|
||||
for 60+ languages.
|
||||
for 60+ languages. Builds for macOS, Windows and Linux.
|
||||
|
||||
## Benchmark
|
||||
|
||||
Opening [freqtrade](https://github.com/freqtrade/freqtrade) (Python, ~770 files)
|
||||
with warm caches, median of 5 runs:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| --------------------------- | ----------------- | ---------------------- |
|
||||
| Window with the project | **0.9 s** | 3.1 s |
|
||||
| Ready (CPU settled) | **1.1 s** | 13.2 s |
|
||||
| Memory (USS, all processes) | **169 MB** | 2011 MB |
|
||||
| CPU time until ready | **2.4 s** | 47.9 s |
|
||||
| Idle CPU | **4 %** of a core | 7 % of a core |
|
||||
| Quit | **0.3 s** | 5.9 s |
|
||||
|
||||
A release build of OpenIDEA from `main` and IDEA with default settings and plugins, on
|
||||
Windows 11, 32-core AMD, 62 GB RAM.
|
||||
IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -79,6 +79,25 @@ user's app-data directory only when the user asks; none of them ships with OpenI
|
||||
unmodified, and its source is available from the repository above.
|
||||
- **Eclipse JDT Language Server** (jdtls): EPL-2.0, from download.eclipse.org.
|
||||
|
||||
## en_US / en_GB Hunspell dictionaries
|
||||
|
||||
`crates/ide-spell/src/dict/en_US.{aff,dic}` (the spell checker's bundled dictionary) is the
|
||||
SCOWL-derived en_US Hunspell dictionary (version 2020.12.07, from the `dictionary-en` npm
|
||||
package 4.0.0), copied unmodified. Copyright Kevin Atkinson and the SCOWL / Hunspell
|
||||
dictionary contributors; MIT- and BSD-style licences, full text in
|
||||
`crates/ide-spell/src/dict/LICENSE-en_US`. `crates/ide-spell/src/dict/en_GB-extra.dic` holds the
|
||||
entries of the matching en_GB dictionary (`dictionary-en-gb` npm package 3.0.0, same SCOWL
|
||||
version, licence and affix file) that en_US lacks. They are checked with the
|
||||
[spellbook](https://github.com/helix-editor/spellbook) crate (MPL-2.0).
|
||||
|
||||
## cspell-dicts word lists
|
||||
|
||||
`crates/ide-spell/src/dict/cspell-terms.txt` (programming terms the spell checker accepts)
|
||||
is derived from the software-terms, typescript, node, rust, fullstack, golang, java, cpp,
|
||||
shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts)
|
||||
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
|
||||
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
|
||||
|
||||
## nvim-treesitter queries
|
||||
|
||||
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
|
||||
@@ -97,7 +116,7 @@ Apache License
|
||||
|
||||
Version 2.0, January 2004
|
||||
|
||||
http://www.apache.org/licenses/
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
@@ -148,3 +167,124 @@ If the Work includes a "NOTICE" text file as part of its distribution, then any
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
## Solarized
|
||||
|
||||
The Solarized palettes in `src/styles/tokens.css` are adapted from
|
||||
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
|
||||
See [theme sources and mapping](docs/themes.md). MIT license:
|
||||
|
||||
```text
|
||||
Copyright (c) 2011 Ethan Schoonover
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
## Atom One Light and One Dark
|
||||
|
||||
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
|
||||
adapted from GitHub’s Atom One syntax and UI themes.
|
||||
Source revisions are listed in [theme sources and mapping](docs/themes.md).
|
||||
Both syntax themes use the following MIT notice:
|
||||
|
||||
```text
|
||||
Copyright (c) 2016 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
Both UI themes use the following MIT notice:
|
||||
|
||||
```text
|
||||
Copyright (c) 2014 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
## Chinese UI terminology from Visual Studio Code
|
||||
|
||||
Some Simplified Chinese terms in `src/platform/locales/text/zh.json` follow the
|
||||
wording of the Visual Studio Code Simplified Chinese language pack
|
||||
([microsoft/vscode-loc](https://github.com/microsoft/vscode-loc), MIT), e.g.
|
||||
"未跟踪" (untracked), "合并冲突" (merge conflicts), "当前" (current), "已修改"
|
||||
(modified), "已过时" (outdated), "中止" (abort), "正在卸载" (uninstalling),
|
||||
"深色" / "浅色" (dark / light) and "只读" (read-only). The remaining translations
|
||||
are written for this project.
|
||||
|
||||
```
|
||||
Visual Studio Code Localization Packs
|
||||
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
All rights reserved.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "ide-agent"
|
||||
description = "Coding agents over the Agent Client Protocol: tasks, sessions, worktrees and their timelines"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
let mut out = result.get("configOptions").map(parse_options).unwrap_or_default();
|
||||
let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category));
|
||||
if !has(&out, "mode")
|
||||
&& let Some(modes) = result.get("modes")
|
||||
{
|
||||
out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode));
|
||||
}
|
||||
if !has(&out, "model")
|
||||
&& let Some(models) = result.get("models")
|
||||
{
|
||||
out.push(legacy(
|
||||
models,
|
||||
"model",
|
||||
"Model",
|
||||
"availableModels",
|
||||
"modelId",
|
||||
"currentModelId",
|
||||
AgentConfigVia::Model,
|
||||
));
|
||||
}
|
||||
out.retain(|o| !o.values.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
|
||||
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
let Some(list) = options.as_array() else { return Vec::new() };
|
||||
list.iter()
|
||||
.filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select"))
|
||||
.filter_map(|o| {
|
||||
let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned();
|
||||
let mut values = Vec::new();
|
||||
for v in o.get("options").and_then(Value::as_array).into_iter().flatten() {
|
||||
// Grouped options: `{ group, name, options: [...] }`.
|
||||
match v.get("options").and_then(Value::as_array) {
|
||||
Some(group) => values.extend(group.iter().filter_map(value_of)),
|
||||
None => values.extend(value_of(v)),
|
||||
}
|
||||
}
|
||||
Some(AgentConfigOption {
|
||||
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
|
||||
description: str_of(o, "description"),
|
||||
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
|
||||
current: str_of(o, "currentValue"),
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
|
||||
/// stand-ins stay.
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
config.retain(|o| !o.values.is_empty());
|
||||
}
|
||||
|
||||
/// `current_mode_update`.
|
||||
pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) {
|
||||
if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) {
|
||||
o.current = Some(mode.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// The request (method, params without the session id) that sets `option` to `value`.
|
||||
pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) {
|
||||
match option.via {
|
||||
AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })),
|
||||
AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })),
|
||||
AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })),
|
||||
}
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
Some(AgentCommand {
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `usage_update`: `{ used, size }`.
|
||||
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
|
||||
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
|
||||
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
|
||||
}
|
||||
|
||||
fn legacy(
|
||||
v: &Value,
|
||||
id: &str,
|
||||
name: &str,
|
||||
list: &str,
|
||||
key: &str,
|
||||
current: &str,
|
||||
via: AgentConfigVia,
|
||||
) -> AgentConfigOption {
|
||||
let values = v
|
||||
.get(list)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| {
|
||||
let value = m.get(key)?.as_str()?.to_owned();
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(m, "description"),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
AgentConfigOption {
|
||||
id: id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
category: Some(id.to_owned()),
|
||||
current: str_of(v, current),
|
||||
values,
|
||||
via,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_of(v: &Value) -> Option<AgentConfigValue> {
|
||||
let value = v.get("value")?.as_str()?.to_owned();
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(v, "description"),
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn str_of(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> {
|
||||
c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_options_win_over_the_older_fields() {
|
||||
let r = json!({
|
||||
"modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]},
|
||||
"configOptions": [
|
||||
{"id": "mode", "category": "mode", "type": "select", "currentValue": "plan",
|
||||
"options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]},
|
||||
{"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium",
|
||||
"options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]},
|
||||
{"id": "fast", "type": "boolean", "currentValue": false}
|
||||
],
|
||||
"models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]}
|
||||
});
|
||||
let c = options_of(&r);
|
||||
assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]);
|
||||
assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["low", "Medium"]);
|
||||
assert_eq!(set_request(&c[0], "default").0, "session/set_config_option");
|
||||
assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_replace_options_and_track_the_mode() {
|
||||
let mut c = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]},
|
||||
"configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}]
|
||||
}));
|
||||
assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id");
|
||||
set_mode(&mut c, "code");
|
||||
assert_eq!(c[1].current.as_deref(), Some("code"));
|
||||
assert_eq!(set_request(&c[1], "ask").0, "session/set_mode");
|
||||
merge_update(
|
||||
&mut c,
|
||||
&json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]),
|
||||
);
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
assert_eq!((u.used, u.size), (1200, 200000));
|
||||
assert!(usage_of(&json!({"used": 1, "size": 0})).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! A task's conversation and those of its subagents.
|
||||
//!
|
||||
//! An agent that supports ACP subagent sessions runs each subagent (Claude Code's Agent
|
||||
//! tool, Codex's spawned agents) as a child session: `subagent_update` on the parent
|
||||
//! announces it and reports its state, its own updates carry its session id, and
|
||||
//! `session_message`s carry what it was asked and what it answered. The parent's
|
||||
//! timeline shows a card per subagent; each subagent keeps its own timeline.
|
||||
|
||||
use crate::model::AgentEntry;
|
||||
use crate::timeline::{Timeline, blocks_text, content_text};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where an entry lives: the task's own conversation (`None`) or a subagent's session.
|
||||
pub(crate) type Session = Option<String>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Conversation {
|
||||
pub main: Timeline,
|
||||
/// In the order they started.
|
||||
subagents: Vec<Subagent>,
|
||||
}
|
||||
|
||||
struct Subagent {
|
||||
id: String,
|
||||
/// The session that started it.
|
||||
parent: Session,
|
||||
timeline: Timeline,
|
||||
}
|
||||
|
||||
/// A subagent's conversation, as saved with its task.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedSubagent {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub entries: Vec<AgentEntry>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
pub fn restore(main: Vec<AgentEntry>, subagents: Vec<SavedSubagent>) -> Self {
|
||||
Self {
|
||||
main: Timeline::from_entries(main),
|
||||
subagents: subagents
|
||||
.into_iter()
|
||||
.map(|s| Subagent { id: s.id, parent: s.parent, timeline: Timeline::from_entries(s.entries) })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn saved_subagents(&self) -> Vec<SavedSubagent> {
|
||||
self.subagents
|
||||
.iter()
|
||||
.map(|s| SavedSubagent { id: s.id.clone(), parent: s.parent.clone(), entries: s.timeline.entries.clone() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn timeline(&self, session: &Session) -> Option<&Timeline> {
|
||||
match session {
|
||||
None => Some(&self.main),
|
||||
Some(id) => self.subagents.iter().find(|s| &s.id == id).map(|s| &s.timeline),
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_mut(&mut self, session: &Session) -> Option<&mut Timeline> {
|
||||
match session {
|
||||
None => Some(&mut self.main),
|
||||
Some(id) => self.subagents.iter_mut().find(|s| &s.id == id).map(|s| &mut s.timeline),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(&self, id: &str) -> Option<&Session> {
|
||||
self.subagents.iter().find(|s| s.id == id).map(|s| &s.parent)
|
||||
}
|
||||
|
||||
/// The session an update for session id `session` belongs to: a known subagent's, else the task's.
|
||||
pub fn owner(&self, session: Option<&str>) -> Session {
|
||||
session.filter(|s| self.parent_of(s).is_some()).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The parent's title (or description) for subagent `id`.
|
||||
pub fn label(&self, id: &str) -> Option<String> {
|
||||
let parent = self.parent_of(id)?;
|
||||
self.timeline(parent)?.entries.iter().find_map(|e| match e {
|
||||
AgentEntry::Subagent { id: card, title, description, .. } if card == id => {
|
||||
title.clone().or_else(|| description.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies one `session/update` of session id `session` (the task's own when unknown);
|
||||
/// returns the entries it added or changed.
|
||||
pub fn apply(&mut self, session: Option<&str>, update: &Value, now: f64) -> Vec<(Session, usize)> {
|
||||
let owner = self.owner(session);
|
||||
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
match kind {
|
||||
"subagent_update" => {
|
||||
let Some(id) = update.get("sessionId").and_then(Value::as_str) else { return out };
|
||||
if self.parent_of(id).is_none() {
|
||||
self.subagents.push(Subagent { id: id.to_owned(), parent: owner, timeline: Timeline::default() });
|
||||
}
|
||||
let parent = self.parent_of(id).cloned().flatten();
|
||||
let Some(timeline) = self.timeline_mut(&parent) else { return out };
|
||||
let (index, stopped) = timeline.subagent(id, update, now);
|
||||
out.push((parent, index));
|
||||
if stopped && let Some(child) = self.timeline_mut(&Some(id.to_owned())) {
|
||||
let id = Some(id.to_owned());
|
||||
out.extend(child.end_turn(None).into_iter().map(|i| (id.clone(), i)));
|
||||
}
|
||||
}
|
||||
"session_message" | "session_message_chunk" => {
|
||||
let append = kind == "session_message_chunk";
|
||||
let text = if append {
|
||||
content_text(update.get("content")).unwrap_or_default()
|
||||
} else {
|
||||
blocks_text(update.get("content"))
|
||||
};
|
||||
let field = |k: &str| update.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
let (sender, recipient) = (field("senderSessionId"), field("recipientSessionId"));
|
||||
let ours =
|
||||
|s: &Option<String>| s.as_deref().filter(|s| self.parent_of(s) == Some(&owner)).map(str::to_owned);
|
||||
// Between this session and a subagent of it: the subagent's task or answer, on its card.
|
||||
let card = ours(&recipient).map(|c| (c, false)).or_else(|| ours(&sender).map(|c| (c, true)));
|
||||
let Some(timeline) = self.timeline_mut(&owner) else { return out };
|
||||
let index = match card {
|
||||
Some((child, reply)) => timeline.card_message(&child, reply, &text, append),
|
||||
None => {
|
||||
let id = field("messageId").unwrap_or_default();
|
||||
Some(timeline.message(&id, sender != owner, &text, append))
|
||||
}
|
||||
};
|
||||
out.extend(index.map(|i| (owner, i)));
|
||||
}
|
||||
_ => {
|
||||
let Some(timeline) = self.timeline_mut(&owner) else { return out };
|
||||
out.extend(timeline.apply(update).changed.into_iter().map(|i| (owner.clone(), i)));
|
||||
if let Some(id) = owner.as_deref()
|
||||
&& matches!(kind, "tool_call" | "tool_call_update")
|
||||
{
|
||||
out.extend(self.refresh_card(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Updates subagent `id`'s card from its timeline.
|
||||
fn refresh_card(&mut self, id: &str) -> Option<(Session, usize)> {
|
||||
let parent = self.parent_of(id)?.clone();
|
||||
let child = self.subagents.iter().position(|s| s.id == id)?;
|
||||
let timeline = std::mem::take(&mut self.subagents[child].timeline);
|
||||
let changed = self.timeline_mut(&parent).and_then(|p| p.card_activity(id, &timeline));
|
||||
self.subagents[child].timeline = timeline;
|
||||
changed.map(|i| (parent, i))
|
||||
}
|
||||
|
||||
/// Settles what an ended turn of the task left open. Subagents work on (in the background).
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<(Session, usize)> {
|
||||
self.main.end_turn(done).into_iter().map(|i| (None, i)).collect()
|
||||
}
|
||||
|
||||
/// Settles everything left open once the agent is gone, subagents included.
|
||||
pub fn settle(&mut self, now: f64) -> Vec<(Session, usize)> {
|
||||
let mut out: Vec<(Session, usize)> = self.main.end_turn(None).into_iter().map(|i| (None, i)).collect();
|
||||
out.extend(self.main.stop_subagents(now).into_iter().map(|i| (None, i)));
|
||||
for s in &mut self.subagents {
|
||||
let id = Some(s.id.clone());
|
||||
let mut changed = s.timeline.end_turn(None);
|
||||
changed.extend(s.timeline.stop_subagents(now));
|
||||
out.extend(changed.into_iter().map(|i| (id.clone(), i)));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{AgentSubagentStatus, AgentToolStatus};
|
||||
use serde_json::json;
|
||||
|
||||
fn card(c: &Conversation, session: &Session, index: usize) -> AgentEntry {
|
||||
c.timeline(session).unwrap().entries[index].clone()
|
||||
}
|
||||
|
||||
/// Claude Code's Agent tool, as `claude-agent-acp` reports it to a client with subagent sessions.
|
||||
#[test]
|
||||
fn a_subagent_gets_a_card_in_its_parent_and_its_own_timeline() {
|
||||
let mut c = Conversation::default();
|
||||
let sub = Some("child".to_owned());
|
||||
let announce = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "title": "Explore",
|
||||
"description": "Find the parser", "capabilities": {"cancel": {}}, "state": {"state": "running"}});
|
||||
assert_eq!(c.apply(Some("root"), &announce, 10.0), vec![(None, 0)]);
|
||||
// The launch prompt: the parent's outgoing view, then the child's incoming one.
|
||||
let prompt = json!({"sessionUpdate": "session_message", "messageId": "toolu_1",
|
||||
"senderSessionId": "root", "recipientSessionId": "child",
|
||||
"content": [{"type": "text", "text": "Look for it"}]});
|
||||
assert_eq!(c.apply(Some("root"), &prompt, 11.0), vec![(None, 0)]);
|
||||
assert_eq!(c.apply(Some("child"), &prompt, 11.0), vec![(sub.clone(), 0)]);
|
||||
assert_eq!(card(&c, &sub, 0), AgentEntry::Message { incoming: true, text: "Look for it".into() });
|
||||
|
||||
// The child's own work stays out of the parent's timeline; its card follows it.
|
||||
c.apply(
|
||||
Some("child"),
|
||||
&json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hm"}}),
|
||||
12.0,
|
||||
);
|
||||
let changed = c.apply(
|
||||
Some("child"),
|
||||
&json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Read lib.rs", "kind": "read", "status": "in_progress"}),
|
||||
12.0,
|
||||
);
|
||||
assert_eq!(changed, vec![(sub.clone(), 2), (None, 0)]);
|
||||
assert_eq!(c.main.entries.len(), 1);
|
||||
// Settings of the child session don't touch the task.
|
||||
assert_eq!(c.owner(Some("child")), sub);
|
||||
assert_eq!(c.owner(Some("root")), None);
|
||||
|
||||
let done = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "state": {"state": "idle", "stopReason": "end_turn"}});
|
||||
let changed = c.apply(Some("root"), &done, 20.0);
|
||||
// Its tool calls left open are settled when it stops.
|
||||
assert_eq!(changed, vec![(None, 0), (sub.clone(), 2)]);
|
||||
let reply = json!({"sessionUpdate": "session_message", "messageId": "toolu_1:reply", "senderSessionId": "child",
|
||||
"recipientSessionId": "root", "content": [{"type": "text", "text": "In "}, {"type": "text", "text": "parser.rs"}]});
|
||||
c.apply(Some("root"), &reply, 20.0);
|
||||
assert_eq!(
|
||||
c.main.entries[0],
|
||||
AgentEntry::Subagent {
|
||||
id: "child".into(),
|
||||
title: Some("Explore".into()),
|
||||
description: Some("Find the parser".into()),
|
||||
status: AgentSubagentStatus::Done,
|
||||
error: None,
|
||||
prompt: Some("Look for it".into()),
|
||||
reply: Some("In \nparser.rs".into()),
|
||||
tools: 1,
|
||||
activity: Some("Read lib.rs".into()),
|
||||
started_ms: 10.0,
|
||||
ended_ms: Some(20.0),
|
||||
cancellable: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(c.label("child").as_deref(), Some("Explore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subagents_and_failures() {
|
||||
let mut c = Conversation::default();
|
||||
c.apply(
|
||||
None,
|
||||
&json!({"sessionUpdate": "subagent_update", "sessionId": "a", "state": {"state": "running"}}),
|
||||
1.0,
|
||||
);
|
||||
c.apply(
|
||||
Some("a"),
|
||||
&json!({"sessionUpdate": "subagent_update", "sessionId": "b", "description": "deeper"}),
|
||||
2.0,
|
||||
);
|
||||
let a = Some("a".to_owned());
|
||||
assert!(matches!(card(&c, &a, 0), AgentEntry::Subagent { ref id, .. } if id == "b"));
|
||||
let failed = json!({"sessionUpdate": "subagent_update", "sessionId": "b",
|
||||
"state": {"state": "idle", "stopReason": "error", "error": {"code": -32603, "message": "boom"}}});
|
||||
assert_eq!(c.apply(Some("a"), &failed, 3.0), vec![(a.clone(), 0)]);
|
||||
assert!(matches!(card(&c, &a, 0),
|
||||
AgentEntry::Subagent { status: AgentSubagentStatus::Failed, error: Some(ref e), ended_ms: Some(3.0), .. } if e == "boom"));
|
||||
|
||||
// Saved and restored, then the agent is gone: what still works is stopped.
|
||||
let mut c = Conversation::restore(c.main.entries.clone(), c.saved_subagents());
|
||||
c.apply(
|
||||
Some("a"),
|
||||
&json!({"sessionUpdate": "tool_call", "toolCallId": "t", "title": "Run", "status": "in_progress"}),
|
||||
4.0,
|
||||
);
|
||||
let settled = c.settle(9.0);
|
||||
assert_eq!(settled, vec![(None, 0), (a.clone(), 1)]);
|
||||
assert!(matches!(c.main.entries[0], AgentEntry::Subagent { status: AgentSubagentStatus::Cancelled, .. }));
|
||||
assert!(matches!(card(&c, &a, 1), AgentEntry::Tool { status: AgentToolStatus::Failed, .. }));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
||||
//! IPC types: tasks, their timeline entries and the events streamed to the UI.
|
||||
|
||||
use ide_core::IpcPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentStatus {
|
||||
/// Creating the worktree / starting the agent.
|
||||
Starting,
|
||||
Running,
|
||||
/// Waiting for a permission answer.
|
||||
NeedsInput,
|
||||
/// The turn ended; the result can be reviewed or the conversation continued.
|
||||
Ready,
|
||||
Failed,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Isolation {
|
||||
/// A new git worktree on its own branch.
|
||||
Worktree,
|
||||
/// The project's own checkout.
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTask {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// [`AgentProvider::id`].
|
||||
pub provider: String,
|
||||
/// Model the task runs on (its `model` setting), when known.
|
||||
pub model: Option<String>,
|
||||
pub status: AgentStatus,
|
||||
pub isolation: Isolation,
|
||||
/// Directory the agent works in (the worktree, or the project root).
|
||||
pub cwd: IpcPath,
|
||||
/// Branch of the task's worktree.
|
||||
pub branch: Option<String>,
|
||||
/// Branch (or revision) the worktree was created from.
|
||||
pub base: Option<String>,
|
||||
/// Commit the task's changes are compared against (worktree tasks; local ones use `HEAD`).
|
||||
pub base_commit: Option<String>,
|
||||
/// Why the task failed, when it did.
|
||||
pub error: Option<String>,
|
||||
pub created_ms: f64,
|
||||
pub updated_ms: f64,
|
||||
/// The session's settings (model, mode, thinking effort…) as last reported, with the
|
||||
/// user's choices applied.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands the agent offers.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
/// Context window use, when the agent reports it.
|
||||
pub usage: Option<AgentUsage>,
|
||||
/// Messages waiting for the current turn to end, oldest first.
|
||||
pub queue: Vec<AgentQueued>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigValue {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// How a setting is changed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentConfigVia {
|
||||
/// `session/set_config_option`.
|
||||
#[default]
|
||||
Option,
|
||||
/// `session/set_mode` (agents that report `modes` instead of a mode config option).
|
||||
Mode,
|
||||
/// `session/set_model` (agents that report `models` instead of a model config option).
|
||||
Model,
|
||||
}
|
||||
|
||||
/// A session setting: an ACP select config option, or the older `modes` / `models`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
|
||||
pub category: Option<String>,
|
||||
pub current: Option<String>,
|
||||
pub values: Vec<AgentConfigValue>,
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
/// Without the leading `/`.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentUsage {
|
||||
/// Tokens in the context window.
|
||||
pub used: u32,
|
||||
/// Size of the context window.
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentImage {
|
||||
pub mime_type: String,
|
||||
/// Base64, without a `data:` prefix.
|
||||
pub data: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A message to the agent: text, plus files to look at and images.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPrompt {
|
||||
pub text: String,
|
||||
pub files: Vec<IpcPath>,
|
||||
pub images: Vec<AgentImage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQueued {
|
||||
pub id: String,
|
||||
pub prompt: AgentPrompt,
|
||||
}
|
||||
|
||||
/// A setting chosen for a new task, by [`AgentConfigOption::id`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigChoice {
|
||||
pub id: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentToolStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentToolDiff {
|
||||
pub path: IpcPath,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// What a subagent is doing, as its parent reports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentSubagentStatus {
|
||||
Running,
|
||||
/// One of its requests (a permission) waits for an answer.
|
||||
NeedsInput,
|
||||
Done,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPlanItem {
|
||||
pub content: String,
|
||||
/// `pending`, `in_progress` or `completed`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPermissionOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `allow_once`, `allow_always`, `reject_once` or `reject_always`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// One item of a task's conversation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum AgentEntry {
|
||||
User {
|
||||
text: String,
|
||||
/// Names of the files and images sent with it.
|
||||
attachments: Vec<String>,
|
||||
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
|
||||
#[serde(default)]
|
||||
sent_ms: Option<f64>,
|
||||
/// When the turn it started ended; `None` while it runs or when it was cut off.
|
||||
#[serde(default)]
|
||||
done_ms: Option<f64>,
|
||||
},
|
||||
Agent {
|
||||
text: String,
|
||||
},
|
||||
Thought {
|
||||
text: String,
|
||||
},
|
||||
Tool {
|
||||
id: String,
|
||||
title: String,
|
||||
/// ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`…
|
||||
kind: String,
|
||||
status: AgentToolStatus,
|
||||
locations: Vec<IpcPath>,
|
||||
/// Text the tool reported (command output…), truncated.
|
||||
output: Option<String>,
|
||||
diffs: Vec<AgentToolDiff>,
|
||||
},
|
||||
Plan {
|
||||
items: Vec<AgentPlanItem>,
|
||||
},
|
||||
/// A subagent the agent started: its own session, whose entries are listed separately.
|
||||
Subagent {
|
||||
/// The subagent's session id.
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
status: AgentSubagentStatus,
|
||||
error: Option<String>,
|
||||
/// What it was asked to do, last.
|
||||
prompt: Option<String>,
|
||||
/// Its answer.
|
||||
reply: Option<String>,
|
||||
/// Tool calls it made.
|
||||
tools: u32,
|
||||
/// Title of its latest tool call.
|
||||
activity: Option<String>,
|
||||
started_ms: f64,
|
||||
/// When it last stopped working; `None` while it works.
|
||||
ended_ms: Option<f64>,
|
||||
/// Whether it can be stopped on its own.
|
||||
cancellable: bool,
|
||||
},
|
||||
/// A message between sessions in a subagent's conversation: its task (`incoming`) or its report.
|
||||
Message {
|
||||
incoming: bool,
|
||||
text: String,
|
||||
},
|
||||
Permission {
|
||||
id: u32,
|
||||
title: String,
|
||||
options: Vec<AgentPermissionOption>,
|
||||
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
|
||||
chosen: Option<String>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The command line that starts its ACP server.
|
||||
pub command: String,
|
||||
/// Whether the command was found (on `PATH`).
|
||||
pub installed: bool,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands it offered last time.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAgentTask {
|
||||
pub prompt: AgentPrompt,
|
||||
pub provider: String,
|
||||
/// Settings for the new session (model, mode…).
|
||||
pub config: Vec<AgentConfigChoice>,
|
||||
pub isolation: Isolation,
|
||||
/// Branch to start the worktree from; the current branch when `None`.
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub kind: ide_vcs::ChangeKind,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum AgentEvent {
|
||||
/// A task was created or its metadata changed.
|
||||
Task {
|
||||
task: AgentTask,
|
||||
},
|
||||
Removed {
|
||||
id: String,
|
||||
},
|
||||
/// Entry `index` of `task` (of its subagent `subagent`) was added or replaced.
|
||||
Entry {
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined
|
||||
//! agents from `<config>/agents/<id>.toml`.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ProviderSpec {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// How to sign in when the agent reports it needs authentication.
|
||||
pub login_hint: String,
|
||||
}
|
||||
|
||||
/// `(id, name, command line, sign-in hint)`. npm adapters are pinned so a session's
|
||||
/// protocol does not change under the user.
|
||||
const BUILTIN: &[(&str, &str, &[&str], &str)] = &[
|
||||
(
|
||||
"claude",
|
||||
"Claude Code",
|
||||
&["npx", "-y", "@agentclientprotocol/[email protected]"],
|
||||
"Run `claude` in a terminal and sign in.",
|
||||
),
|
||||
("codex", "Codex", &["npx", "-y", "@agentclientprotocol/[email protected]"], "Run `codex login` in a terminal."),
|
||||
("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."),
|
||||
("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."),
|
||||
("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
|
||||
("opencode", "OpenCode", &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
|
||||
("qwen", "Qwen Code", &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
|
||||
("kimi", "Kimi CLI", &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
|
||||
("goose", "Goose", &["goose", "acp"], "Run `goose configure` in a terminal."),
|
||||
("auggie", "Auggie", &["auggie", "--acp"], "Run `auggie login` in a terminal."),
|
||||
("kiro", "Kiro", &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
|
||||
("vibe", "Mistral Vibe", &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
|
||||
];
|
||||
|
||||
fn builtins() -> Vec<ProviderSpec> {
|
||||
BUILTIN
|
||||
.iter()
|
||||
.map(|(id, name, cmd, hint)| ProviderSpec {
|
||||
id: (*id).into(),
|
||||
name: (*name).into(),
|
||||
command: cmd[0].into(),
|
||||
args: cmd[1..].iter().map(|a| (*a).to_owned()).collect(),
|
||||
env: Vec::new(),
|
||||
login_hint: (*hint).into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomAgent {
|
||||
name: Option<String>,
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
|
||||
/// built-in id replaces it.
|
||||
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
let mut specs = builtins();
|
||||
let mut files: Vec<PathBuf> = custom_dir
|
||||
.and_then(|d| std::fs::read_dir(d).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for path in files {
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let parsed = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| toml::from_str::<CustomAgent>(&t).map_err(|e| e.to_string()));
|
||||
let agent = match parsed {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("skipping agent {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut env: Vec<(String, String)> = agent.env.into_iter().collect();
|
||||
env.sort();
|
||||
let spec = ProviderSpec {
|
||||
name: agent.name.unwrap_or_else(|| id.clone()),
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
env,
|
||||
login_hint: "Sign in with the agent's own command line first.".into(),
|
||||
id,
|
||||
};
|
||||
match specs.iter_mut().find(|s| s.id == spec.id) {
|
||||
Some(existing) => *existing = spec,
|
||||
None => specs.push(spec),
|
||||
}
|
||||
}
|
||||
specs
|
||||
}
|
||||
|
||||
/// Whether `command` resolves to an executable (a path, or a name on `PATH`).
|
||||
pub(crate) fn installed(command: &str) -> bool {
|
||||
let p = Path::new(command);
|
||||
if p.components().count() > 1 {
|
||||
return is_executable(p);
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) { &["exe", "cmd", "bat"] } else { &[] };
|
||||
std::env::var_os("PATH").is_some_and(|path| {
|
||||
std::env::split_paths(&path).any(|dir| {
|
||||
let c = dir.join(command);
|
||||
is_executable(&c) || exts.iter().any(|e| is_executable(&c.with_extension(e)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
p.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_agents_extend_and_override_the_builtins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let write = |name: &str, text: &str| std::fs::write(dir.path().join(name), text).unwrap();
|
||||
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
|
||||
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
|
||||
write("broken.toml", "nope = 1\n");
|
||||
let specs = load(Some(dir.path()));
|
||||
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
let gemini = specs.iter().find(|s| s.id == "gemini").unwrap();
|
||||
assert_eq!((gemini.name.as_str(), gemini.args.as_slice()), ("gemini", &["--acp".to_owned()][..]));
|
||||
assert!(!specs.iter().any(|s| s.id == "broken"));
|
||||
assert_eq!(specs.iter().filter(|s| s.id == "claude").count(), 1);
|
||||
assert!(!installed("/definitely/not/here"));
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Claude Code's slash commands on disk, for completion before (or without) a session:
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::AgentCommand;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
const MAX_DESCRIPTION: usize = 200;
|
||||
/// Command directories nest this deep at most (`a/b/c.md` → `a:b:c`).
|
||||
const MAX_DEPTH: usize = 4;
|
||||
|
||||
/// The commands Claude Code finds from `cwd`: nearest directory first, the user's last;
|
||||
/// the first of a name wins.
|
||||
pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut add = |found: Vec<AgentCommand>| {
|
||||
for c in found {
|
||||
if seen.insert(c.name.clone()) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
for dir in cwd.ancestors() {
|
||||
if home.is_some_and(|h| h == dir) {
|
||||
break;
|
||||
}
|
||||
add(in_dir(&dir.join(".claude")));
|
||||
}
|
||||
if let Some(home) = home {
|
||||
add(in_dir(&home.join(".claude")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
out
|
||||
}
|
||||
|
||||
fn in_dir(claude: &Path) -> Vec<AgentCommand> {
|
||||
if !claude.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = skills(&claude.join("skills"));
|
||||
commands(&claude.join("commands"), "", 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
let mut out: Vec<AgentCommand> = sorted(dir)
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let text = std::fs::read_to_string(path.join("SKILL.md")).ok()?;
|
||||
let meta = Frontmatter::parse(&text);
|
||||
if meta.get("user-invocable").is_some_and(|v| v.eq_ignore_ascii_case("false")) {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for path in sorted(dir) {
|
||||
let Some(name) = file_name(&path) else { continue };
|
||||
if path.is_dir() {
|
||||
commands(&path, &format!("{prefix}{name}:"), depth + 1, out);
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| file_name(p).is_some_and(|n| !n.starts_with('.')))
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> Option<String> {
|
||||
path.file_name()?.to_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The `key: value` lines between the leading `---` fences (folded `>` / `|` values joined).
|
||||
struct Frontmatter {
|
||||
fields: Vec<(String, String)>,
|
||||
/// Where the body starts.
|
||||
body: usize,
|
||||
}
|
||||
|
||||
impl Frontmatter {
|
||||
fn parse(text: &str) -> Self {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let mut lines = text.split_inclusive('\n');
|
||||
let mut offset = 0;
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
match lines.next() {
|
||||
Some(first) if first.trim_end() == "---" => offset += first.len(),
|
||||
_ => return Self { fields, body: 0 },
|
||||
}
|
||||
for line in lines {
|
||||
offset += line.len();
|
||||
let line = line.trim_end();
|
||||
if line == "---" {
|
||||
return Self { fields, body: offset };
|
||||
}
|
||||
if line.starts_with([' ', '\t']) {
|
||||
if let Some((_, value)) = fields.last_mut() {
|
||||
if !value.is_empty() {
|
||||
value.push(' ');
|
||||
}
|
||||
value.push_str(line.trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let value = value.trim();
|
||||
let value = if matches!(value, ">" | "|" | ">-" | "|-") { "" } else { unquote(value) };
|
||||
fields.push((key.trim().to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
// No closing fence: not frontmatter.
|
||||
Self { fields: Vec::new(), body: 0 }
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<&str> {
|
||||
self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
.map(|l| l.trim().trim_start_matches('#').trim())
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(value: &str) -> &str {
|
||||
for q in ['"', '\''] {
|
||||
if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn truncate(s: &str) -> String {
|
||||
match s.char_indices().nth(MAX_DESCRIPTION) {
|
||||
Some((i, _)) => format!("{}…", s[..i].trim_end()),
|
||||
None => s.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn names(cmds: &[AgentCommand]) -> Vec<&str> {
|
||||
cmds.iter().map(|c| c.name.as_str()).collect()
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
&skills.join("triage/SKILL.md"),
|
||||
"---\nname: triage\ndescription: One pass over open issues and PRs\n---\n# Triage\n",
|
||||
);
|
||||
write(&skills.join("issue-fix/SKILL.md"), "---\nname: issue-fix\ndescription: \"Fix an issue\"\n---\n");
|
||||
// No frontmatter name: the directory's.
|
||||
write(&skills.join("verify/SKILL.md"), "---\ndescription: >\n Build and\n drive the app\n---\n");
|
||||
write(&skills.join("hidden/SKILL.md"), "---\nname: hidden\nuser-invocable: false\n---\n");
|
||||
write(&skills.join("no-skill-file/README.md"), "nothing");
|
||||
write(&repo.join(".claude/commands/review.md"), "---\nargument-hint: <pr>\n---\nReview a pull request\n");
|
||||
write(&repo.join(".claude/commands/git/commit.md"), "# Commit the staged changes\n");
|
||||
// A parent directory's commands apply too.
|
||||
write(&tmp.path().join("work/.claude/commands/parent.md"), "---\ndescription: from above\n---\n");
|
||||
// The user's: shadowed by the project's skill of the same name.
|
||||
write(&home.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: user triage\n---\n");
|
||||
write(&home.join(".claude/skills/lint/SKILL.md"), "---\nname: lint\ndescription: user lint\n---\n");
|
||||
write(&home.join(".claude/commands/brooks-review.md"), "---\ndescription: Review\n---\n");
|
||||
|
||||
let found = discover(&repo, Some(&home));
|
||||
assert_eq!(
|
||||
names(&found),
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
assert_eq!(by("git:commit").description, "Commit the staged changes");
|
||||
assert_eq!(
|
||||
(by("review").description.as_str(), by("review").hint.as_deref()),
|
||||
("Review a pull request", Some("<pr>"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_without_claude_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
let repo = home.join("plain");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
assert!(discover(&repo, Some(&home)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walking_up_stops_at_home() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
// Above home: not the user's, and not this project's.
|
||||
write(&tmp.path().join(".claude/commands/outside.md"), "x\n");
|
||||
write(&home.join(".claude/commands/mine.md"), "x\n");
|
||||
let repo = home.join("src/app");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
assert_eq!(names(&discover(&repo, Some(&home))), ["mine"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_edge_cases() {
|
||||
let f = Frontmatter::parse("no fence\n");
|
||||
assert!(f.fields.is_empty());
|
||||
let f = Frontmatter::parse("---\nname: x\nunterminated\n");
|
||||
assert!(f.get("name").is_none());
|
||||
let f = Frontmatter::parse("\u{feff}---\r\nname: 'quoted'\r\n---\r\nbody\r\n");
|
||||
assert_eq!(f.get("name"), Some("quoted"));
|
||||
assert_eq!(truncate(&"é".repeat(MAX_DESCRIPTION + 5)).chars().count(), MAX_DESCRIPTION + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! End-to-end task flow against an in-memory ACP agent.
|
||||
|
||||
use super::*;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::io::duplex;
|
||||
|
||||
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
|
||||
#[derive(Default)]
|
||||
struct FakeAgent {
|
||||
peer: OnceLock<Peer>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
fn update(&self, session: &str, update: Value) {
|
||||
let peer = self.peer.get().unwrap();
|
||||
peer.notify("session/update", json!({ "sessionId": session, "update": update })).unwrap();
|
||||
}
|
||||
|
||||
fn say(&self, session: &str, text: &str) {
|
||||
self.update(
|
||||
session,
|
||||
json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
/// Runs a subagent in session `c1`, as an agent with ACP subagent sessions reports it.
|
||||
fn delegate(&self, root: &str) {
|
||||
let sub = |state: Value| json!({"sessionUpdate": "subagent_update", "sessionId": "c1", "state": state});
|
||||
let mut announce = sub(json!({"state": "running"}));
|
||||
announce["title"] = json!("Explore");
|
||||
self.update(root, announce);
|
||||
self.update(
|
||||
root,
|
||||
json!({"sessionUpdate": "session_message", "messageId": "m1", "senderSessionId": root,
|
||||
"recipientSessionId": "c1", "content": [{"type": "text", "text": "Find the lexer"}]}),
|
||||
);
|
||||
self.update("c1", json!({"sessionUpdate": "usage_update", "used": 900, "size": 1000}));
|
||||
self.update(
|
||||
"c1",
|
||||
json!({"sessionUpdate": "tool_call", "toolCallId": "c1-t1", "title": "Read lexer.rs",
|
||||
"kind": "read", "status": "completed"}),
|
||||
);
|
||||
let peer = self.peer.get().unwrap().clone();
|
||||
let _: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": "c1", "toolCall": {"toolCallId": "c1-t2", "title": "Run ls"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
self.say("c1", "It is in lexer.rs");
|
||||
self.update(root, sub(json!({"state": "idle", "stopReason": "end_turn"})));
|
||||
self.update(
|
||||
root,
|
||||
json!({"sessionUpdate": "session_message", "messageId": "m2", "senderSessionId": "c1",
|
||||
"recipientSessionId": root, "content": [{"type": "text", "text": "lexer.rs"}]}),
|
||||
);
|
||||
self.say(root, "Found it");
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for FakeAgent {
|
||||
fn on_notification(&self, method: &str, _: Value) {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
"initialize" => {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}}))
|
||||
}
|
||||
"session/new" => Ok(json!({"sessionId": "s1",
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
|
||||
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
|
||||
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})),
|
||||
"session/load" => {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
Ok(json!({}))
|
||||
}
|
||||
"session/prompt" => {
|
||||
let text = params.pointer("/prompt/0/text").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
if text == "again" {
|
||||
self.say(&session, "second turn");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "delegate" {
|
||||
self.delegate(&session);
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "wait" {
|
||||
// Works until cancelled.
|
||||
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
return Ok(json!({"stopReason": "cancelled"}));
|
||||
}
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "available_commands_update",
|
||||
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
|
||||
);
|
||||
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
|
||||
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
|
||||
self.say(&session, "Hi");
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Edit a.rs",
|
||||
"kind": "edit", "status": "completed",
|
||||
"content": [{"type": "diff", "path": "/p/a.rs", "oldText": "a\n", "newText": "b\n"}]}),
|
||||
);
|
||||
let peer = self.peer.get().unwrap().clone();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("chose {chosen}"));
|
||||
Ok(json!({"stopReason": "end_turn"}))
|
||||
}
|
||||
_ => Err((codes::METHOD_NOT_FOUND, method.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
manager_at(dir, &dir.join("models.json"), agent)
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
|
||||
// Keep the latest connection: a restarted manager connects again.
|
||||
let _ = agent.peer.set(server);
|
||||
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
|
||||
});
|
||||
AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: dir.to_path_buf(),
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (a, b, models) = (tmp.path().join("a"), tmp.path().join("b"), tmp.path().join("models.json"));
|
||||
std::fs::create_dir_all(a.join(".claude/skills/triage")).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n").unwrap();
|
||||
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
|
||||
|
||||
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
|
||||
// Before any session: the skills on disk.
|
||||
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
|
||||
let task = mgr_a
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Fix the lexer"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr_a, &task.id, AgentStatus::NeedsInput).await;
|
||||
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["review", "triage"]);
|
||||
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
|
||||
// Other agents get only what they offered.
|
||||
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
|
||||
mgr_a.shutdown();
|
||||
|
||||
// Another project shares the settings but not the commands.
|
||||
let mgr_b = manager_at(&b, &models, Arc::new(FakeAgent::default()));
|
||||
let claude = mgr_b.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert_eq!(claude.config.len(), 2);
|
||||
assert!(claude.commands.is_empty());
|
||||
assert!(mgr_b.commands("claude", None).await.unwrap().is_empty());
|
||||
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
|
||||
}
|
||||
|
||||
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
|
||||
for _ in 0..500 {
|
||||
if mgr.task(id).unwrap().state.lock().task.status == status {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("task never reached {status:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
|
||||
let sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
|
||||
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Fix the lexer\nmore detail"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(task.title, "Fix the lexer");
|
||||
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "a new task is not settled");
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
|
||||
let permission = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let kinds: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
AgentEntry::User { text, .. } | AgentEntry::Agent { text } => text.clone(),
|
||||
AgentEntry::Tool { title, .. } => title.clone(),
|
||||
AgentEntry::Permission { chosen, .. } => format!("permission: {}", chosen.clone().unwrap_or_default()),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["Fix the lexer\nmore detail", "Hi", "Edit a.rs", "permission: Allow", "chose yes"]);
|
||||
assert!(
|
||||
agent.calls.lock().contains(&"session/set_config_option \"small\"".to_owned()),
|
||||
"switched to the chosen model"
|
||||
);
|
||||
{
|
||||
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
|
||||
assert_eq!(t.model.as_deref(), Some("small"));
|
||||
let modes = t.config.iter().find(|o| o.id == "mode").unwrap();
|
||||
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
|
||||
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
|
||||
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
|
||||
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
|
||||
}
|
||||
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
|
||||
assert!(agent.calls.lock().contains(&"session/set_mode \"code\"".to_owned()));
|
||||
assert!(mgr.set_config(&task.id, "mode", "nope".into()).await.is_err());
|
||||
|
||||
tokio::time::sleep(FLUSH_DELAY * 3).await;
|
||||
{
|
||||
let events = events.lock();
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
|
||||
);
|
||||
let last_entry = events.iter().rev().find_map(|e| match e {
|
||||
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
|
||||
Some((*index, entry.clone()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() })));
|
||||
// The turn's end is recorded on the message that started it.
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
AgentEvent::Entry { index: 0, entry: AgentEntry::User { sent_ms: Some(_), done_ms: Some(_), .. }, .. }
|
||||
)));
|
||||
}
|
||||
let providers = mgr.providers();
|
||||
let claude = providers.iter().find(|p| p.id == "claude").unwrap();
|
||||
assert_eq!(claude.config.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["model", "mode"]);
|
||||
assert_eq!(claude.commands.len(), 1);
|
||||
|
||||
// A new manager (IDE restart) finds the task and resumes its session without duplicating history.
|
||||
mgr.shutdown();
|
||||
let agent2 = Arc::new(FakeAgent::default());
|
||||
let mgr2 = manager(tmp.path(), agent2.clone());
|
||||
assert_eq!(mgr2.entries(&task.id, None).unwrap().len(), 5);
|
||||
assert_eq!(mgr2.task(&task.id).unwrap().state.lock().task.title, "Lexer fix");
|
||||
assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2);
|
||||
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr2.entries(&task.id, None).unwrap();
|
||||
assert_eq!(entries.len(), 7);
|
||||
assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() });
|
||||
assert!(agent2.calls.lock().contains(&"session/load".to_owned()));
|
||||
// The chosen settings are re-applied to the reloaded session.
|
||||
let calls = agent2.calls.lock().clone();
|
||||
assert!(calls.contains(&"session/set_mode \"code\"".to_owned()), "{calls:?}");
|
||||
}
|
||||
|
||||
fn text(t: &str) -> AgentPrompt {
|
||||
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("wait"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
let queue = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
|
||||
assert_eq!(queue.iter().map(|q| q.prompt.text.as_str()).collect::<Vec<_>>(), ["again"]);
|
||||
assert!(mgr.prompt(&task.id, text(" "), false).await.is_err());
|
||||
|
||||
mgr.send_queued_now(&task.id, &queue[0].id).unwrap();
|
||||
for _ in 0..500 {
|
||||
if mgr.entries(&task.id, None).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let users: Vec<String> = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
AgentEntry::User { text, .. } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(users, ["wait", "again"]);
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
|
||||
// Stopping a turn keeps the queue for later.
|
||||
mgr.prompt(&task.id, text("wait"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
agent.calls.lock().retain(|c| c != "session/cancel");
|
||||
mgr.prompt(&task.id, text("later"), false).await.unwrap();
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
|
||||
assert_eq!(queued.len(), 1);
|
||||
assert_eq!(mgr.unqueue(&task.id, &queued[0].id).unwrap().prompt.text, "later");
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn subagents_get_their_own_conversation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("delegate"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// The subagent's permission request is answered in the task's conversation.
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let (id, title) = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, title, .. } => Some((id, title)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(title, "Explore: Run ls");
|
||||
mgr.respond(&task.id, id, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c == "subagents"));
|
||||
|
||||
let check = |mgr: &AgentManager| {
|
||||
let main = mgr.entries(&task.id, None).unwrap();
|
||||
let card = main.iter().find(|e| matches!(e, AgentEntry::Subagent { .. })).unwrap();
|
||||
let AgentEntry::Subagent { title, status, prompt, reply, tools, activity, .. } = card else { unreachable!() };
|
||||
assert_eq!(title.as_deref(), Some("Explore"));
|
||||
assert_eq!(*status, AgentSubagentStatus::Done);
|
||||
assert_eq!((prompt.as_deref(), reply.as_deref()), (Some("Find the lexer"), Some("lexer.rs")));
|
||||
assert_eq!((*tools, activity.as_deref()), (1, Some("Read lexer.rs")));
|
||||
assert!(main.contains(&AgentEntry::Agent { text: "Found it".into() }));
|
||||
assert!(!main.iter().any(|e| matches!(e, AgentEntry::Tool { .. })));
|
||||
let sub = mgr.entries(&task.id, Some("c1".into())).unwrap();
|
||||
assert!(matches!(&sub[0], AgentEntry::Tool { title, .. } if title == "Read lexer.rs"));
|
||||
assert_eq!(sub[1], AgentEntry::Agent { text: "It is in lexer.rs".into() });
|
||||
};
|
||||
// Updates can trail the end of the turn.
|
||||
for _ in 0..500 {
|
||||
if mgr.entries(&task.id, None).unwrap().contains(&AgentEntry::Agent { text: "Found it".into() }) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
check(&mgr);
|
||||
// The subagent's context use is not the task's.
|
||||
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.usage.as_ref().map(|u| u.used), Some(900));
|
||||
assert!(mgr.entries(&task.id, Some("nope".into())).is_err());
|
||||
|
||||
// Saved with the task.
|
||||
mgr.shutdown();
|
||||
check(&manager(tmp.path(), Arc::new(FakeAgent::default())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_become_acp_content_blocks() {
|
||||
let p = AgentPrompt {
|
||||
text: "look".into(),
|
||||
files: vec![IpcPath(PathBuf::from("/p/my file.rs"))],
|
||||
images: vec![AgentImage { mime_type: "image/png".into(), data: "AAA".into(), name: None }],
|
||||
};
|
||||
assert_eq!(
|
||||
blocks(&p, &[]),
|
||||
[
|
||||
json!({"type": "text", "text": "look"}),
|
||||
json!({"type": "resource_link", "uri": "file:///p/my%20file.rs", "name": "my file.rs"}),
|
||||
json!({"type": "image", "mimeType": "image/png", "data": "AAA"}),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
blocks(&p, &[Some("fn a() {}".into())])[1],
|
||||
json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}})
|
||||
);
|
||||
// A slash command goes through as typed: the agent runs it.
|
||||
let skill = clean(text(" /triage dry-run\n")).unwrap();
|
||||
assert_eq!(blocks(&skill, &[]), [json!({"type": "text", "text": "/triage dry-run"})]);
|
||||
let old = upgrade(
|
||||
json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}),
|
||||
);
|
||||
assert_eq!(old["task"]["queue"], json!([]));
|
||||
assert_eq!(old["entries"][0]["attachments"], json!([]));
|
||||
assert!(old["entries"][1].get("attachments").is_none());
|
||||
assert_eq!(title_of(&AgentPrompt { files: p.files.clone(), ..AgentPrompt::default() }), "my file.rs");
|
||||
assert!(clean(text(" ")).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("do it"),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")));
|
||||
assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() }));
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default");
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(mgr.entries(&task.id, None).is_err());
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
assert!(
|
||||
mgr.create(NewAgentTask {
|
||||
prompt: text(" "),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives the real Claude Code adapter (needs `npx`, network and a signed-in `claude`):
|
||||
/// `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored --nocapture`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn real_claude_task_in_a_worktree() {
|
||||
if std::env::var_os("OPENIDEA_AGENT_E2E").is_none() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
for args in [&["init", "-q", "-b", "main"][..], &["commit", "-q", "--allow-empty", "-m", "init"]] {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok);
|
||||
}
|
||||
let mgr = AgentManager::new(
|
||||
AgentPaths {
|
||||
project_root: repo.clone(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("wt"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
|
||||
isolation: Isolation::Worktree,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(240);
|
||||
loop {
|
||||
assert!(std::time::Instant::now() < deadline, "timed out");
|
||||
let status = mgr.task(&task.id).unwrap().state.lock().task.status;
|
||||
match status {
|
||||
AgentStatus::NeedsInput => {
|
||||
let pending = mgr.entries(&task.id, None).unwrap().into_iter().rev().find_map(|e| match e {
|
||||
AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((id, options)) = pending {
|
||||
let allow = options.iter().find(|o| o.kind == "allow_once").or(options.first()).unwrap();
|
||||
eprintln!("allowing: {}", allow.name);
|
||||
let _ = mgr.respond(&task.id, id, Some(allow.id.clone()));
|
||||
}
|
||||
}
|
||||
AgentStatus::Ready | AgentStatus::Failed => break,
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
for e in mgr.entries(&task.id, None).unwrap() {
|
||||
eprintln!("{e:?}");
|
||||
}
|
||||
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
|
||||
assert_eq!(t.status, AgentStatus::Ready, "{:?}", t.error);
|
||||
assert_eq!(t.model.as_deref(), Some("haiku"));
|
||||
let changes = mgr.changes(&task.id).await.unwrap();
|
||||
assert!(changes.iter().any(|c| c.rel_path == "hello.txt"), "{changes:?}");
|
||||
assert!(!repo.join("hello.txt").exists(), "the project checkout is untouched");
|
||||
assert!(!mgr.providers().iter().find(|p| p.id == "claude").unwrap().config.is_empty());
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Folds ACP `session/update` notifications into a task's entries.
|
||||
|
||||
use crate::model::{AgentEntry, AgentPlanItem, AgentSubagentStatus, AgentToolDiff, AgentToolStatus};
|
||||
use ide_core::IpcPath;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Tool output kept per call; longer output keeps its tail.
|
||||
const MAX_OUTPUT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Timeline {
|
||||
pub entries: Vec<AgentEntry>,
|
||||
tools: HashMap<String, usize>,
|
||||
plan: Option<usize>,
|
||||
/// Subagent cards, by the subagent's session id.
|
||||
cards: HashMap<String, usize>,
|
||||
/// Session messages, by message id.
|
||||
messages: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// What a `session/update` changed in the timeline.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct Applied {
|
||||
/// Indices of the entries added or changed.
|
||||
pub changed: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
pub fn from_entries(entries: Vec<AgentEntry>) -> Self {
|
||||
let mut t = Self { entries, ..Self::default() };
|
||||
for (i, e) in t.entries.iter().enumerate() {
|
||||
match e {
|
||||
AgentEntry::Tool { id, .. } => {
|
||||
t.tools.insert(id.clone(), i);
|
||||
}
|
||||
AgentEntry::Plan { .. } => t.plan = Some(i),
|
||||
AgentEntry::Subagent { id, .. } => {
|
||||
t.cards.insert(id.clone(), i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: AgentEntry) -> usize {
|
||||
self.entries.push(entry);
|
||||
self.entries.len() - 1
|
||||
}
|
||||
|
||||
/// Applies one `session/update` `update` object.
|
||||
pub fn apply(&mut self, update: &Value) -> Applied {
|
||||
let mut out = Applied::default();
|
||||
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
|
||||
match kind {
|
||||
"agent_message_chunk" | "agent_thought_chunk" => {
|
||||
let Some(text) = content_text(update.get("content")) else { return out };
|
||||
out.changed.push(self.append_text(kind == "agent_thought_chunk", &text));
|
||||
}
|
||||
"tool_call" => {
|
||||
let id = str_field(update, "toolCallId");
|
||||
let entry = AgentEntry::Tool {
|
||||
id: id.clone(),
|
||||
title: str_field(update, "title"),
|
||||
kind: update.get("kind").and_then(Value::as_str).unwrap_or("other").to_owned(),
|
||||
status: tool_status(update.get("status")).unwrap_or(AgentToolStatus::Pending),
|
||||
locations: locations(update.get("locations")),
|
||||
output: None,
|
||||
diffs: Vec::new(),
|
||||
};
|
||||
let index = match self.tools.get(&id) {
|
||||
Some(&i) => {
|
||||
self.entries[i] = entry;
|
||||
i
|
||||
}
|
||||
None => {
|
||||
let i = self.push(entry);
|
||||
self.tools.insert(id, i);
|
||||
i
|
||||
}
|
||||
};
|
||||
self.merge_tool(index, update);
|
||||
out.changed.push(index);
|
||||
}
|
||||
"tool_call_update" => {
|
||||
let Some(&index) = self.tools.get(&str_field(update, "toolCallId")) else { return out };
|
||||
self.merge_tool(index, update);
|
||||
out.changed.push(index);
|
||||
}
|
||||
"plan" => {
|
||||
let items = update
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|e| AgentPlanItem { content: str_field(e, "content"), status: str_field(e, "status") })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let entry = AgentEntry::Plan { items };
|
||||
let index = match self.plan {
|
||||
Some(i) => {
|
||||
self.entries[i] = entry;
|
||||
i
|
||||
}
|
||||
None => self.push(entry),
|
||||
};
|
||||
self.plan = Some(index);
|
||||
out.changed.push(index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn append_text(&mut self, thought: bool, text: &str) -> usize {
|
||||
match self.entries.last_mut() {
|
||||
Some(AgentEntry::Agent { text: t }) if !thought => t.push_str(text),
|
||||
Some(AgentEntry::Thought { text: t }) if thought => t.push_str(text),
|
||||
_ => {
|
||||
let text = text.to_owned();
|
||||
return self.push(if thought { AgentEntry::Thought { text } } else { AgentEntry::Agent { text } });
|
||||
}
|
||||
}
|
||||
self.entries.len() - 1
|
||||
}
|
||||
|
||||
fn merge_tool(&mut self, index: usize, update: &Value) {
|
||||
let AgentEntry::Tool { title, kind, status, locations: locs, output, diffs, .. } = &mut self.entries[index]
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(t) = update.get("title").and_then(Value::as_str) {
|
||||
*title = t.to_owned();
|
||||
}
|
||||
if let Some(k) = update.get("kind").and_then(Value::as_str) {
|
||||
*kind = k.to_owned();
|
||||
}
|
||||
if let Some(s) = tool_status(update.get("status")) {
|
||||
*status = s;
|
||||
}
|
||||
if update.get("locations").is_some_and(Value::is_array) {
|
||||
*locs = locations(update.get("locations"));
|
||||
}
|
||||
let Some(content) = update.get("content").and_then(Value::as_array) else { return };
|
||||
let mut text = String::new();
|
||||
let mut new_diffs = Vec::new();
|
||||
for item in content {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("content") => {
|
||||
if let Some(t) = content_text(item.get("content")) {
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&t);
|
||||
}
|
||||
}
|
||||
Some("diff") => {
|
||||
let old = item.get("oldText").and_then(Value::as_str).unwrap_or_default();
|
||||
let new = item.get("newText").and_then(Value::as_str).unwrap_or_default();
|
||||
let (added, removed) = line_stats(old, new);
|
||||
new_diffs.push(AgentToolDiff { path: IpcPath(str_field(item, "path").into()), added, removed });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !text.is_empty() {
|
||||
*output = Some(tail(text));
|
||||
}
|
||||
if !new_diffs.is_empty() {
|
||||
*diffs = new_diffs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or updates the card of subagent `id` from its `subagent_update`; returns the
|
||||
/// card's index and whether the subagent just stopped working.
|
||||
pub fn subagent(&mut self, id: &str, update: &Value, now: f64) -> (usize, bool) {
|
||||
let index = match self.cards.get(id) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = self.push(AgentEntry::Subagent {
|
||||
id: id.to_owned(),
|
||||
title: None,
|
||||
description: None,
|
||||
status: AgentSubagentStatus::Running,
|
||||
error: None,
|
||||
prompt: None,
|
||||
reply: None,
|
||||
tools: 0,
|
||||
activity: None,
|
||||
started_ms: now,
|
||||
ended_ms: None,
|
||||
cancellable: false,
|
||||
});
|
||||
self.cards.insert(id.to_owned(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
let AgentEntry::Subagent { title, description, status, error, ended_ms, cancellable, .. } =
|
||||
&mut self.entries[index]
|
||||
else {
|
||||
return (index, false);
|
||||
};
|
||||
// Patch semantics: an omitted field is unchanged, `null` clears it.
|
||||
if let Some(v) = update.get("title") {
|
||||
*title = v.as_str().map(str::to_owned);
|
||||
}
|
||||
if let Some(v) = update.get("description") {
|
||||
*description = v.as_str().map(str::to_owned);
|
||||
}
|
||||
if let Some(v) = update.get("capabilities") {
|
||||
*cancellable = v.get("cancel").is_some_and(Value::is_object);
|
||||
}
|
||||
let mut stopped = false;
|
||||
if let Some((next, message)) = update.get("state").and_then(subagent_status) {
|
||||
let working = matches!(next, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput);
|
||||
stopped = !working && ended_ms.is_none();
|
||||
*ended_ms = if working { None } else { ended_ms.or(Some(now)) };
|
||||
*status = next;
|
||||
*error = message;
|
||||
}
|
||||
(index, stopped)
|
||||
}
|
||||
|
||||
/// Sets (or with `append`, extends) what subagent `id` was asked or answered, on its card.
|
||||
pub fn card_message(&mut self, id: &str, reply: bool, text: &str, append: bool) -> Option<usize> {
|
||||
let &index = self.cards.get(id)?;
|
||||
let AgentEntry::Subagent { prompt, reply: answer, .. } = &mut self.entries[index] else { return None };
|
||||
let field = if reply { answer } else { prompt };
|
||||
match field {
|
||||
Some(t) if append => t.push_str(text),
|
||||
_ => *field = Some(text.to_owned()),
|
||||
}
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Adds, replaces or (with `append`) extends session message `id`.
|
||||
pub fn message(&mut self, id: &str, incoming: bool, text: &str, append: bool) -> usize {
|
||||
if let Some(&i) = self.messages.get(id)
|
||||
&& let AgentEntry::Message { text: t, .. } = &mut self.entries[i]
|
||||
{
|
||||
if append {
|
||||
t.push_str(text);
|
||||
} else {
|
||||
*t = text.to_owned();
|
||||
}
|
||||
return i;
|
||||
}
|
||||
let i = self.push(AgentEntry::Message { incoming, text: text.to_owned() });
|
||||
self.messages.insert(id.to_owned(), i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Updates subagent `id`'s card with what its own timeline `of` shows: how many tool
|
||||
/// calls it made and its latest one. Returns the card's index when it changed.
|
||||
pub fn card_activity(&mut self, id: &str, of: &Timeline) -> Option<usize> {
|
||||
let &index = self.cards.get(id)?;
|
||||
let AgentEntry::Subagent { tools, activity, .. } = &mut self.entries[index] else { return None };
|
||||
let calls = of.entries.iter().filter_map(|e| match e {
|
||||
AgentEntry::Tool { title, kind, .. } => Some(if title.is_empty() { kind } else { title }),
|
||||
_ => None,
|
||||
});
|
||||
let count = calls.clone().count() as u32;
|
||||
let latest = calls.clone().next_back().cloned();
|
||||
if *tools == count && *activity == latest {
|
||||
return None;
|
||||
}
|
||||
*tools = count;
|
||||
*activity = latest;
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Marks the subagents still working as stopped (their agent is gone).
|
||||
pub fn stop_subagents(&mut self, now: f64) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
if let AgentEntry::Subagent { status, ended_ms, .. } = e
|
||||
&& matches!(status, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput)
|
||||
{
|
||||
*status = AgentSubagentStatus::Cancelled;
|
||||
*ended_ms = Some(now);
|
||||
changed.push(i);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Settles what an ended turn left open: running tools and pending permissions,
|
||||
/// and records when the turn ended (`done`) on the message that started it.
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
match e {
|
||||
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
|
||||
*done_ms = done;
|
||||
changed.push(i);
|
||||
}
|
||||
AgentEntry::Tool { status, .. }
|
||||
if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) =>
|
||||
{
|
||||
*status = AgentToolStatus::Failed;
|
||||
changed.push(i);
|
||||
}
|
||||
AgentEntry::Permission { chosen, .. } if chosen.is_none() => {
|
||||
*chosen = Some("Cancelled".into());
|
||||
changed.push(i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
fn str_field(v: &Value, key: &str) -> String {
|
||||
v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
/// The status a subagent `state` reports, with its error message; `None` for an unknown state.
|
||||
fn subagent_status(state: &Value) -> Option<(AgentSubagentStatus, Option<String>)> {
|
||||
Some(match state.get("state")?.as_str()? {
|
||||
"running" => (AgentSubagentStatus::Running, None),
|
||||
"requires_action" => (AgentSubagentStatus::NeedsInput, None),
|
||||
"idle" => match state.get("stopReason").and_then(Value::as_str) {
|
||||
Some("cancelled") => (AgentSubagentStatus::Cancelled, None),
|
||||
Some("error") => (
|
||||
AgentSubagentStatus::Failed,
|
||||
state.pointer("/error/message").and_then(Value::as_str).map(str::to_owned),
|
||||
),
|
||||
_ => (AgentSubagentStatus::Done, None),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The text of a list of content blocks, one per line.
|
||||
pub(crate) fn blocks_text(content: Option<&Value>) -> String {
|
||||
let blocks = content.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
blocks.iter().filter_map(|b| content_text(Some(b))).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn content_text(content: Option<&Value>) -> Option<String> {
|
||||
let c = content?;
|
||||
match c.get("type").and_then(Value::as_str) {
|
||||
Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned),
|
||||
Some("resource_link") => c.get("uri").and_then(Value::as_str).map(str::to_owned),
|
||||
Some("resource") => c.pointer("/resource/text").and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_status(v: Option<&Value>) -> Option<AgentToolStatus> {
|
||||
Some(match v?.as_str()? {
|
||||
"pending" => AgentToolStatus::Pending,
|
||||
"in_progress" => AgentToolStatus::Running,
|
||||
"completed" => AgentToolStatus::Completed,
|
||||
"failed" => AgentToolStatus::Failed,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn locations(v: Option<&Value>) -> Vec<IpcPath> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|l| l.get("path")?.as_str()).map(|p| IpcPath(p.into())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn line_stats(old: &str, new: &str) -> (u32, u32) {
|
||||
ide_vcs::diff_hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.lines, r + h.old_lines))
|
||||
}
|
||||
|
||||
fn tail(mut text: String) -> String {
|
||||
if text.len() > MAX_OUTPUT {
|
||||
let mut cut = text.len() - MAX_OUTPUT;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut += 1;
|
||||
}
|
||||
text.replace_range(..cut, "…");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn chunks_merge_into_one_message_until_another_entry_comes() {
|
||||
let mut t = Timeline::default();
|
||||
let chunk = |s: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": s}});
|
||||
assert_eq!(t.apply(&chunk("Hel")).changed, vec![0]);
|
||||
assert_eq!(t.apply(&chunk("lo")).changed, vec![0]);
|
||||
t.apply(&json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}}));
|
||||
t.apply(&chunk("again"));
|
||||
assert_eq!(
|
||||
t.entries,
|
||||
vec![
|
||||
AgentEntry::Agent { text: "Hello".into() },
|
||||
AgentEntry::Thought { text: "hmm".into() },
|
||||
AgentEntry::Agent { text: "again".into() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_calls_update_in_place() {
|
||||
let mut t = Timeline::default();
|
||||
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Edit a.rs", "kind": "edit",
|
||||
"status": "pending", "locations": [{"path": "/p/a.rs", "line": 3}]}));
|
||||
let applied = t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed",
|
||||
"content": [
|
||||
{"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\n", "newText": "a\nB\nc\n"},
|
||||
{"type": "content", "content": {"type": "text", "text": "done"}}
|
||||
]}));
|
||||
assert_eq!(applied.changed, vec![0]);
|
||||
let AgentEntry::Tool { status, diffs, output, locations, .. } = &t.entries[0] else { panic!() };
|
||||
assert_eq!(*status, AgentToolStatus::Completed);
|
||||
assert_eq!(diffs, &vec![AgentToolDiff { path: IpcPath("/p/a.rs".into()), added: 2, removed: 1 }]);
|
||||
assert_eq!(output.as_deref(), Some("done"));
|
||||
assert_eq!(locations, &vec![IpcPath("/p/a.rs".into())]);
|
||||
// Unknown calls are ignored.
|
||||
assert!(t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "zz"})).changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_one_entry_and_end_turn_settles_open_items() {
|
||||
let mut t = Timeline::default();
|
||||
let plan =
|
||||
|s: &str| json!({"sessionUpdate": "plan", "entries": [{"content": "x", "priority": "high", "status": s}]});
|
||||
t.apply(&plan("pending"));
|
||||
t.push(AgentEntry::Agent { text: "between".into() });
|
||||
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
|
||||
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
|
||||
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
|
||||
assert_eq!(t.end_turn(None), vec![2, 3]);
|
||||
let again = Timeline::from_entries(t.entries.clone());
|
||||
assert_eq!(again.plan, Some(0));
|
||||
assert_eq!(again.tools.get("c"), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_turn_records_when_the_last_message_was_answered() {
|
||||
let user =
|
||||
|sent: f64| AgentEntry::User { text: "q".into(), attachments: vec![], sent_ms: Some(sent), done_ms: None };
|
||||
let mut t = Timeline::default();
|
||||
t.push(user(1.0));
|
||||
t.push(AgentEntry::Agent { text: "a".into() });
|
||||
t.push(user(2.0));
|
||||
assert_eq!(t.end_turn(Some(5.0)), vec![2]);
|
||||
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: None, .. }));
|
||||
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: Some(5.0), .. }));
|
||||
// Settled once: a later end (a restart) doesn't move it.
|
||||
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_tail() {
|
||||
let s = tail("é".repeat(MAX_OUTPUT));
|
||||
assert!(s.starts_with('…') && s.len() <= MAX_OUTPUT + 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::AgentChange;
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
pub branch: String,
|
||||
pub base: String,
|
||||
pub base_commit: String,
|
||||
}
|
||||
|
||||
fn repo_at(path: &Path) -> IdeResult<Repo> {
|
||||
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
|
||||
}
|
||||
|
||||
/// The current branch (or commit when detached) and its commit.
|
||||
pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
|
||||
let info = repo_at(path)?.branch_info()?;
|
||||
let commit = info.head_commit.ok_or_else(|| IdeError::unavailable("the repository has no commits yet"))?;
|
||||
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
|
||||
}
|
||||
|
||||
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
|
||||
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
|
||||
let repo = repo_at(project_root)?;
|
||||
let (base, base_commit) = match base.map(str::trim).filter(|b| !b.is_empty()) {
|
||||
Some(b) => {
|
||||
let commit =
|
||||
repo.merge_base(b, b)?.ok_or_else(|| IdeError::not_found(format!("no branch or revision {b}")))?;
|
||||
(b.to_owned(), commit)
|
||||
}
|
||||
None => head_of(project_root)?,
|
||||
};
|
||||
std::fs::create_dir_all(worktrees_dir).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let branch_exists = |s: &str| {
|
||||
let r = format!("refs/heads/agent/{s}");
|
||||
repo.merge_base(&r, &r).ok().flatten().is_some()
|
||||
};
|
||||
let slug = unique_slug(&slugify(title), |s| worktrees_dir.join(s).exists() || branch_exists(s));
|
||||
let path = worktrees_dir.join(&slug);
|
||||
let branch = format!("agent/{slug}");
|
||||
repo.add_worktree(&path, &base_commit, Some(&branch))?;
|
||||
Ok(Created { path, branch, base, base_commit })
|
||||
}
|
||||
|
||||
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
repo_at(project_root)?.remove_worktree(path, true)
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let repo = repo_at(cwd)?;
|
||||
let root = repo.root().to_path_buf();
|
||||
let stats = repo.numstat_with_worktree(base)?;
|
||||
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
|
||||
let mut out: Vec<AgentChange> = repo
|
||||
.diff_with_worktree(base)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stat(&f.path);
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
})
|
||||
.collect();
|
||||
for s in repo.status()? {
|
||||
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
|
||||
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
|
||||
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
fn stamp(path: &Path) -> Stamp {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
|
||||
Some((meta.len(), modified))
|
||||
}
|
||||
|
||||
/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show.
|
||||
pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
|
||||
Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect())
|
||||
}
|
||||
|
||||
/// Drops the changes that were there before the task and haven't been touched since.
|
||||
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
|
||||
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
repo_at(cwd)?.file_at(path, Some(rev))
|
||||
}
|
||||
|
||||
/// A short ASCII branch-safe name from the task title.
|
||||
pub(crate) fn slugify(title: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in title.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
if out.len() >= 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
if out.is_empty() { "task".to_owned() } else { out.to_owned() }
|
||||
}
|
||||
|
||||
fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
if !taken(base) {
|
||||
return base.to_owned();
|
||||
}
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("unbounded")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
assert_eq!(slugify("修复 lexer 越界"), "lexer");
|
||||
assert_eq!(slugify("修复越界"), "task");
|
||||
assert_eq!(slugify(&"a".repeat(50)).len(), 32);
|
||||
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "git {args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_changes_are_listed_against_the_base() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
assert_eq!(created.branch, "agent/edit-a");
|
||||
assert_eq!(created.base, "main");
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\nthree\n").unwrap();
|
||||
std::fs::write(created.path.join("new.txt"), "x\ny\n").unwrap();
|
||||
|
||||
let got = changes(&created.path, &created.base_commit).unwrap();
|
||||
let summary: Vec<_> = got.iter().map(|c| (c.rel_path.as_str(), c.kind, c.added, c.removed)).collect();
|
||||
assert_eq!(summary, [("a.txt", ChangeKind::Modified, 2, 1), ("new.txt", ChangeKind::Added, 2, 0)]);
|
||||
let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap();
|
||||
assert_eq!(base_text.as_deref(), Some("one\ntwo\n"));
|
||||
|
||||
// Changes already in the checkout drop out until they are edited again.
|
||||
let before = snapshot(&created.path).unwrap();
|
||||
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
|
||||
let rels = |c: Vec<AgentChange>| c.into_iter().map(|c| c.rel_path).collect::<Vec<_>>();
|
||||
let after = changes(&created.path, "HEAD").unwrap();
|
||||
assert_eq!(rels(without_untouched(after, &before)), ["b.txt"]);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
std::fs::write(created.path.join("new.txt"), "x\ny\nz\n").unwrap();
|
||||
let after = changes(&created.path, "HEAD").unwrap();
|
||||
assert_eq!(rels(without_untouched(after, &before)), ["b.txt", "new.txt"]);
|
||||
|
||||
// A second task with the same title gets its own worktree.
|
||||
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
|
||||
remove(&repo, &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ fn main() {
|
||||
eprintln!("synced in {:?}: {r:?}", started.elapsed());
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let model = ide_build::load(&root, &cache);
|
||||
let model = ide_build::load(&root, &cache, None);
|
||||
println!("{}", serde_json::to_string_pretty(&model).unwrap());
|
||||
eprintln!("loaded in {:?}", started.elapsed());
|
||||
}
|
||||
@@ -225,6 +225,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
}
|
||||
let step = |p: &maven::MavenProject, extra: &[String]| {
|
||||
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
s.env = p.env.clone();
|
||||
@@ -583,6 +584,7 @@ pub fn diagnostics(messages: &[BuildMessage]) -> BTreeMap<PathBuf, Vec<Diagnosti
|
||||
code: m.code.clone(),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: Some(DIAGNOSTICS_SOURCE.to_owned()),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -671,7 +673,7 @@ mod tests {
|
||||
);
|
||||
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&root, "core/src/main/java/A.java", "class A {}");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"));
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
@@ -685,7 +687,7 @@ mod tests {
|
||||
let g = tmp.path().join("g");
|
||||
write(&g, "settings.gradle", "include 'app'");
|
||||
write(&g, "app/build.gradle", "apply plugin: 'java'");
|
||||
let model = crate::load(&g, &tmp.path().join("cache2"));
|
||||
let model = crate::load(&g, &tmp.path().join("cache2"), None);
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Gradle);
|
||||
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
|
||||
@@ -285,6 +285,74 @@ pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Configurations whose dependencies are libraries (`testFixturesImplementation` ends in one).
|
||||
const DEPENDENCY_CONFIGURATIONS: &[&str] = &[
|
||||
"implementation",
|
||||
"api",
|
||||
"compileOnly",
|
||||
"runtimeOnly",
|
||||
"compileOnlyApi",
|
||||
"annotationProcessor",
|
||||
"kapt",
|
||||
"ksp",
|
||||
"compile",
|
||||
"runtime",
|
||||
"testCompile",
|
||||
"testRuntime",
|
||||
"developmentOnly",
|
||||
];
|
||||
|
||||
fn is_dependency_configuration(name: &str) -> bool {
|
||||
DEPENDENCY_CONFIGURATIONS.iter().any(|c| {
|
||||
name == *c || {
|
||||
let mut cap = c.chars();
|
||||
let first = cap.next().map(|f| f.to_ascii_uppercase()).into_iter();
|
||||
let capitalised: String = first.chain(cap).collect();
|
||||
name.len() > c.len() && name.ends_with(&capitalised)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
continue;
|
||||
}
|
||||
let rest = &line[name.len()..];
|
||||
let lits = literals(rest);
|
||||
let map_value = |key: &str| -> Option<String> {
|
||||
let i = rest.find(key)?;
|
||||
let after = rest[i + key.len()..].trim_start().strip_prefix([':', '='])?;
|
||||
literals(after).into_iter().next()
|
||||
};
|
||||
let coordinate = match (map_value("group"), map_value("name"), map_value("version")) {
|
||||
(Some(g), Some(a), Some(v)) => Some((g, a, v)),
|
||||
_ => lits.first().and_then(|l| {
|
||||
let l = l.split('@').next().unwrap_or(l);
|
||||
let mut parts = l.split(':');
|
||||
Some((parts.next()?.to_owned(), parts.next()?.to_owned(), parts.next()?.to_owned()))
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
if plain(&g) && plain(&a) && plain(&v) && !v.contains('+') {
|
||||
out.push((g, a, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
@@ -333,6 +401,38 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn script_dependencies_read_string_and_map_notations() {
|
||||
let script = r#"
|
||||
plugins { id 'java' }
|
||||
dependencies {
|
||||
implementation 'com.google.guava:guava:33.0.0-jre'
|
||||
api("org.slf4j:slf4j-api:2.0.9")
|
||||
testImplementation "junit:junit:4.13.2@jar"
|
||||
// implementation 'commented:out:1.0'
|
||||
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'
|
||||
runtimeOnly(group = "com.h2database", name = "h2", version = "2.2.224")
|
||||
implementation "org.x:dyn:${ver}"
|
||||
implementation 'org.x:range:1.+'
|
||||
implementation libs.okhttp
|
||||
implementation project(':core')
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script);
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.guava:guava:33.0.0-jre",
|
||||
"org.slf4j:slf4j-api:2.0.9",
|
||||
"junit:junit:4.13.2",
|
||||
"org.projectlombok:lombok:1.18.30",
|
||||
"com.h2database:h2:2.2.224",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,17 +8,18 @@
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
mod sync;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,9 +37,10 @@ pub struct BuildModel {
|
||||
}
|
||||
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
pub fn load(root: &Path, cache_dir: &Path) -> BuildModel {
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let mut m = maven::load(root);
|
||||
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
@@ -49,6 +51,60 @@ pub fn load(root: &Path, cache_dir: &Path) -> BuildModel {
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyCoordinate {
|
||||
/// Gradle builds resolve into the Gradle cache, Maven ones into the local repository.
|
||||
pub gradle: bool,
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
let modules: std::collections::HashSet<String> = m.projects.iter().map(MavenProject::key).collect();
|
||||
let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect();
|
||||
while let Some(d) = stack.pop() {
|
||||
// Omitted occurrences (conflict losers, duplicates) are not on any classpath.
|
||||
if !d.resolved() {
|
||||
continue;
|
||||
}
|
||||
stack.extend(&d.children);
|
||||
let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else {
|
||||
continue;
|
||||
};
|
||||
if d.kind == "pom" || matches!(d.scope.as_str(), "import" | "system") {
|
||||
continue;
|
||||
}
|
||||
if modules.contains(&format!("{}:{}", d.group_id, d.artifact_id)) {
|
||||
continue;
|
||||
}
|
||||
out.push(DependencyCoordinate {
|
||||
gradle: false,
|
||||
group_id: d.group_id.clone(),
|
||||
artifact_id: d.artifact_id.clone(),
|
||||
version: version.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(g) = &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a created / changed / removed project-relative path can change the build model.
|
||||
pub fn affects_build(rel: &Path) -> bool {
|
||||
let Some(name) = rel.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
@@ -68,13 +124,64 @@ mod tests {
|
||||
let cache = tmp.path().join("cache");
|
||||
let root = tmp.path().join("p");
|
||||
std::fs::create_dir_all(root.join("svc")).unwrap();
|
||||
assert_eq!(load(&root, &cache), BuildModel::default());
|
||||
assert_eq!(load(&root, &cache, None), BuildModel::default());
|
||||
std::fs::write(root.join("svc/pom.xml"), "<project><artifactId>svc</artifactId></project>").unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let m = load(&root, &cache);
|
||||
let m = load(&root, &cache, None);
|
||||
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
|
||||
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_flatten_maven_trees_and_read_gradle_scripts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "dependencies { implementation 'org.a:x:1.0' }\n").unwrap();
|
||||
let dep = |g: &str, a: &str, v: Option<&str>, children: Vec<MavenDependency>| MavenDependency {
|
||||
group_id: g.into(),
|
||||
artifact_id: a.into(),
|
||||
version: v.map(Into::into),
|
||||
kind: "jar".into(),
|
||||
scope: "compile".into(),
|
||||
children,
|
||||
..Default::default()
|
||||
};
|
||||
let maven = MavenModel {
|
||||
projects: vec![
|
||||
MavenProject {
|
||||
group_id: "me".into(),
|
||||
artifact_id: "app".into(),
|
||||
dependencies: vec![
|
||||
dep("me", "core", Some("1"), vec![]),
|
||||
dep(
|
||||
"org.b",
|
||||
"y",
|
||||
Some("2"),
|
||||
vec![
|
||||
dep("org.c", "z", Some("3"), vec![]),
|
||||
MavenDependency {
|
||||
omitted: Some(Omission::Conflict),
|
||||
conflict_with: Some("3".into()),
|
||||
..dep("org.c", "z", Some("1"), vec![])
|
||||
},
|
||||
],
|
||||
),
|
||||
dep("org.d", "unresolved", Some("${v}"), vec![]),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
MavenProject { group_id: "me".into(), artifact_id: "core".into(), ..Default::default() },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root);
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}{}:{}:{}", if c.gradle { "G " } else { "M " }, c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ pub struct MavenProject {
|
||||
pub java_version: Option<u32>,
|
||||
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
|
||||
pub command: String,
|
||||
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
|
||||
pub options: Vec<String>,
|
||||
/// Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell.
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
@@ -128,6 +130,37 @@ pub struct MavenDependency {
|
||||
pub optional: bool,
|
||||
/// Transitive dependencies (only after a sync).
|
||||
pub children: Vec<MavenDependency>,
|
||||
/// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub omitted: Option<Omission>,
|
||||
/// The version that won when `omitted` is `Conflict`.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub conflict_with: Option<String>,
|
||||
/// The version requested before `<dependencyManagement>` replaced it.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub managed_from: Option<String>,
|
||||
}
|
||||
|
||||
impl MavenDependency {
|
||||
/// Part of the resolved graph (not a verbose-tree placeholder for an omitted occurrence).
|
||||
pub fn resolved(&self) -> bool {
|
||||
self.omitted.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a dependency occurrence is not part of the resolved graph.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Omission {
|
||||
/// Another occurrence of the same version is nearer the root.
|
||||
Duplicate,
|
||||
/// Another version is nearer the root (or declared first) and wins.
|
||||
Conflict,
|
||||
/// The artifact depends on itself through this path.
|
||||
Cycle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -410,7 +443,7 @@ struct Resolver {
|
||||
}
|
||||
|
||||
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
|
||||
fn default_local_repo() -> Option<PathBuf> {
|
||||
pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
let m2 = dirs::home_dir()?.join(".m2");
|
||||
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
|
||||
&& let Some(repo) = settings.text_of("localRepository")
|
||||
@@ -869,7 +902,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
classifier: d.classifier.clone(),
|
||||
scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()),
|
||||
optional: d.optional,
|
||||
children: Vec::new(),
|
||||
..MavenDependency::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -924,6 +957,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(root, &dir),
|
||||
options: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
});
|
||||
for m in modules.into_iter().rev() {
|
||||
@@ -965,7 +999,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
|
||||
classifier: d.classifier.clone(),
|
||||
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
|
||||
optional: d.optional,
|
||||
children: Vec::new(),
|
||||
..MavenDependency::default()
|
||||
})
|
||||
.collect();
|
||||
(format!("{group}:{}", raw.artifact_id), plugins, deps)
|
||||
@@ -973,13 +1007,16 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line.
|
||||
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line, with the
|
||||
/// `-Dverbose` annotations: `(g:a:jar:1:compile - omitted for conflict with 2)`,
|
||||
/// `g:a:jar:1:compile (version managed from 0.9)`, `(optional)`.
|
||||
fn parse_coords(s: &str) -> Option<MavenDependency> {
|
||||
let s = s.trim();
|
||||
let (coords, extra) = match s.find(" (") {
|
||||
Some(i) => (&s[..i], &s[i..]),
|
||||
None => (s, ""),
|
||||
let (s, wrapped) = match s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
|
||||
Some(inner) => (inner, true),
|
||||
None => (s, false),
|
||||
};
|
||||
let (coords, notes) = s.split_once(' ').unwrap_or((s, ""));
|
||||
let parts: Vec<&str> = coords.split(':').collect();
|
||||
let (g, a, t, c, v, scope) = match parts.as_slice() {
|
||||
[g, a, t, v] => (g, a, t, None, v, None),
|
||||
@@ -987,16 +1024,35 @@ fn parse_coords(s: &str) -> Option<MavenDependency> {
|
||||
[g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(MavenDependency {
|
||||
let mut dep = MavenDependency {
|
||||
group_id: (*g).to_owned(),
|
||||
artifact_id: (*a).to_owned(),
|
||||
version: Some((*v).to_owned()),
|
||||
kind: (*t).to_owned(),
|
||||
classifier: c.map(|c| (*c).to_owned()),
|
||||
scope: scope.unwrap_or("compile").to_owned(),
|
||||
optional: extra.contains("optional"),
|
||||
children: Vec::new(),
|
||||
})
|
||||
..MavenDependency::default()
|
||||
};
|
||||
let notes = notes.replace(['(', ')'], ";").replace(" - ", ";");
|
||||
for note in notes.split(';').map(|n| n.trim().trim_start_matches("- ").trim()) {
|
||||
if note == "optional" {
|
||||
dep.optional = true;
|
||||
} else if let Some(winner) = note.strip_prefix("omitted for conflict with ") {
|
||||
dep.omitted = Some(Omission::Conflict);
|
||||
dep.conflict_with = Some(winner.trim().to_owned());
|
||||
} else if note == "omitted for duplicate" {
|
||||
dep.omitted = Some(Omission::Duplicate);
|
||||
} else if note == "omitted for cycle" {
|
||||
dep.omitted = Some(Omission::Cycle);
|
||||
} else if let Some(from) = note.strip_prefix("version managed from ") {
|
||||
dep.managed_from = Some(from.trim().to_owned());
|
||||
}
|
||||
}
|
||||
// A parenthesised line is always an omitted occurrence, even with an unknown reason.
|
||||
if wrapped && dep.omitted.is_none() {
|
||||
dep.omitted = Some(Omission::Duplicate);
|
||||
}
|
||||
Some(dep)
|
||||
}
|
||||
|
||||
fn insert_at(list: &mut Vec<MavenDependency>, depth: usize, dep: MavenDependency) {
|
||||
@@ -1324,4 +1380,82 @@ mod tests {
|
||||
);
|
||||
assert_eq!(trees[2].1[0].scope, "compile");
|
||||
}
|
||||
|
||||
/// `mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree -Dverbose` on a project
|
||||
/// whose libraries ask for different commons-lang3 / commons-text / guava versions.
|
||||
#[test]
|
||||
fn verbose_dependency_tree_output() {
|
||||
let trees = parse_dependency_tree(include_str!("../testdata/dependency-tree-verbose.txt"));
|
||||
assert_eq!(trees.len(), 1);
|
||||
let (key, deps) = &trees[0];
|
||||
assert_eq!(key, "demo:conflict-demo");
|
||||
let direct: Vec<&str> = deps.iter().map(|d| d.artifact_id.as_str()).collect();
|
||||
assert_eq!(direct, ["hamcrest-core", "commons-text", "commons-configuration2", "guava", "guice", "junit"]);
|
||||
assert!(deps.iter().all(MavenDependency::resolved));
|
||||
|
||||
let config = &deps[2].children;
|
||||
let lang3 = &config[0];
|
||||
assert_eq!(
|
||||
(lang3.artifact_id.as_str(), lang3.version.as_deref(), lang3.omitted, lang3.conflict_with.as_deref()),
|
||||
("commons-lang3", Some("3.14.0"), Some(Omission::Conflict), Some("3.12.0"))
|
||||
);
|
||||
assert_eq!(lang3.scope, "compile");
|
||||
let logging = &config[2];
|
||||
assert_eq!(
|
||||
(logging.version.as_deref(), logging.managed_from.as_deref(), logging.omitted),
|
||||
(Some("1.2"), Some("1.3.0"), None)
|
||||
);
|
||||
let guava = &deps[4].children[2];
|
||||
assert_eq!((guava.version.as_deref(), guava.conflict_with.as_deref()), (Some("30.1-jre"), Some("32.1.3-jre")));
|
||||
let hamcrest = &deps[5].children[0];
|
||||
assert_eq!((hamcrest.omitted, hamcrest.scope.as_str()), (Some(Omission::Duplicate), "test"));
|
||||
assert_eq!(deps[1].children[0].omitted, None, "the winning commons-lang3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_annotations() {
|
||||
// (line, omitted, conflict winner, managed from, optional)
|
||||
type Case<'a> = (&'a str, Option<Omission>, Option<&'a str>, Option<&'a str>, bool);
|
||||
let cases: &[Case] = &[
|
||||
("(g:a:jar:1:compile - omitted for cycle)", Some(Omission::Cycle), None, None, false),
|
||||
(
|
||||
"(g:a:jar:1:compile - version managed from 0.9; omitted for duplicate)",
|
||||
Some(Omission::Duplicate),
|
||||
None,
|
||||
Some("0.9"),
|
||||
false,
|
||||
),
|
||||
("g:a:jar:1:compile (optional)", None, None, None, true),
|
||||
(
|
||||
"(g:a:jar:1:runtime (optional) - omitted for conflict with 2)",
|
||||
Some(Omission::Conflict),
|
||||
Some("2"),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"(g:a:jar:1:compile - scope updated from runtime; omitted for duplicate)",
|
||||
Some(Omission::Duplicate),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"g:a:jar:1:compile (version managed from 0.9; scope managed from runtime)",
|
||||
None,
|
||||
None,
|
||||
Some("0.9"),
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (line, omitted, winner, managed, optional) in cases {
|
||||
let d = parse_coords(line).unwrap();
|
||||
assert_eq!(
|
||||
(d.omitted, d.conflict_with.as_deref(), d.managed_from.as_deref(), d.optional),
|
||||
(*omitted, *winner, *managed, *optional),
|
||||
"{line}"
|
||||
);
|
||||
assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct
|
||||
//! dependency is declared and adding an `<exclusion>` to it, in the style
|
||||
//! (indentation, line endings, one-line elements) of the surrounding POM.
|
||||
|
||||
use crate::maven::{self, MavenModel};
|
||||
use crate::xml::{self, Element};
|
||||
use ide_core::{Pos, Range, TextEdit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// `groupId:artifactId` of a dependency.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactKey {
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
}
|
||||
|
||||
impl ArtifactKey {
|
||||
pub fn new(group_id: impl Into<String>, artifact_id: impl Into<String>) -> Self {
|
||||
Self { group_id: group_id.into(), artifact_id: artifact_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ArtifactKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.group_id, self.artifact_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// What adding an exclusion does to a POM.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Exclusion {
|
||||
/// The edit that adds it.
|
||||
Edit(TextEdit),
|
||||
/// The dependency already excludes it (or everything, `*:*`).
|
||||
Present,
|
||||
}
|
||||
|
||||
/// Properties the coordinates of a POM's dependencies may refer to.
|
||||
fn properties(project: &Element) -> HashMap<String, String> {
|
||||
let mut props: HashMap<String, String> = project
|
||||
.child("properties")
|
||||
.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect())
|
||||
.unwrap_or_default();
|
||||
let own_or_parent = |name: &str| project.text_of(name).or_else(|| project.child("parent")?.text_of(name));
|
||||
for (key, value) in [
|
||||
("project.groupId", own_or_parent("groupId")),
|
||||
("project.version", own_or_parent("version")),
|
||||
("project.artifactId", project.text_of("artifactId")),
|
||||
] {
|
||||
if let Some(v) = value {
|
||||
props.insert(key.into(), v);
|
||||
}
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn matches(e: &Element, key: &ArtifactKey, props: &HashMap<String, String>) -> bool {
|
||||
let text = |name: &str| e.text_of(name).map(|t| maven::interpolate(&t, props));
|
||||
text("groupId").as_deref() == Some(key.group_id.as_str())
|
||||
&& text("artifactId").as_deref() == Some(key.artifact_id.as_str())
|
||||
}
|
||||
|
||||
/// The `<dependency>` declaring `key` in the project's `<dependencies>`, else in a
|
||||
/// profile's (never `<dependencyManagement>`, which declares no dependency).
|
||||
fn find_dependency<'a>(project: &'a Element, key: &ArtifactKey) -> Option<&'a Element> {
|
||||
let props = properties(project);
|
||||
let own = project.list("dependencies", "dependency");
|
||||
let profiles = project.list("profiles", "profile").into_iter().flat_map(|p| p.list("dependencies", "dependency"));
|
||||
own.into_iter().chain(profiles).find(|d| matches(d, key, &props))
|
||||
}
|
||||
|
||||
/// 0-based line / UTF-16 column of byte offset `at`.
|
||||
fn pos_at(text: &str, at: usize) -> Pos {
|
||||
let before = &text[..at];
|
||||
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
|
||||
Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32)
|
||||
}
|
||||
|
||||
fn line_start(text: &str, at: usize) -> usize {
|
||||
text[..at].rfind('\n').map_or(0, |i| i + 1)
|
||||
}
|
||||
|
||||
/// The whitespace before `at` on its line, or `None` when other text precedes it there.
|
||||
fn indent_before(text: &str, at: usize) -> Option<&str> {
|
||||
let ws = &text[line_start(text, at)..at];
|
||||
ws.chars().all(|c| c == ' ' || c == '\t').then_some(ws)
|
||||
}
|
||||
|
||||
/// One level of indentation: what the first child of `e` adds to `e`'s, else two spaces.
|
||||
fn indent_unit(text: &str, e: &Element) -> String {
|
||||
let outer = indent_before(text, e.start);
|
||||
let inner = e.children.first().and_then(|c| indent_before(text, c.start));
|
||||
match (outer, inner) {
|
||||
(Some(o), Some(i)) if i.len() > o.len() && i.starts_with(o) => i[o.len()..].to_owned(),
|
||||
(_, Some(i)) if i.starts_with('\t') => "\t".into(),
|
||||
_ => " ".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<")
|
||||
}
|
||||
|
||||
/// Range of the `<artifactId>` of the dependency declaring `key` in `text` (a POM), or of
|
||||
/// the whole `<dependency>` when it has none; `None` when the POM does not declare it.
|
||||
pub fn declaration(text: &str, key: &ArtifactKey) -> Option<Range> {
|
||||
let project = xml::parse(text)?;
|
||||
let dep = find_dependency(&project, key)?;
|
||||
let target = dep.child("artifactId").unwrap_or(dep);
|
||||
Some(Range::new(pos_at(text, target.start), pos_at(text, target.end)))
|
||||
}
|
||||
|
||||
/// Adds `exclude` to the `<exclusions>` of the dependency declaring `dependency` in `text`
|
||||
/// (a POM); `None` when the POM does not declare it.
|
||||
pub fn add_exclusion(text: &str, dependency: &ArtifactKey, exclude: &ArtifactKey) -> Option<Exclusion> {
|
||||
let project = xml::parse(text)?;
|
||||
let dep = find_dependency(&project, dependency)?;
|
||||
let nl = if text.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
let unit = indent_unit(text, dep);
|
||||
let (g, a) = (escape(&exclude.group_id), escape(&exclude.artifact_id));
|
||||
|
||||
// Insert `block` (lines relative to `parent`'s indentation + one level) before `parent`'s
|
||||
// end tag; on one line (`compact`) when `parent` is not laid out one element per line.
|
||||
let insert = |parent: &Element, lines: &[(usize, String)], compact: String| {
|
||||
let close_on_own_line = indent_before(text, parent.inner_end).is_some() && parent.inner_end < parent.end;
|
||||
let (at, new_text) = match indent_before(text, parent.start) {
|
||||
Some(base) if close_on_own_line => {
|
||||
let mut out = String::new();
|
||||
for (depth, line) in lines {
|
||||
out.push_str(base);
|
||||
out.push_str(&unit.repeat(depth + 1));
|
||||
out.push_str(line);
|
||||
out.push_str(nl);
|
||||
}
|
||||
(line_start(text, parent.inner_end), out)
|
||||
}
|
||||
_ => (parent.inner_end, compact),
|
||||
};
|
||||
let p = pos_at(text, at);
|
||||
Exclusion::Edit(TextEdit { range: Range::point(p), new_text })
|
||||
};
|
||||
let exclusion_lines = |depth: usize| {
|
||||
vec![
|
||||
(depth, "<exclusion>".to_owned()),
|
||||
(depth + 1, format!("<groupId>{g}</groupId>")),
|
||||
(depth + 1, format!("<artifactId>{a}</artifactId>")),
|
||||
(depth, "</exclusion>".to_owned()),
|
||||
]
|
||||
};
|
||||
let compact = format!("<exclusion><groupId>{g}</groupId><artifactId>{a}</artifactId></exclusion>");
|
||||
|
||||
if let Some(exclusions) = dep.child("exclusions") {
|
||||
let props = properties(&project);
|
||||
let wildcard = ArtifactKey::new("*", "*");
|
||||
let any_artifact = ArtifactKey::new(exclude.group_id.clone(), "*");
|
||||
if exclusions
|
||||
.children_named("exclusion")
|
||||
.any(|e| [exclude, &wildcard, &any_artifact].iter().any(|k| matches(e, k, &props)))
|
||||
{
|
||||
return Some(Exclusion::Present);
|
||||
}
|
||||
return Some(insert(exclusions, &exclusion_lines(0), compact));
|
||||
}
|
||||
let mut lines = vec![(0, "<exclusions>".to_owned())];
|
||||
lines.extend(exclusion_lines(1));
|
||||
lines.push((0, "</exclusions>".to_owned()));
|
||||
Some(insert(dep, &lines, format!("<exclusions>{compact}</exclusions>")))
|
||||
}
|
||||
|
||||
/// The POMs that may declare a dependency of the module `pom` (project-relative), nearest
|
||||
/// first: the module's own, then its parents that are part of the project.
|
||||
pub fn declaring_poms(model: &MavenModel, pom: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut current = model.projects.iter().find(|p| p.pom == pom);
|
||||
while let Some(p) = current {
|
||||
if !seen.insert(p.pom.clone()) {
|
||||
break;
|
||||
}
|
||||
out.push(p.pom.clone());
|
||||
current = p.parent.as_deref().and_then(|coords| {
|
||||
model.projects.iter().find(|q| format!("{}:{}:{}", q.group_id, q.artifact_id, q.version) == coords)
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn apply(text: &str, edit: &TextEdit) -> String {
|
||||
let offset = |p: Pos| {
|
||||
let start: usize = text.split_inclusive('\n').take(p.line as usize).map(str::len).sum();
|
||||
let line = &text[start..];
|
||||
start + line.char_indices().nth(p.col as usize).map_or(line.len(), |(i, _)| i)
|
||||
};
|
||||
let (from, to) = (offset(edit.range.start), offset(edit.range.end));
|
||||
format!("{}{}{}", &text[..from], edit.new_text, &text[to..])
|
||||
}
|
||||
|
||||
fn exclude(text: &str, dep: (&str, &str), excl: (&str, &str)) -> Option<String> {
|
||||
match add_exclusion(text, &ArtifactKey::new(dep.0, dep.1), &ArtifactKey::new(excl.0, excl.1))? {
|
||||
Exclusion::Edit(e) => Some(apply(text, &e)),
|
||||
Exclusion::Present => Some(text.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
const POM: &str = "<project>
|
||||
<groupId>demo</groupId>
|
||||
<properties>
|
||||
<guice.group>com.google.inject</guice.group>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.inject</groupId>
|
||||
<artifactId>guice</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${guice.group}</groupId>
|
||||
<artifactId>guice</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn adds_exclusions_to_the_declaration_not_the_managed_entry() {
|
||||
let out = exclude(POM, ("com.google.inject", "guice"), ("com.google.guava", "guava")).unwrap();
|
||||
let expected = POM.replace(
|
||||
" <artifactId>guice</artifactId>\n </dependency>\n </dependencies>",
|
||||
" <artifactId>guice</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>",
|
||||
);
|
||||
assert_eq!(out, expected);
|
||||
// A second exclusion joins the first; excluding it again changes nothing.
|
||||
let twice = exclude(&out, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap();
|
||||
assert!(twice.contains(
|
||||
" </exclusion>
|
||||
<exclusion>
|
||||
<groupId>aopalliance</groupId>
|
||||
<artifactId>aopalliance</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>"
|
||||
));
|
||||
assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_tabs_crlf_and_one_line_dependencies() {
|
||||
let tabs = "<project>\r\n\t<dependencies>\r\n\t\t<dependency>\r\n\t\t\t<groupId>g</groupId>\r\n\t\t\t<artifactId>a</artifactId>\r\n\t\t</dependency>\r\n\t</dependencies>\r\n</project>\r\n";
|
||||
let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap();
|
||||
assert!(out.contains("<artifactId>a</artifactId>\r\n\t\t\t<exclusions>\r\n\t\t\t\t<exclusion>\r\n\t\t\t\t\t<groupId>x</groupId>\r\n"));
|
||||
assert!(out.contains("\t\t\t</exclusions>\r\n\t\t</dependency>"));
|
||||
|
||||
let inline = "<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId></dependency></dependencies></project>";
|
||||
assert_eq!(
|
||||
exclude(inline, ("g", "a"), ("x", "y")).unwrap(),
|
||||
"<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId><exclusions><exclusion><groupId>x</groupId><artifactId>y</artifactId></exclusion></exclusions></dependency></dependencies></project>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcards_profiles_and_missing_declarations() {
|
||||
let pom = "<project>
|
||||
<profiles>
|
||||
<profile>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>g</groupId><artifactId>a</artifactId>
|
||||
<exclusions><exclusion><groupId>x</groupId><artifactId>*</artifactId></exclusion></exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>";
|
||||
let key = ArtifactKey::new("g", "a");
|
||||
assert_eq!(add_exclusion(pom, &key, &ArtifactKey::new("x", "y")), Some(Exclusion::Present));
|
||||
let out = exclude(pom, ("g", "a"), ("z", "w")).unwrap();
|
||||
assert!(out.contains(
|
||||
"</exclusion><exclusion><groupId>z</groupId><artifactId>w</artifactId></exclusion></exclusions>"
|
||||
));
|
||||
assert_eq!(add_exclusion(pom, &ArtifactKey::new("g", "other"), &key), None);
|
||||
assert_eq!(add_exclusion("not xml", &key, &key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_ranges_use_utf16_columns() {
|
||||
let pom = "<project><!-- é😀 --><dependencies>\n <dependency><groupId>g</groupId><artifactId>a</artifactId></dependency>\n</dependencies></project>";
|
||||
let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap();
|
||||
assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60)));
|
||||
let first = "<project><!-- é😀 --><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId>";
|
||||
let r = declaration(&format!("{first}</dependency></dependencies></project>"), &ArtifactKey::new("g", "a"))
|
||||
.unwrap();
|
||||
let start = first.find("<artifactId>").unwrap();
|
||||
assert_eq!(r.start.col as usize, first[..start].encode_utf16().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaring_poms_follow_parents_inside_the_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let write = |rel: &str, text: &str| {
|
||||
let p = root.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, text).unwrap();
|
||||
};
|
||||
write(
|
||||
"pom.xml",
|
||||
"<project><groupId>d</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
"app/pom.xml",
|
||||
"<project><parent><groupId>d</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let model = maven::load_with_repo(root, None);
|
||||
assert_eq!(declaring_poms(&model, "app/pom.xml"), ["app/pom.xml", "pom.xml"]);
|
||||
assert_eq!(declaring_poms(&model, "pom.xml"), ["pom.xml"]);
|
||||
assert!(declaring_poms(&model, "missing/pom.xml").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,7 @@ pub(crate) fn run(
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
|
||||
let (tx, rx) = mpsc::channel::<(Stream, String)>();
|
||||
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Reading the effective build from Maven / Gradle itself ("Reload" in the tool
|
||||
//! windows): `mvn help:effective-pom dependency:tree` and `gradle tasks --all`,
|
||||
//! windows): `mvn help:effective-pom dependency:tree -Dverbose` and `gradle tasks --all`,
|
||||
//! run in the background with progress and cancellation. Results are cached
|
||||
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
|
||||
//! into the static model by [`crate::load`]; a changed build file marks them stale.
|
||||
@@ -29,6 +29,10 @@ pub struct SyncOptions {
|
||||
pub profiles: Vec<String>,
|
||||
/// Extra environment (`JAVA_HOME`).
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
|
||||
pub maven_command: Option<String>,
|
||||
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
|
||||
pub maven_options: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -45,6 +49,9 @@ struct MavenCache {
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
projects: Vec<CachedMavenProject>,
|
||||
/// The trees list omitted occurrences (`-Dverbose`); older caches are reloaded.
|
||||
#[serde(default)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -56,6 +63,21 @@ struct GradleCache {
|
||||
}
|
||||
|
||||
const MAVEN_CACHE: &str = "maven.json";
|
||||
|
||||
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
|
||||
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
|
||||
/// Offline, the project's own plugin version is used instead, since this one may not be cached.
|
||||
const DEPENDENCY_TREE: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree";
|
||||
|
||||
/// The goals that read the effective POM into `effective` and the verbose dependency trees.
|
||||
fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
|
||||
vec![
|
||||
"help:effective-pom".into(),
|
||||
format!("-Doutput={}", effective.to_string_lossy()),
|
||||
if offline { "dependency:tree".into() } else { DEPENDENCY_TREE.into() },
|
||||
"-Dverbose".into(),
|
||||
]
|
||||
}
|
||||
const GRADLE_CACHE: &str = "gradle.json";
|
||||
|
||||
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
|
||||
@@ -106,7 +128,7 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
|
||||
}
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
model.stale = cache.hash != hash_files(&maven::build_files(root, model));
|
||||
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
|
||||
}
|
||||
|
||||
/// Merges the cached task list into `model`.
|
||||
@@ -178,23 +200,24 @@ pub fn sync_maven(
|
||||
) -> IdeResult<()> {
|
||||
let model = maven::load(root);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), ..MavenCache::default() };
|
||||
let mut cache =
|
||||
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
|
||||
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
|
||||
let _ = std::fs::remove_file(&effective);
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
||||
args.extend(options.maven_options.iter().cloned());
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
}
|
||||
if !options.profiles.is_empty() {
|
||||
args.push(format!("-P{}", options.profiles.join(",")));
|
||||
}
|
||||
args.push("help:effective-pom".into());
|
||||
args.push(format!("-Doutput={}", effective.to_string_lossy()));
|
||||
args.push("dependency:tree".into());
|
||||
args.extend(maven_sync_goals(&effective, options.offline));
|
||||
progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None });
|
||||
let out = run(&top.command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
|
||||
let command = options.maven_command.as_deref().unwrap_or(&top.command);
|
||||
let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
|
||||
if let Some((name, i, total)) = maven_building(line) {
|
||||
let fraction = (i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0);
|
||||
progress(SyncProgress { message: format!("Building model of {name}"), fraction: Some(fraction) });
|
||||
@@ -271,6 +294,7 @@ mod tests {
|
||||
hash: hash_files(&maven::build_files(&root, &model)),
|
||||
at_ms: 5.0,
|
||||
error: None,
|
||||
verbose: true,
|
||||
projects: vec![CachedMavenProject {
|
||||
key: "g:a".into(),
|
||||
plugins: vec![maven::plugin(
|
||||
@@ -321,6 +345,25 @@ mod tests {
|
||||
assert!(!model.stale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
|
||||
let online = maven_sync_goals(Path::new("e.xml"), false);
|
||||
assert_eq!(online, ["help:effective-pom", "-Doutput=e.xml", DEPENDENCY_TREE, "-Dverbose"]);
|
||||
let offline = maven_sync_goals(Path::new("e.xml"), true);
|
||||
assert_eq!(offline[2], "dependency:tree");
|
||||
assert!(offline.contains(&"-Dverbose".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_written_before_omissions_existed_still_loads() {
|
||||
let json = r#"{"hash":"x","at_ms":1.0,"error":null,"projects":[{"key":"g:a","plugins":[],
|
||||
"dependencies":[{"groupId":"g","artifactId":"d","version":"1","type":"jar","classifier":null,
|
||||
"scope":"compile","optional":false,"children":[]}]}]}"#;
|
||||
let cache: MavenCache = serde_json::from_str(json).unwrap();
|
||||
assert!(cache.projects[0].dependencies[0].resolved());
|
||||
assert!(!cache.verbose, "reloaded to read the omitted occurrences");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_and_error_lines() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! A small, forgiving XML element reader for POM files: elements, text and
|
||||
//! CDATA; attributes, comments, processing instructions and DOCTYPEs are
|
||||
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
|
||||
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub children: Vec<Element>,
|
||||
/// Byte offset of the start tag's `<`.
|
||||
pub start: usize,
|
||||
/// Byte offset of the end tag's `<` (`end` for an empty or unclosed element).
|
||||
pub inner_end: usize,
|
||||
/// Byte offset just past the end tag (past the start tag for `<empty/>`).
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
@@ -76,6 +83,7 @@ fn local(name: &str) -> String {
|
||||
pub fn parse(text: &str) -> Option<Element> {
|
||||
let mut stack: Vec<Element> = vec![Element::default()];
|
||||
let mut rest = text;
|
||||
let at = |rest: &str| text.len() - rest.len();
|
||||
while !rest.is_empty() {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
decode_entities(rest, &mut stack.last_mut()?.text);
|
||||
@@ -98,13 +106,19 @@ pub fn parse(text: &str) -> Option<Element> {
|
||||
} else if let Some(body) = tag.strip_prefix("</") {
|
||||
let end = body.find('>').unwrap_or(body.len());
|
||||
let name = local(body[..end].trim());
|
||||
if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
|
||||
while stack.len() > at {
|
||||
let done = stack.pop()?;
|
||||
let close_start = at(tag);
|
||||
rest = body.get(end + 1..).unwrap_or("");
|
||||
let close_end = at(rest);
|
||||
if let Some(i) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
|
||||
while stack.len() > i {
|
||||
let mut done = stack.pop()?;
|
||||
// Elements closed implicitly by an outer end tag end where it starts.
|
||||
let matched = stack.len() == i;
|
||||
done.inner_end = close_start;
|
||||
done.end = if matched { close_end } else { close_start };
|
||||
stack.last_mut()?.children.push(done);
|
||||
}
|
||||
}
|
||||
rest = body.get(end + 1..).unwrap_or("");
|
||||
} else {
|
||||
// Start or empty tag; skip quoted attribute values when looking for `>`.
|
||||
let bytes = tag.as_bytes();
|
||||
@@ -124,17 +138,22 @@ pub fn parse(text: &str) -> Option<Element> {
|
||||
let empty = inner.ends_with('/');
|
||||
let inner = inner.trim_end_matches('/');
|
||||
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
|
||||
let el = Element { name: local(&inner[..name_end]), ..Element::default() };
|
||||
let start = at(tag);
|
||||
rest = tag.get(i + 1..).unwrap_or("");
|
||||
let after = at(rest);
|
||||
let el =
|
||||
Element { name: local(&inner[..name_end]), start, inner_end: after, end: after, ..Element::default() };
|
||||
if empty {
|
||||
stack.last_mut()?.children.push(el);
|
||||
} else {
|
||||
stack.push(el);
|
||||
}
|
||||
rest = tag.get(i + 1..).unwrap_or("");
|
||||
}
|
||||
}
|
||||
while stack.len() > 1 {
|
||||
let done = stack.pop()?;
|
||||
let mut done = stack.pop()?;
|
||||
done.inner_end = text.len();
|
||||
done.end = text.len();
|
||||
stack.last_mut()?.children.push(done);
|
||||
}
|
||||
stack.pop()?.children.into_iter().next()
|
||||
@@ -167,6 +186,22 @@ mod tests {
|
||||
assert_eq!(root.children_named("name").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_offsets() {
|
||||
let doc = "<a>\n <b>x</b><c/>\n</a>";
|
||||
let root = parse(doc).unwrap();
|
||||
assert_eq!((root.start, root.inner_end, root.end), (0, doc.len() - 4, doc.len()));
|
||||
let b = root.child("b").unwrap();
|
||||
assert_eq!(&doc[b.start..b.end], "<b>x</b>");
|
||||
assert_eq!(&doc[b.start..b.inner_end], "<b>x");
|
||||
let c = root.child("c").unwrap();
|
||||
assert_eq!(&doc[c.start..c.end], "<c/>");
|
||||
let broken = parse("<a><b>x</a>").unwrap();
|
||||
let b = broken.child("b").unwrap();
|
||||
assert_eq!((b.inner_end, b.end), (7, 7));
|
||||
assert_eq!(broken.end, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_broken_documents() {
|
||||
let root = parse("<a><b>x</c><d>y").unwrap();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[INFO] --- dependency:3.8.1:tree (default-cli) @ conflict-demo ---
|
||||
[INFO] demo:conflict-demo:jar:1.0-SNAPSHOT
|
||||
[INFO] +- org.hamcrest:hamcrest-core:jar:1.3:compile
|
||||
[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile
|
||||
[INFO] | \- org.apache.commons:commons-lang3:jar:3.12.0:compile
|
||||
[INFO] +- org.apache.commons:commons-configuration2:jar:2.10.1:compile
|
||||
[INFO] | +- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.12.0)
|
||||
[INFO] | +- (org.apache.commons:commons-text:jar:1.11.0:compile - omitted for conflict with 1.10.0)
|
||||
[INFO] | \- commons-logging:commons-logging:jar:1.2:compile (version managed from 1.3.0)
|
||||
[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile
|
||||
[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile
|
||||
[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
|
||||
[INFO] | +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
|
||||
[INFO] | +- org.checkerframework:checker-qual:jar:3.37.0:compile
|
||||
[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.21.1:compile
|
||||
[INFO] | \- com.google.j2objc:j2objc-annotations:jar:2.8:compile
|
||||
[INFO] +- com.google.inject:guice:jar:5.1.0:compile
|
||||
[INFO] | +- javax.inject:javax.inject:jar:1:compile
|
||||
[INFO] | +- aopalliance:aopalliance:jar:1.0:compile
|
||||
[INFO] | \- (com.google.guava:guava:jar:30.1-jre:compile - omitted for conflict with 32.1.3-jre)
|
||||
[INFO] \- junit:junit:jar:4.13.2:test
|
||||
[INFO] \- (org.hamcrest:hamcrest-core:jar:1.3:test - omitted for duplicate)
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,293 @@
|
||||
//! The file list on the OS clipboard: what Explorer copies when the user
|
||||
//! copies files, so the project view can paste them, and what the project view
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both).
|
||||
//!
|
||||
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
|
||||
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
|
||||
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
|
||||
//! [`set_files`] fails there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
/// The absolute paths on the clipboard, empty when it holds no file list.
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
imp::files()
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds a file list, without reading it.
|
||||
pub fn has_files() -> bool {
|
||||
imp::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
|
||||
/// to write; the clipboard is a shared resource, so callers treat this as
|
||||
/// best-effort.
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
if paths.is_empty() {
|
||||
return false;
|
||||
}
|
||||
imp::set_files(paths)
|
||||
}
|
||||
|
||||
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
|
||||
/// page survive. Empty paths are skipped.
|
||||
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
|
||||
let mut units: Vec<u16> = Vec::new();
|
||||
for path in paths {
|
||||
let path = path.to_string_lossy();
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
units.extend(path.encode_utf16());
|
||||
units.push(0);
|
||||
}
|
||||
// The list ends with an empty string: with one path, two NULs in a row.
|
||||
units.push(0);
|
||||
let mut out = Vec::with_capacity(HEADER + units.len() * 2);
|
||||
out.extend_from_slice(&(HEADER as u32).to_le_bytes()); // pFiles: list starts after the header
|
||||
out.extend_from_slice(&[0u8; 8]); // pt: the drop point, unused
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // fNC: not a file manager drop
|
||||
out.extend_from_slice(&1u32.to_le_bytes()); // fWide: UTF-16 paths
|
||||
for unit in units {
|
||||
out.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The paths of a `DROPFILES` block, stopping at the empty string that ends the
|
||||
/// list. An ANSI list (pre-Unicode writers) is decoded as UTF-8, lossily.
|
||||
pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
if bytes.len() < HEADER {
|
||||
return Vec::new();
|
||||
}
|
||||
let offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
|
||||
let wide = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) != 0;
|
||||
if offset >= bytes.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let body = &bytes[offset..];
|
||||
let mut out = Vec::new();
|
||||
if wide {
|
||||
let mut current: Vec<u16> = Vec::new();
|
||||
for chunk in body.as_chunks::<2>().0 {
|
||||
let unit = u16::from_le_bytes(*chunk);
|
||||
if unit != 0 {
|
||||
current.push(unit);
|
||||
} else if current.is_empty() {
|
||||
break;
|
||||
} else {
|
||||
out.push(PathBuf::from(String::from_utf16_lossy(¤t)));
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for part in body.split(|b| *b == 0) {
|
||||
if part.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
|
||||
const GMEM_MOVEABLE: u32 = 0x0002;
|
||||
/// Another process holds the clipboard only while it copies; retrying beats
|
||||
/// reporting "no files" for a paste the user just made.
|
||||
const OPEN_ATTEMPTS: usize = 10;
|
||||
const OPEN_RETRY: Duration = Duration::from_millis(20);
|
||||
|
||||
#[link(name = "user32")]
|
||||
unsafe extern "system" {
|
||||
fn OpenClipboard(owner: Handle) -> i32;
|
||||
fn CloseClipboard() -> i32;
|
||||
fn EmptyClipboard() -> i32;
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GlobalAlloc(flags: u32, bytes: usize) -> Handle;
|
||||
fn GlobalLock(memory: Handle) -> *mut c_void;
|
||||
fn GlobalUnlock(memory: Handle) -> i32;
|
||||
fn GlobalFree(memory: Handle) -> Handle;
|
||||
fn GlobalSize(memory: Handle) -> usize;
|
||||
}
|
||||
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
if !has_files() || !open() {
|
||||
return Vec::new();
|
||||
}
|
||||
let memory = unsafe { GetClipboardData(CF_HDROP) };
|
||||
let paths = if memory.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
|
||||
let paths = decode(bytes);
|
||||
GlobalUnlock(memory);
|
||||
paths
|
||||
}
|
||||
}
|
||||
};
|
||||
unsafe { CloseClipboard() };
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the block is allocated, filled, handed to the clipboard and
|
||||
// freed only on the paths where the clipboard did not take ownership.
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
|
||||
if memory.is_null() {
|
||||
return false;
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
ptr::copy_nonoverlapping(bytes.as_ptr(), data as *mut u8, bytes.len());
|
||||
GlobalUnlock(memory);
|
||||
if !open() {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
if EmptyClipboard() == 0 {
|
||||
CloseClipboard();
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
|
||||
CloseClipboard();
|
||||
if !taken {
|
||||
GlobalFree(memory);
|
||||
}
|
||||
taken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn set_files(_paths: &[PathBuf]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn paths(list: &[&str]) -> Vec<PathBuf> {
|
||||
list.iter().map(PathBuf::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_list_reads_back_unchanged() {
|
||||
let list = paths(&["C:/work/project/a.txt", "C:/work/project/sub"]);
|
||||
assert_eq!(decode(&encode(&list)), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_says_where_the_paths_start_and_that_they_are_wide() {
|
||||
let bytes = encode(&paths(&["C:/a"]));
|
||||
assert_eq!(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), HEADER as u32);
|
||||
assert_eq!(u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), 1);
|
||||
// `C:/a` plus its NUL, then the empty string that ends the list.
|
||||
assert_eq!(bytes.len(), HEADER + (4 + 1 + 1) * 2);
|
||||
assert_eq!(&bytes[HEADER + 6..HEADER + 8], b"a\0");
|
||||
assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_outside_the_ansi_code_page_survive() {
|
||||
let list = paths(&["C:/工作/文档/测试.txt", "C:/a/"]);
|
||||
assert_eq!(decode(&encode(&list)), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_has_no_paths() {
|
||||
assert_eq!(decode(&encode(&[])), Vec::<PathBuf>::new());
|
||||
// A lone terminator is enough for the reader to stop.
|
||||
assert_eq!(decode(&[0u8; HEADER]), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_or_foreign_block_reads_as_no_paths() {
|
||||
assert_eq!(decode(&[]), Vec::<PathBuf>::new());
|
||||
assert_eq!(decode(&[1, 2, 3]), Vec::<PathBuf>::new());
|
||||
let mut far = encode(&paths(&["C:/a"]));
|
||||
far[0] = 0xff; // pFiles past the end
|
||||
assert_eq!(decode(&far), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ansi_list_is_still_read_before_the_terminator() {
|
||||
let mut bytes = vec![0u8; HEADER];
|
||||
bytes[..4].copy_from_slice(&(HEADER as u32).to_le_bytes());
|
||||
bytes[16] = 0; // fWide: the list is single-byte
|
||||
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
|
||||
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
@@ -39,7 +39,7 @@ impl Default for EditorGeneralSettings {
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
soft_wrap_indent: true,
|
||||
scroll_past_end: true,
|
||||
scroll_past_end: false,
|
||||
strip_trailing_scope: "modified".into(),
|
||||
keep_trailing_on_caret_line: true,
|
||||
remove_trailing_blank_lines: false,
|
||||
@@ -102,7 +102,7 @@ pub struct BreadcrumbsSettings {
|
||||
|
||||
impl Default for BreadcrumbsSettings {
|
||||
fn default() -> Self {
|
||||
Self { show: false, placement: "bottom".into(), disabled_languages: Vec::new() }
|
||||
Self { show: true, placement: "bottom".into(), disabled_languages: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,12 @@ pub struct CompletionSettings {
|
||||
pub auto_popup: bool,
|
||||
/// Insert the selected suggestion by pressing space, dot, or other context-dependent keys.
|
||||
pub insert_on_special_keys: bool,
|
||||
/// Insert a sole suggestion of explicit (⌃Space) completion right away.
|
||||
pub auto_insert_single: bool,
|
||||
/// Sort suggestions by name instead of relevance.
|
||||
pub sort_alphabetically: bool,
|
||||
/// Add `()` after function and method suggestions.
|
||||
pub insert_parentheses: bool,
|
||||
pub show_documentation: bool,
|
||||
pub documentation_delay: u32,
|
||||
pub parameter_info: bool,
|
||||
@@ -130,10 +136,13 @@ impl Default for CompletionSettings {
|
||||
case_sensitivity: "first".into(),
|
||||
auto_popup: true,
|
||||
insert_on_special_keys: false,
|
||||
show_documentation: true,
|
||||
auto_insert_single: true,
|
||||
sort_alphabetically: false,
|
||||
insert_parentheses: true,
|
||||
show_documentation: false,
|
||||
documentation_delay: 500,
|
||||
parameter_info: true,
|
||||
parameter_info_delay: 300,
|
||||
parameter_info_delay: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,13 +175,22 @@ impl Default for FoldingSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorTabsSettings {
|
||||
/// `top` | `none`.
|
||||
/// `top` | `bottom` | `none`.
|
||||
pub placement: String,
|
||||
pub multiple_rows: bool,
|
||||
/// One row that doesn't fit: `scroll` the tabs panel | `squeeze` the tabs.
|
||||
pub one_row_overflow: String,
|
||||
pub pinned_separate_row: bool,
|
||||
pub show_file_icon: bool,
|
||||
pub show_extension: bool,
|
||||
pub show_directory_for_non_unique: bool,
|
||||
/// Mark modified tabs with an asterisk.
|
||||
pub mark_modified: bool,
|
||||
pub show_full_path_on_hover: bool,
|
||||
pub show_close_button: bool,
|
||||
/// `right` | `left` (with `show_close_button`).
|
||||
pub close_button_position: String,
|
||||
pub sort_alphabetically: bool,
|
||||
pub preview_tab: bool,
|
||||
pub open_at_end: bool,
|
||||
pub tab_limit: u32,
|
||||
@@ -187,14 +205,21 @@ impl Default for EditorTabsSettings {
|
||||
Self {
|
||||
placement: "top".into(),
|
||||
multiple_rows: false,
|
||||
one_row_overflow: "scroll".into(),
|
||||
pinned_separate_row: false,
|
||||
show_file_icon: true,
|
||||
show_extension: true,
|
||||
show_directory_for_non_unique: true,
|
||||
mark_modified: false,
|
||||
show_full_path_on_hover: true,
|
||||
show_close_button: true,
|
||||
close_button_position: "right".into(),
|
||||
sort_alphabetically: false,
|
||||
preview_tab: true,
|
||||
open_at_end: false,
|
||||
tab_limit: 10,
|
||||
closing_policy: "unchanged".into(),
|
||||
activate_on_close: "mru".into(),
|
||||
tab_limit: 30,
|
||||
closing_policy: "lru".into(),
|
||||
activate_on_close: "left".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +239,7 @@ pub struct SmartKeysSettings {
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub smart_indent: bool,
|
||||
/// Backspace › Unindent: `nearest` indent position | `none`.
|
||||
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
|
||||
pub backspace_unindent: String,
|
||||
}
|
||||
|
||||
@@ -226,10 +251,10 @@ impl Default for SmartKeysSettings {
|
||||
pair_brackets: true,
|
||||
pair_quotes: true,
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: false,
|
||||
reformat_on_paste: "indentBlock".into(),
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
smart_indent: true,
|
||||
backspace_unindent: "nearest".into(),
|
||||
backspace_unindent: "autoindent".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,11 +300,38 @@ pub struct InlayHintsSettings {
|
||||
pub disabled_languages: Vec<String>,
|
||||
/// Code vision: usages above declarations.
|
||||
pub code_vision_usages: bool,
|
||||
/// Code vision: last author of a declaration (VCS blame) beside its usages.
|
||||
pub code_vision_author: bool,
|
||||
}
|
||||
|
||||
impl Default for InlayHintsSettings {
|
||||
fn default() -> Self {
|
||||
Self { types: true, parameters: true, other: true, disabled_languages: Vec::new(), code_vision_usages: true }
|
||||
Self {
|
||||
types: true,
|
||||
parameters: true,
|
||||
other: true,
|
||||
disabled_languages: Vec::new(),
|
||||
code_vision_usages: true,
|
||||
code_vision_author: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › General › Inline Completion: ghost text from language servers that
|
||||
/// support `textDocument/inlineCompletion` (LSP 3.18).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InlineCompletionSettings {
|
||||
pub enabled: bool,
|
||||
/// Ask only once typing has paused for `pause_ms`, instead of right after each change.
|
||||
pub only_on_pause: bool,
|
||||
pub pause_ms: u32,
|
||||
pub disabled_languages: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for InlineCompletionSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, only_on_pause: false, pause_ms: 500, disabled_languages: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +366,12 @@ pub struct LanguageCodeStyle {
|
||||
pub indent: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hard_wrap_at: Option<u32>,
|
||||
/// Comment Code › Line comment at first column.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_comment_at_first_column: Option<bool>,
|
||||
/// Comment Code › Add a space at line comment start.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_comment_add_space: Option<bool>,
|
||||
}
|
||||
|
||||
/// Editor › Code Style (General tab + per-language overrides).
|
||||
@@ -329,6 +387,14 @@ pub struct CodeStyleSettings {
|
||||
/// Enable EditorConfig support.
|
||||
pub editorconfig: bool,
|
||||
pub languages: BTreeMap<String, LanguageCodeStyle>,
|
||||
/// Formatter › Do not format: globs (`*.min.js`, `generated/**`) Reformat Code skips.
|
||||
pub do_not_format: Vec<String>,
|
||||
/// Formatter › Turn formatter on/off with markers in code comments.
|
||||
pub formatter_tags_enabled: bool,
|
||||
pub formatter_off_tag: String,
|
||||
pub formatter_on_tag: String,
|
||||
/// The markers are regular expressions.
|
||||
pub formatter_tags_regexp: bool,
|
||||
}
|
||||
|
||||
impl Default for CodeStyleSettings {
|
||||
@@ -340,6 +406,11 @@ impl Default for CodeStyleSettings {
|
||||
detect_indents: true,
|
||||
editorconfig: true,
|
||||
languages: BTreeMap::new(),
|
||||
do_not_format: Vec::new(),
|
||||
formatter_tags_enabled: false,
|
||||
formatter_off_tag: "@formatter:off".into(),
|
||||
formatter_on_tag: "@formatter:on".into(),
|
||||
formatter_tags_regexp: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ pub enum IdeEvent {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File Type).
|
||||
DocLanguage {
|
||||
doc: DocId,
|
||||
language: crate::LanguageId,
|
||||
},
|
||||
/// Full replacement of diagnostics for one file from one producer.
|
||||
Diagnostics {
|
||||
path: crate::IpcPath,
|
||||
@@ -62,6 +67,11 @@ pub enum IdeEvent {
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// A stopped language server left the project's list (disabled in settings, or a
|
||||
/// fallback that a preferred server took over from again).
|
||||
LanguageServerRemoved {
|
||||
server: ServerId,
|
||||
},
|
||||
/// A language server asked clients to re-request one kind of data for its
|
||||
/// open documents (`workspace/inlayHint/refresh`, …), e.g. after indexing.
|
||||
LspRefresh {
|
||||
@@ -95,6 +105,28 @@ pub enum IdeEvent {
|
||||
ProjectRootsChanged {
|
||||
root: crate::IpcPath,
|
||||
},
|
||||
/// A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`.
|
||||
Askpass {
|
||||
id: u64,
|
||||
prompt: String,
|
||||
kind: AskpassKind,
|
||||
},
|
||||
/// Prompt `id` was answered (in any window) or gave up.
|
||||
AskpassDone {
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an askpass prompt asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AskpassKind {
|
||||
/// Password or passphrase: hidden input.
|
||||
Secret,
|
||||
/// User name: visible input.
|
||||
Text,
|
||||
/// Yes / no (an unknown host key); answered with `yes` or `no`.
|
||||
Confirm,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -106,6 +138,9 @@ pub enum ServerStatus {
|
||||
Crashed,
|
||||
Stopped,
|
||||
Missing,
|
||||
/// Not started: its configuration can't launch it (e.g. no JDK new enough);
|
||||
/// the message says what to change, and a settings change retries.
|
||||
Misconfigured,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! "Ignored Files and Folders" (IDEA: Settings › File Types): file names the IDE
|
||||
//! treats as nonexistent — hidden from the project view and navigation, never
|
||||
//! indexed or searched, never offered as unversioned changes.
|
||||
|
||||
/// IDEA's default list, plus `.jj` and the Windows shell files.
|
||||
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.rbc",
|
||||
"*.yarb",
|
||||
"*~",
|
||||
".DS_Store",
|
||||
".git",
|
||||
".hg",
|
||||
".jj",
|
||||
".svn",
|
||||
"CVS",
|
||||
"__pycache__",
|
||||
"_svn",
|
||||
"vssver.scc",
|
||||
"vssver2.scc",
|
||||
"Thumbs.db",
|
||||
"desktop.ini",
|
||||
];
|
||||
|
||||
/// Matches single path segments against name patterns (`*` and `?` wildcards).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct IgnoredNames {
|
||||
exact: Vec<String>,
|
||||
wildcards: Vec<String>,
|
||||
}
|
||||
|
||||
impl IgnoredNames {
|
||||
pub fn new(patterns: &[String]) -> Self {
|
||||
let mut out = Self::default();
|
||||
for p in patterns.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.contains('/')) {
|
||||
if p.contains(['*', '?']) {
|
||||
out.wildcards.push(p.to_owned());
|
||||
} else {
|
||||
out.exact.push(p.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_ignored_name(&self, name: &str) -> bool {
|
||||
self.exact.iter().any(|e| e == name) || self.wildcards.iter().any(|w| wildcard(w.as_bytes(), name.as_bytes()))
|
||||
}
|
||||
|
||||
/// Whether any segment of a `/`-separated relative path is ignored.
|
||||
pub fn is_ignored_path(&self, rel: &str) -> bool {
|
||||
rel.split('/').any(|seg| !seg.is_empty() && self.is_ignored_name(seg))
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcard(p: &[u8], s: &[u8]) -> bool {
|
||||
let (mut pi, mut si, mut star, mut mark) = (0, 0, None, 0);
|
||||
while si < s.len() {
|
||||
if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
|
||||
pi += 1;
|
||||
si += 1;
|
||||
} else if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some(pi);
|
||||
pi += 1;
|
||||
mark = si;
|
||||
} else if let Some(st) = star {
|
||||
pi = st + 1;
|
||||
mark += 1;
|
||||
si = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p[pi..].iter().all(|&c| c == b'*')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn defaults() -> IgnoredNames {
|
||||
IgnoredNames::new(&DEFAULT_IGNORED_NAMES.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_names_and_wildcards() {
|
||||
let m = defaults();
|
||||
for name in [".DS_Store", "__pycache__", "a.pyc", "notes.txt~", "Thumbs.db"] {
|
||||
assert!(m.is_ignored_name(name), "{name}");
|
||||
}
|
||||
for name in ["DS_Store", "a.py", "pyc", "src", ".gitignore"] {
|
||||
assert!(!m.is_ignored_name(name), "{name}");
|
||||
}
|
||||
assert!(wildcard(b"a?c*", b"abcdef"));
|
||||
assert!(!wildcard(b"a?c", b"abcd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_any_path_segment() {
|
||||
let m = defaults();
|
||||
assert!(m.is_ignored_path("src/.DS_Store"));
|
||||
assert!(m.is_ignored_path("pkg/__pycache__/"));
|
||||
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
|
||||
assert!(!m.is_ignored_path("src/main.rs"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Settings › Editor › Inspections: inspection profiles. `Default` and the
|
||||
//! user's IDE-level copies live in the IDE (global) settings, `Project Default`
|
||||
//! and project-level copies in the project's; the project picks which one is in
|
||||
//! effect. The UI applies the profile to diagnostics. Also Editor › Natural
|
||||
//! Languages › Spelling (the Typo inspection's dictionaries).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InspectionSettings {
|
||||
/// Profile in effect for the project: `default`, `project`, or a copy
|
||||
/// as `ide:<key>` / `project:<key>`.
|
||||
pub profile: String,
|
||||
/// The `Default` profile (IDE level): inspection id → override.
|
||||
pub default: BTreeMap<String, InspectionOverride>,
|
||||
/// The `Project Default` profile (stored with the project).
|
||||
pub project: BTreeMap<String, InspectionOverride>,
|
||||
/// Profiles copied at IDE level (global settings), by key.
|
||||
pub ide_profiles: BTreeMap<String, CustomInspectionProfile>,
|
||||
/// Profiles copied at project level (project settings), by key.
|
||||
pub project_profiles: BTreeMap<String, CustomInspectionProfile>,
|
||||
}
|
||||
|
||||
impl Default for InspectionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: "default".into(),
|
||||
default: BTreeMap::new(),
|
||||
project: BTreeMap::new(),
|
||||
ide_profiles: BTreeMap::new(),
|
||||
project_profiles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CustomInspectionProfile {
|
||||
pub name: String,
|
||||
pub overrides: BTreeMap<String, InspectionOverride>,
|
||||
}
|
||||
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SpellingSettings {
|
||||
/// Application-level dictionary: accepted words (IDE settings).
|
||||
pub ide_words: Vec<String>,
|
||||
/// Project-level dictionary: accepted words (project settings).
|
||||
pub project_words: Vec<String>,
|
||||
/// Custom dictionaries: plain word lists or Hunspell `.dic` files.
|
||||
pub custom_dictionaries: Vec<String>,
|
||||
}
|
||||
|
||||
/// Changes to one inspection; absent fields keep the inspection's defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InspectionOverride {
|
||||
#[specta(optional)]
|
||||
pub enabled: Option<bool>,
|
||||
#[specta(optional)]
|
||||
pub severity: Option<InspectionLevel>,
|
||||
/// The inspection's own options (Typo: `processCode`, `processLiterals`,
|
||||
/// `processComments`); absent ones keep their defaults.
|
||||
#[specta(optional)]
|
||||
pub options: Option<BTreeMap<String, bool>>,
|
||||
}
|
||||
|
||||
/// IDEA's highlighting levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InspectionLevel {
|
||||
Error,
|
||||
Warning,
|
||||
WeakWarning,
|
||||
ServerProblem,
|
||||
Typo,
|
||||
/// Reported (Problems, Inspect Code) but not highlighted in the editor.
|
||||
None,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overrides_parse_partially() {
|
||||
let s: InspectionSettings = serde_json::from_str(
|
||||
r#"{"default":{"lsp:ruff":{"enabled":false},"lsp:ruff:F401":{"severity":"weakWarning"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.profile, "default");
|
||||
assert_eq!(s.default["lsp:ruff"].enabled, Some(false));
|
||||
assert_eq!(s.default["lsp:ruff:F401"].severity, Some(InspectionLevel::WeakWarning));
|
||||
assert!(s.project.is_empty());
|
||||
assert!(s.ide_profiles.is_empty());
|
||||
let s: InspectionSettings = serde_json::from_str(
|
||||
r#"{"profile":"ide:strict","ideProfiles":{"strict":{"name":"Strict","overrides":{"lsp:ruff":{"severity":"error"}}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.ide_profiles["strict"].name, "Strict");
|
||||
assert_eq!(s.ide_profiles["strict"].overrides["lsp:ruff"].severity, Some(InspectionLevel::Error));
|
||||
assert!(serde_json::from_str::<InspectionSettings>(r#"{"default":{"x":{"severity":"loud"}}}"#).is_err());
|
||||
let s: InspectionSettings =
|
||||
serde_json::from_str(r#"{"default":{"builtin:spelling":{"options":{"processCode":false}}}}"#).unwrap();
|
||||
assert!(!s.default["builtin:spelling"].options.as_ref().unwrap()["processCode"]);
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,12 @@ pub mod editor_settings;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod text;
|
||||
pub mod workflow_settings;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Child-process helpers shared by every crate that spawns tools.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
|
||||
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
|
||||
pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Readies `cmd` for spawning any child (java, node, curl, git, a language server, ...).
|
||||
/// For a `tokio::process::Command`, pass `cmd.as_std_mut()`.
|
||||
///
|
||||
/// - Windows: release builds use the GUI subsystem, so without `CREATE_NO_WINDOW` every
|
||||
/// console child flashes a `cmd` window.
|
||||
/// - Linux AppImage: see [`host_env`].
|
||||
///
|
||||
/// Variables the call site has already set are kept; call it before `env_clear`.
|
||||
pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
host_env(cmd)
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Variables the call site has already set are kept.
|
||||
/// A no-op outside an AppImage.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
let fixes = appimage_fixes();
|
||||
if fixes.is_empty() {
|
||||
return cmd;
|
||||
}
|
||||
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
|
||||
for (key, value) in fixes.into_iter().filter(|(k, _)| !explicit.contains(k)) {
|
||||
match value {
|
||||
Some(v) => cmd.env(key, v),
|
||||
None => cmd.env_remove(key),
|
||||
};
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The [`host_env`] changes as `(name, value)` pairs, `None` meaning "remove", for spawners
|
||||
/// that are not a `std::process::Command` (a PTY `CommandBuilder`). Apply before the call
|
||||
/// site's own variables. Empty outside an AppImage.
|
||||
pub fn host_env_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
appimage_fixes()
|
||||
}
|
||||
|
||||
/// This process's environment as a child should inherit it (see [`host_env`]), for call
|
||||
/// sites that build a child's environment from the IDE's own instead of inheriting it.
|
||||
pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
|
||||
let fixes = appimage_fixes();
|
||||
std::env::vars_os().filter_map(move |(k, v)| match fixes.iter().find(|(f, _)| *f == k) {
|
||||
Some((_, fixed)) => fixed.clone().map(|v| (k, v)),
|
||||
None => Some((k, v)),
|
||||
})
|
||||
}
|
||||
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
|
||||
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
let key = key.as_ref();
|
||||
match appimage_fixes().into_iter().find(|(k, _)| k == key) {
|
||||
Some((_, fixed)) => fixed,
|
||||
None => std::env::var_os(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read on every call, not cached: startup rewrites `PATH` (login shell, managed tools).
|
||||
fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
if !cfg!(target_os = "linux") || std::env::var_os("APPDIR").is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
appimage_env_fixes(&std::env::vars_os().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Variables the AppImage runtime sets to describe the IDE's own launch.
|
||||
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
|
||||
|
||||
/// What to change in `vars` (an environment) so a child sees the host's environment rather
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
|
||||
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
|
||||
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
|
||||
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
|
||||
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
|
||||
let Some(appdir) =
|
||||
get("APPDIR").and_then(|v| v.to_str()).map(|v| v.trim_end_matches('/')).filter(|v| !v.is_empty())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
let mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
let fixed = if APPIMAGE_LAUNCH_VARS.contains(&name) {
|
||||
None
|
||||
} else if value.split(':').any(inside) {
|
||||
match get(&format!("{name}_ORIG")) {
|
||||
Some(orig) => Some(orig.clone()),
|
||||
None => {
|
||||
let kept: Vec<&str> = value.split(':').filter(|e| !e.is_empty() && !inside(e)).collect();
|
||||
Some(OsString::from(kept.join(":")))
|
||||
}
|
||||
}
|
||||
} else if name == "GTK_THEME" && value.is_empty() {
|
||||
// linuxdeploy-plugin-gtk exports `GTK_THEME="$APPIMAGE_GTK_THEME"`, usually empty.
|
||||
None
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
fixes.push((key.clone(), fixed.filter(|v| !v.is_empty())));
|
||||
}
|
||||
fixes
|
||||
}
|
||||
|
||||
static SHELL_ENV: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
|
||||
|
||||
/// Records the login shell's environment, read once at startup off the main thread.
|
||||
pub fn set_shell_env(vars: Vec<(OsString, OsString)>) {
|
||||
let _ = SHELL_ENV.set(vars);
|
||||
}
|
||||
|
||||
/// The login shell's variables this process lacks: an app started from Finder/Dock gets only
|
||||
/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...).
|
||||
/// Apply before a call site's own variables so those win. Empty until the shell has been read.
|
||||
pub fn shell_env() -> impl Iterator<Item = (&'static OsStr, &'static OsStr)> {
|
||||
SHELL_ENV
|
||||
.get()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, _)| std::env::var_os(k).is_none())
|
||||
.map(|(k, v)| (k.as_os_str(), v.as_os_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
let vars: Vec<_> = shell_env().collect();
|
||||
assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]);
|
||||
}
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
|
||||
pairs.iter().map(|(k, v)| (OsString::from(k), OsString::from(v))).collect()
|
||||
}
|
||||
|
||||
fn fixes(pairs: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
|
||||
appimage_env_fixes(&env(pairs))
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into_string().unwrap(), v.map(|v| v.into_string().unwrap())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fix(name: &str, value: Option<&str>) -> (String, Option<String>) {
|
||||
(name.to_owned(), value.map(str::to_owned))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_entries_are_dropped_and_host_entries_kept() {
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_OpenIDabc"),
|
||||
(
|
||||
"LD_LIBRARY_PATH",
|
||||
"/tmp/.mount_OpenIDabc/usr/lib/:/tmp/.mount_OpenIDabc/usr/lib/x86_64-linux-gnu/:/opt/cuda/lib64:/usr/local/lib",
|
||||
),
|
||||
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
|
||||
("HOME", "/home/me"),
|
||||
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
fix("APPDIR", None),
|
||||
fix("LD_LIBRARY_PATH", Some("/opt/cuda/lib64:/usr/local/lib")),
|
||||
fix("PATH", Some("/usr/local/bin:/usr/bin")),
|
||||
fix("XDG_DATA_DIRS", Some("/usr/share:/usr/local/share:/usr/share")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_left_empty_are_removed() {
|
||||
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
|
||||
let got = fixes(&[
|
||||
("APPIMAGE", "/opt/openidea/OpenIDEA.AppImage"),
|
||||
("APPDIR", "/tmp/.mount_x/"),
|
||||
("ARGV0", "/opt/openidea/OpenIDEA.AppImage"),
|
||||
("OWD", "/home/me"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_x/usr/lib/:/tmp/.mount_x/lib/:"),
|
||||
("GIO_MODULE_DIR", "/tmp/.mount_x/usr/lib/gio/modules"),
|
||||
("GTK_DATA_PREFIX", "/tmp/.mount_x"),
|
||||
("GDK_PIXBUF_MODULE_FILE", "/tmp/.mount_x/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"),
|
||||
("GST_PLUGIN_SYSTEM_PATH_1_0", "/tmp/.mount_x/usr/lib/gstreamer-1.0"),
|
||||
("GTK_THEME", ""),
|
||||
]);
|
||||
let names: Vec<_> = got.iter().filter(|(_, v)| v.is_none()).map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"APPIMAGE",
|
||||
"APPDIR",
|
||||
"ARGV0",
|
||||
"OWD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"GIO_MODULE_DIR",
|
||||
"GTK_DATA_PREFIX",
|
||||
"GDK_PIXBUF_MODULE_FILE",
|
||||
"GST_PLUGIN_SYSTEM_PATH_1_0",
|
||||
"GTK_THEME",
|
||||
]
|
||||
);
|
||||
assert_eq!(names.len(), got.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_original_value_wins() {
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_x"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_x/usr/lib:/opt/lib"),
|
||||
("LD_LIBRARY_PATH_ORIG", "/opt/lib:/home/me/lib"),
|
||||
("PYTHONHOME", "/tmp/.mount_x/usr"),
|
||||
("PYTHONHOME_ORIG", ""),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[fix("APPDIR", None), fix("LD_LIBRARY_PATH", Some("/opt/lib:/home/me/lib")), fix("PYTHONHOME", None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_an_appimage_nothing_changes() {
|
||||
let plain = [("LD_LIBRARY_PATH", "/opt/lib:"), ("PATH", "/usr/bin"), ("GTK_THEME", "")];
|
||||
assert!(fixes(&plain).is_empty());
|
||||
assert!(fixes(&[("APPDIR", ""), ("PATH", "/usr/bin")]).is_empty());
|
||||
assert!(fixes(&[("APPDIR", "/"), ("PATH", "/usr/bin")]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_env_keeps_what_the_call_site_set() {
|
||||
let mut cmd = Command::new("true");
|
||||
cmd.env("PATH", "/custom");
|
||||
prepare(&mut cmd);
|
||||
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// How an SSH connection authenticates.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SshAuth {
|
||||
Password,
|
||||
/// A private key file, optionally protected by a passphrase.
|
||||
KeyPair,
|
||||
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
|
||||
#[default]
|
||||
OpenSsh,
|
||||
}
|
||||
|
||||
/// Tools › SSH Configurations.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SshSettings {
|
||||
pub configs: Vec<SshConfiguration>,
|
||||
}
|
||||
|
||||
/// A named SSH host, shared by terminals, deployment, run targets and data sources.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SshConfiguration {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`].
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub user: String,
|
||||
pub auth: SshAuth,
|
||||
/// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory.
|
||||
pub key_file: String,
|
||||
/// Keep the password / key passphrase in the saved passwords.
|
||||
pub save_secret: bool,
|
||||
}
|
||||
|
||||
impl SshConfiguration {
|
||||
/// `user@host:port` with what is known (the name when the host is empty).
|
||||
pub fn label(&self) -> String {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let mut s = String::new();
|
||||
if !self.user.trim().is_empty() {
|
||||
s.push_str(self.user.trim());
|
||||
s.push('@');
|
||||
}
|
||||
s.push_str(host);
|
||||
if let Some(p) = self.port {
|
||||
s.push_str(&format!(":{p}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// When local changes are uploaded to the default deployment server.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AutoUpload {
|
||||
#[default]
|
||||
Never,
|
||||
/// Files saved with Save / Save All (⌘S).
|
||||
OnExplicitSave,
|
||||
/// Every save, including autosave and external changes.
|
||||
Always,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Deployment (and its Options page).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
/// Delete remote files when their local file is deleted (automatic upload).
|
||||
pub delete_remote: bool,
|
||||
/// Keep local modification times on uploaded files.
|
||||
pub preserve_timestamps: bool,
|
||||
/// File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI).
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
preserve_timestamps: true,
|
||||
exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Absolute remote folder the mappings are relative to; empty = the login directory.
|
||||
pub root_path: String,
|
||||
/// Web server URL of the root (Open in Browser on the deployed file).
|
||||
pub web_url: String,
|
||||
pub mappings: Vec<DeploymentMapping>,
|
||||
/// Remote paths (relative to the root) never touched.
|
||||
pub excluded_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentMapping {
|
||||
/// Project-relative folder; empty = the project root.
|
||||
pub local: String,
|
||||
/// Remote folder, relative to the server root (a leading `/` is the root too).
|
||||
pub deployment: String,
|
||||
/// Web path, relative to the server's URL.
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run.
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use crate::editor_settings::*;
|
||||
pub use crate::inspection_settings::*;
|
||||
pub use crate::remote_settings::*;
|
||||
pub use crate::workflow_settings::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -37,6 +39,88 @@ pub struct Settings {
|
||||
pub terminal: TerminalSettings,
|
||||
pub actions_on_save: ActionsOnSaveSettings,
|
||||
pub external_tools: Vec<ExternalTool>,
|
||||
/// Language servers / debug adapters at a user-chosen location, by the
|
||||
/// command the language definition launches (`elixir-ls`, `codelldb`).
|
||||
pub tools: BTreeMap<String, ToolSettings>,
|
||||
/// Per language server, by name (`rust-analyzer`): launch and protocol
|
||||
/// options on top of the language definition, or a server defined here.
|
||||
pub language_servers: BTreeMap<String, LanguageServerSettings>,
|
||||
/// Editor › Inspections profiles.
|
||||
pub inspections: InspectionSettings,
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
pub spelling: SpellingSettings,
|
||||
/// Tools › SSH Configurations.
|
||||
pub ssh: SshSettings,
|
||||
/// Build, Execution, Deployment › Deployment.
|
||||
pub deployment: DeploymentSettings,
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ToolSettings {
|
||||
/// Executable to launch instead of the managed / `PATH` one; empty = auto-detect.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerSettings {
|
||||
/// `false` keeps the server from starting for any language.
|
||||
pub enabled: bool,
|
||||
/// Executable to launch instead of the definition's; required for a server
|
||||
/// defined here (one with `languages`).
|
||||
pub command: String,
|
||||
/// Replaces the definition's arguments.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Added to the server's environment.
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Merged over the definition's `initializationOptions` (sent once at startup).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional, type = Option<specta_typescript::Unknown>)]
|
||||
pub initialization_options: Option<serde_json::Value>,
|
||||
/// Answers the server's `workspace/configuration` requests; changes are pushed
|
||||
/// with `workspace/didChangeConfiguration` without a restart.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional, type = Option<specta_typescript::Unknown>)]
|
||||
pub settings: Option<serde_json::Value>,
|
||||
/// Language ids a server defined here serves (`python`); empty for the servers
|
||||
/// of the language definitions.
|
||||
pub languages: Vec<String>,
|
||||
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
|
||||
pub root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for LanguageServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
command: String::new(),
|
||||
args: None,
|
||||
env: BTreeMap::new(),
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
languages: Vec::new(),
|
||||
root_markers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageServerSettings {
|
||||
/// A server defined in settings rather than by a language definition.
|
||||
pub fn is_custom(&self) -> bool {
|
||||
!self.languages.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The configured executable for `command`, if any (`~/` not yet expanded).
|
||||
pub fn tool_path(&self, command: &str) -> Option<&str> {
|
||||
self.tools.get(command).map(|t| t.path.trim()).filter(|p| !p.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -59,6 +143,13 @@ impl Default for Settings {
|
||||
terminal: TerminalSettings::default(),
|
||||
actions_on_save: ActionsOnSaveSettings::default(),
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
language_servers: BTreeMap::new(),
|
||||
inspections: InspectionSettings::default(),
|
||||
spelling: SpellingSettings::default(),
|
||||
ssh: SshSettings::default(),
|
||||
deployment: DeploymentSettings::default(),
|
||||
run_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +182,11 @@ pub struct GeneralSettings {
|
||||
pub save_on_idle: bool,
|
||||
pub idle_seconds: u32,
|
||||
pub date_format: DateFormatSettings,
|
||||
/// File › Power Save Mode: no automatic popups (completion, parameter info,
|
||||
/// quick documentation) or background highlighting (usages, code vision).
|
||||
pub power_save_mode: bool,
|
||||
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
|
||||
pub project_name: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -103,6 +199,8 @@ impl Default for GeneralSettings {
|
||||
save_on_idle: false,
|
||||
idle_seconds: 15,
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +290,20 @@ pub struct AppearanceSettings {
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
/// Customize Main Toolbar; `None` = the default toolbar.
|
||||
pub main_toolbar: Option<MainToolbarSettings>,
|
||||
}
|
||||
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
|
||||
/// (registered later) keep their default place.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MainToolbarSettings {
|
||||
pub left: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
/// Widgets the user removed.
|
||||
pub hidden: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AppearanceSettings {
|
||||
@@ -218,9 +330,10 @@ impl Default for AppearanceSettings {
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: true,
|
||||
show_tool_window_names: false,
|
||||
widescreen_tool_windows: false,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,9 +355,18 @@ pub struct EditorSettings {
|
||||
pub color_scheme: String,
|
||||
/// Color Scheme edits: scheme id → attribute id → override.
|
||||
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
|
||||
pub semantic_highlighting: bool,
|
||||
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
|
||||
pub rainbow_brackets: bool,
|
||||
pub ligatures: bool,
|
||||
/// Fallback font for glyphs the main font lacks; empty = none.
|
||||
pub fallback_font: String,
|
||||
/// Typography Settings › Main font weight (CSS 100–900), as IDEA.
|
||||
pub font_weight: u16,
|
||||
/// Typography Settings › Bold font weight: wherever the editor renders bold.
|
||||
pub bold_font_weight: u16,
|
||||
pub general: EditorGeneralSettings,
|
||||
pub appearance: EditorAppearanceSettings,
|
||||
pub breadcrumbs: BreadcrumbsSettings,
|
||||
@@ -255,6 +377,7 @@ pub struct EditorSettings {
|
||||
pub smart_keys: SmartKeysSettings,
|
||||
pub code_editing: CodeEditingSettings,
|
||||
pub inlay: InlayHintsSettings,
|
||||
pub inline_completion: InlineCompletionSettings,
|
||||
/// Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`).
|
||||
pub gutter_icons_disabled: Vec<String>,
|
||||
pub show_gutter_icons: bool,
|
||||
@@ -277,8 +400,12 @@ impl Default for EditorSettings {
|
||||
format_on_save: false,
|
||||
color_scheme: "theme-default".into(),
|
||||
color_overrides: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
fallback_font: String::new(),
|
||||
font_weight: 400,
|
||||
bold_font_weight: 700,
|
||||
general: EditorGeneralSettings::default(),
|
||||
appearance: EditorAppearanceSettings::default(),
|
||||
breadcrumbs: BreadcrumbsSettings::default(),
|
||||
@@ -289,6 +416,7 @@ impl Default for EditorSettings {
|
||||
smart_keys: SmartKeysSettings::default(),
|
||||
code_editing: CodeEditingSettings::default(),
|
||||
inlay: InlayHintsSettings::default(),
|
||||
inline_completion: InlineCompletionSettings::default(),
|
||||
gutter_icons_disabled: Vec::new(),
|
||||
show_gutter_icons: true,
|
||||
code_style: CodeStyleSettings::default(),
|
||||
@@ -303,12 +431,30 @@ pub struct FileSettings {
|
||||
/// Save on focus loss / tab switch, like IDEA.
|
||||
pub auto_save: bool,
|
||||
pub exclude: Vec<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
pub trim_trailing_whitespace: bool,
|
||||
pub ensure_final_newline: bool,
|
||||
/// Local History keeps changes from this many working days (days with changes), like IDEA.
|
||||
pub local_history_days: u32,
|
||||
/// Upper bound for a project's Local History in MB; the oldest revisions go first.
|
||||
pub local_history_max_mb: u32,
|
||||
/// File Types › Recognized File Types: the user's changes, by language id.
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileTypeAssociation {
|
||||
/// Added file name patterns: `*.foo`, `Jenkinsfile`, `Dockerfile.*` (`*` and `?` wildcards).
|
||||
pub patterns: Vec<String>,
|
||||
/// Patterns of the definition (`*.ext`, file names) the user removed.
|
||||
pub removed_patterns: Vec<String>,
|
||||
/// Interpreters run by a `#!` first line, e.g. `node` for `#!/usr/bin/env node`.
|
||||
pub hashbangs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for FileSettings {
|
||||
@@ -316,10 +462,13 @@ impl Default for FileSettings {
|
||||
Self {
|
||||
auto_save: true,
|
||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
||||
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
|
||||
trim_trailing_whitespace: true,
|
||||
ensure_final_newline: true,
|
||||
local_history_days: 5,
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +481,7 @@ pub struct LanguagesSettings {
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub java: JavaSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -394,6 +544,35 @@ pub struct GoSettings {
|
||||
pub gopath: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct JavaSettings {
|
||||
/// Project JDK home; empty = per module, the installed JDK matching the
|
||||
/// Java version it targets.
|
||||
pub jdk: String,
|
||||
/// JDK home the Java language server (jdtls) runs on, apart from the project
|
||||
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
|
||||
/// else the first installed one that is.
|
||||
pub jdtls_jdk: String,
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
}
|
||||
|
||||
/// Build Tools › Maven.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MavenSettings {
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TypeScriptSettings {
|
||||
@@ -420,12 +599,23 @@ pub struct ProjectStructureSettings {
|
||||
pub excluded: Vec<String>,
|
||||
/// Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked.
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
}
|
||||
|
||||
impl ProjectStructureSettings {
|
||||
/// The "Exclude files" name patterns.
|
||||
pub fn exclude_file_patterns(&self) -> Vec<String> {
|
||||
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// `files.exclude` plus the Project Structure excluded folders as globs.
|
||||
/// `files.exclude`, the ignored names and the Project Structure excluded folders as globs.
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
let mut out = self.files.exclude.clone();
|
||||
let mut out = self.file_excludes();
|
||||
for dir in &self.project_structure.excluded {
|
||||
let dir = dir.trim().trim_matches('/');
|
||||
if !dir.is_empty() {
|
||||
@@ -434,12 +624,35 @@ impl Settings {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `files.exclude`, `files.ignoredNames` and the Project Structure "Exclude files"
|
||||
/// patterns (a bare name matches at any depth).
|
||||
pub fn file_excludes(&self) -> Vec<String> {
|
||||
let mut out = self.files.exclude.clone();
|
||||
out.extend(self.files.ignored_names.iter().filter(|n| !n.trim().is_empty()).cloned());
|
||||
out.extend(self.project_structure.exclude_file_patterns().into_iter().filter(|p| !p.contains('/')));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn ignored_names(&self) -> crate::ignored::IgnoredNames {
|
||||
crate::ignored::IgnoredNames::new(&self.files.ignored_names)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["editor"]["fontWeight"], 400);
|
||||
assert_eq!(json["editor"]["boldFontWeight"], 700);
|
||||
let s: Settings = serde_json::from_str(r#"{"editor":{"fontWeight":300,"boldFontWeight":600}}"#).unwrap();
|
||||
assert_eq!((s.editor.font_weight, s.editor.bold_font_weight), (300, 600));
|
||||
assert_eq!(s.editor.font_size, 13.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keymap_overrides_are_camel_case_and_default_empty() {
|
||||
let s: Settings =
|
||||
@@ -462,4 +675,15 @@ mod tests {
|
||||
assert!(k.overrides["file.save"].is_empty());
|
||||
assert_eq!(serde_json::to_value(Settings::default()).unwrap()["customKeymaps"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_files_patterns_join_the_file_excludes() {
|
||||
let s: Settings = serde_json::from_str(
|
||||
r#"{"files":{"exclude":[],"ignoredNames":[]},"projectStructure":{"excludeFiles":" *.log ; tmp*;;"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.project_structure.exclude_file_patterns(), vec!["*.log", "tmp*"]);
|
||||
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,11 @@ pub struct Diagnostic {
|
||||
pub unnecessary: bool,
|
||||
#[serde(default)]
|
||||
pub deprecated: bool,
|
||||
/// Language server (or tool) that reported it, e.g. `basedpyright`; `source`
|
||||
/// is what the producer calls itself. Inspection profiles key on this.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
/// Symbol kinds, a stable subset of LSP `SymbolKind`.
|
||||
@@ -168,6 +173,17 @@ impl SymbolKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Declared (or conventional) visibility of an outline symbol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Protected,
|
||||
/// Package / module / assembly scope (Java default, `internal`, `pub(crate)`, Go unexported).
|
||||
Package,
|
||||
Private,
|
||||
}
|
||||
|
||||
/// A node of a document outline (Structure tool window, breadcrumbs).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct OutlineItem {
|
||||
@@ -179,6 +195,10 @@ pub struct OutlineItem {
|
||||
/// The identifier itself; where navigation lands.
|
||||
pub selection_range: Range,
|
||||
pub children: Vec<OutlineItem>,
|
||||
/// Unknown (`None`) for language-server symbols and languages without the notion.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub visibility: Option<Visibility>,
|
||||
}
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct VcsSettings {
|
||||
pub git: GitSettings,
|
||||
pub commit: CommitSettings,
|
||||
pub confirmation: VcsConfirmationSettings,
|
||||
pub shelf: ShelfSettings,
|
||||
/// Issue Navigation: commit-message patterns turned into links.
|
||||
pub issue_navigation: Vec<IssueLink>,
|
||||
}
|
||||
@@ -24,12 +25,14 @@ pub struct VcsSettings {
|
||||
impl Default for VcsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_dirty_directories: true,
|
||||
// IDEA's VcsConfiguration.SHOW_DIRTY_RECURSIVELY is off by default.
|
||||
show_dirty_directories: false,
|
||||
limit_history: true,
|
||||
history_limit: 1000,
|
||||
git: GitSettings::default(),
|
||||
commit: CommitSettings::default(),
|
||||
confirmation: VcsConfirmationSettings::default(),
|
||||
shelf: ShelfSettings::default(),
|
||||
issue_navigation: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +55,17 @@ pub struct GitSettings {
|
||||
pub protected_branches: String,
|
||||
/// Initial state of Commit Options › Sign-off Commit.
|
||||
pub sign_off: bool,
|
||||
/// Ask before committing files larger than `large_file_mb`.
|
||||
pub warn_large_files: bool,
|
||||
pub large_file_mb: u32,
|
||||
/// Ask before committing names that are invalid on Windows or differ only in case.
|
||||
pub warn_bad_file_names: bool,
|
||||
/// Commit and Push opens the Push dialog; otherwise it pushes right away.
|
||||
pub show_push_dialog: bool,
|
||||
/// With `show_push_dialog`: only for commits to protected branches.
|
||||
pub push_dialog_only_protected: bool,
|
||||
/// Stashes and shelves share one tab.
|
||||
pub combine_stash_and_shelf: bool,
|
||||
}
|
||||
|
||||
impl Default for GitSettings {
|
||||
@@ -65,6 +79,12 @@ impl Default for GitSettings {
|
||||
incoming_check: "auto".into(),
|
||||
protected_branches: "master; main".into(),
|
||||
sign_off: false,
|
||||
warn_large_files: true,
|
||||
large_file_mb: 50,
|
||||
warn_bad_file_names: true,
|
||||
show_push_dialog: true,
|
||||
push_dialog_only_protected: false,
|
||||
combine_stash_and_shelf: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +93,11 @@ impl Default for GitSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CommitSettings {
|
||||
/// Commit from the Commit tool window; off = Local Changes in the Git tool
|
||||
/// window and a Commit dialog.
|
||||
pub non_modal: bool,
|
||||
/// Start with an empty message instead of the last one / the changelist's.
|
||||
pub clear_initial_message: bool,
|
||||
pub analyze_code: bool,
|
||||
pub check_todo: bool,
|
||||
pub reformat: bool,
|
||||
@@ -90,6 +115,8 @@ pub struct CommitSettings {
|
||||
impl Default for CommitSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
non_modal: true,
|
||||
clear_initial_message: false,
|
||||
analyze_code: true,
|
||||
check_todo: true,
|
||||
reformat: false,
|
||||
@@ -106,20 +133,40 @@ impl Default for CommitSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Version Control › Confirmation. Values: `ask` | `silently` | `never`.
|
||||
/// Version Control › Confirmation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct VcsConfirmationSettings {
|
||||
/// `ask` | `silently` | `never`.
|
||||
pub on_create: String,
|
||||
pub on_delete: String,
|
||||
/// Update Project opens its options dialog first.
|
||||
pub show_update_options: bool,
|
||||
pub confirm_drop_commits: bool,
|
||||
/// After a patch is saved: `ask` | `no` | `reveal` (show it in the file manager).
|
||||
pub on_patch_created: String,
|
||||
}
|
||||
|
||||
impl Default for VcsConfirmationSettings {
|
||||
fn default() -> Self {
|
||||
Self { on_create: "ask".into(), on_delete: "ask".into() }
|
||||
Self {
|
||||
on_create: "ask".into(),
|
||||
on_delete: "ask".into(),
|
||||
show_update_options: true,
|
||||
confirm_drop_commits: true,
|
||||
on_patch_created: "ask".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version Control › Shelf.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ShelfSettings {
|
||||
/// Initial state of Unshelve's "Remove successfully applied files from the shelf".
|
||||
pub remove_applied: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct IssueLink {
|
||||
@@ -211,6 +258,8 @@ impl Default for PythonDebuggerSettings {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
pub show_on_start: bool,
|
||||
/// Grace period between interrupt and kill when stopping a process.
|
||||
pub termination_timeout_ms: u32,
|
||||
@@ -218,7 +267,7 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +290,8 @@ pub struct TerminalSettings {
|
||||
pub shell_integration: bool,
|
||||
pub highlight_hyperlinks: bool,
|
||||
pub option_as_meta: bool,
|
||||
/// Escape moves focus to the editor instead of reaching the shell.
|
||||
pub escape_to_editor: bool,
|
||||
/// `block` | `underline` | `bar`.
|
||||
pub cursor_shape: String,
|
||||
pub cursor_blink: bool,
|
||||
@@ -249,6 +300,10 @@ pub struct TerminalSettings {
|
||||
pub font_size: f32,
|
||||
/// Multiple of the cell height.
|
||||
pub line_height: f32,
|
||||
/// Font tried for glyphs the main font lacks; empty = none.
|
||||
pub fallback_font_family: String,
|
||||
/// Multiple of the cell width.
|
||||
pub column_width: f32,
|
||||
}
|
||||
|
||||
impl Default for TerminalSettings {
|
||||
@@ -267,12 +322,15 @@ impl Default for TerminalSettings {
|
||||
shell_integration: true,
|
||||
highlight_hyperlinks: true,
|
||||
option_as_meta: false,
|
||||
escape_to_editor: false,
|
||||
cursor_shape: "block".into(),
|
||||
cursor_blink: true,
|
||||
use_editor_font: true,
|
||||
font_family: "JetBrains Mono".into(),
|
||||
font_size: 13.0,
|
||||
line_height: 1.0,
|
||||
fallback_font_family: String::new(),
|
||||
column_width: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! "Import a report collected in CI from disk": the format is sniffed from the
|
||||
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
let head = text.trim_start_matches('\u{feff}').trim_start();
|
||||
if head.starts_with('<') {
|
||||
return head.contains("<report").then_some(Format::Jacoco);
|
||||
}
|
||||
let first = head.lines().next().unwrap_or_default();
|
||||
if first.starts_with("mode:") {
|
||||
return Some(Format::GoProfile);
|
||||
}
|
||||
head.lines().any(|l| l.trim_start().starts_with("SF:")).then_some(Format::Lcov)
|
||||
}
|
||||
|
||||
/// Reads the report at `path` for the project at `root`. Blocking.
|
||||
pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut suite = match detect(&text) {
|
||||
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
|
||||
Some(Format::GoProfile) => {
|
||||
let (module, module_dir) = go_module_for(root, &text);
|
||||
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
|
||||
}
|
||||
Some(Format::Jacoco) => {
|
||||
// A report inside the project (`<module>/target/site/jacoco/…`) knows its module.
|
||||
let module = jacoco::module_dir(path);
|
||||
let module =
|
||||
if path.starts_with(root) && module.starts_with(root) && module != path.parent().unwrap_or(path) {
|
||||
module
|
||||
} else {
|
||||
root.to_path_buf()
|
||||
};
|
||||
let mut acc = Accumulator { has_branches: true, ..Default::default() };
|
||||
jacoco::parse_into(&text, &module, &mut acc)
|
||||
.map_err(|e| IdeError::invalid(format!("{}: {e}", path.display())))?;
|
||||
acc.finish("JaCoCo")
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
for f in &mut suite.files {
|
||||
let p: &Path = f.path.as_ref();
|
||||
if !p.exists()
|
||||
&& let Some(found) = relocate(p, root)
|
||||
{
|
||||
f.path = found.into();
|
||||
}
|
||||
}
|
||||
suite.files.sort_by(|a, b| (a.path.as_ref() as &Path).cmp(b.path.as_ref()));
|
||||
Ok(suite)
|
||||
}
|
||||
|
||||
/// `/home/runner/work/app/app/src/lib.rs` → `<root>/src/lib.rs`: the longest suffix of
|
||||
/// `path` that exists under `root`.
|
||||
pub(crate) fn relocate(path: &Path, root: &Path) -> Option<PathBuf> {
|
||||
let parts: Vec<&std::ffi::OsStr> = path
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(0..parts.len()).find_map(|skip| {
|
||||
let candidate = parts[skip..].iter().fold(root.to_path_buf(), |p, s| p.join(s));
|
||||
candidate.is_file().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
/// The Go module the profile's import paths belong to: the project's `go.mod`, else one
|
||||
/// a level or two down whose module path prefixes the profile's files.
|
||||
fn go_module_for(root: &Path, profile: &str) -> (String, PathBuf) {
|
||||
let first = profile
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with("mode:"))
|
||||
.and_then(|l| l.rsplit_once(':').map(|(f, _)| f.to_owned()))
|
||||
.unwrap_or_default();
|
||||
let matches = |module: &str| !module.is_empty() && (first == module || first.starts_with(&format!("{module}/")));
|
||||
let read = |dir: &Path| -> Option<String> {
|
||||
let text = std::fs::read_to_string(dir.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|l| l.trim().strip_prefix("module")).map(|m| m.trim().trim_matches('"').to_owned())
|
||||
};
|
||||
let mut candidates = vec![root.to_path_buf()];
|
||||
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
|
||||
candidates
|
||||
.extend(std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()));
|
||||
candidates.push(dir);
|
||||
}
|
||||
let found: Vec<(String, PathBuf)> = candidates.into_iter().filter_map(|d| read(&d).map(|m| (m, d))).collect();
|
||||
found
|
||||
.iter()
|
||||
.find(|(m, _)| matches(m))
|
||||
.or_else(|| found.iter().find(|(_, d)| d == root))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (String::new(), root.to_path_buf()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_formats() {
|
||||
assert_eq!(detect("TN:\nSF:a.rs\nDA:1,1\nend_of_record\n"), Some(Format::Lcov));
|
||||
assert_eq!(detect("mode: atomic\nm/a.go:1.1,2.2 1 0\n"), Some(Format::GoProfile));
|
||||
assert_eq!(
|
||||
detect("<?xml version=\"1.0\"?><!DOCTYPE report><report name=\"x\"></report>"),
|
||||
Some(Format::Jacoco)
|
||||
);
|
||||
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
|
||||
assert_eq!(detect("hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_lcov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/lib.rs"), "").unwrap();
|
||||
let report = tmp.path().join("lcov.info");
|
||||
std::fs::write(&report, "SF:/home/runner/work/app/app/src/lib.rs\nDA:1,1\nDA:2,0\nend_of_record\n").unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.runner, "lcov");
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/lib.rs"));
|
||||
assert_eq!(suite.files[0].lines_covered, 1);
|
||||
assert!(import(&tmp.path().join("missing"), &root).is_err());
|
||||
std::fs::write(&report, "nothing").unwrap();
|
||||
assert!(import(&report, &root).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_go_profiles_of_nested_modules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("svc/calc")).unwrap();
|
||||
std::fs::write(root.join("svc/go.mod"), "module example.com/svc\n").unwrap();
|
||||
let report = root.join("cover.out");
|
||||
std::fs::write(&report, "mode: set\nexample.com/svc/calc/calc.go:3.1,4.2 1 1\n").unwrap();
|
||||
let suite = import(&report, root).unwrap();
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("svc/calc/calc.go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_jacoco_against_the_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
std::fs::create_dir_all(root.join("src/main/java/com/x")).unwrap();
|
||||
std::fs::write(root.join("src/main/java/com/x/A.java"), "").unwrap();
|
||||
let report = tmp.path().join("jacoco.xml");
|
||||
let xml = r#"<report name="p"><package name="com/x"><sourcefile name="A.java"><line nr="2" mi="0" ci="1" mb="0" cb="0"/></sourcefile></package></report>"#;
|
||||
std::fs::write(&report, xml).unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/main/java/com/x/A.java"));
|
||||
assert!(suite.has_branches);
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,12 @@
|
||||
//! Lines in the model are 0-based, like every IPC coordinate.
|
||||
|
||||
mod goprofile;
|
||||
mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
@@ -204,7 +206,7 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
CoverageReportSpec::Exported { runner, argv, cwd, env, lcov } => {
|
||||
let (program, args) =
|
||||
argv.split_first().ok_or_else(|| IdeError::invalid("empty coverage export command"))?;
|
||||
let out = Command::new(program)
|
||||
let out = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.envs(env)
|
||||
|
||||
@@ -287,6 +287,8 @@ mod tests {
|
||||
detected: true,
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ use std::pin::Pin;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
pub const PORT_PLACEHOLDER: &str = "${port}";
|
||||
/// Replaced by the session's working directory (the project) in adapter arguments.
|
||||
pub const ROOT_PLACEHOLDER: &str = "${root}";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
@@ -36,6 +38,11 @@ pub struct AdapterSpec {
|
||||
pub cwd: PathBuf,
|
||||
/// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`).
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface.
|
||||
pub server_host: Option<String>,
|
||||
/// `host:port` of an adapter inside another program (the Godot editor): connected to
|
||||
/// when it accepts, else `command` is started.
|
||||
pub connect: Option<String>,
|
||||
}
|
||||
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
@@ -45,6 +52,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
command: command.to_owned(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
transport: transport.to_owned(),
|
||||
connect: None,
|
||||
install_hint: Some(hint.to_owned()),
|
||||
};
|
||||
Some(match adapter {
|
||||
@@ -116,6 +124,8 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
install_hint: config.install_hint.clone(),
|
||||
cwd,
|
||||
env: Vec::new(),
|
||||
server_host: None,
|
||||
connect: config.connect.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +134,19 @@ pub fn substitute_port(args: &[String], port: u16) -> Vec<String> {
|
||||
args.iter().map(|a| a.replace(PORT_PLACEHOLDER, &port.to_string())).collect()
|
||||
}
|
||||
|
||||
/// Replaces every `${root}` occurrence with `root`.
|
||||
pub fn substitute_root(args: &[String], root: &Path) -> Vec<String> {
|
||||
let root = root.to_string_lossy();
|
||||
args.iter().map(|a| a.replace(ROOT_PLACEHOLDER, &root)).collect()
|
||||
}
|
||||
|
||||
/// Splits `host:port` (`[v6]:port`).
|
||||
pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
let (host, port) = addr.trim().rsplit_once(':')?;
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
Some((host.to_owned(), port.parse().ok().filter(|p| *p != 0)?))
|
||||
}
|
||||
|
||||
/// A port that was free a moment ago on the loopback interface.
|
||||
pub fn free_port() -> std::io::Result<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
@@ -233,7 +256,9 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
|
||||
let Some(python) = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd)).await.ok().flatten() else {
|
||||
return Ok(()); // Reported as a missing adapter when it is started.
|
||||
};
|
||||
let probe = Command::new(&python)
|
||||
let mut probe = Command::new(&python);
|
||||
ide_core::process::prepare(probe.as_std_mut());
|
||||
let probe = probe
|
||||
.args(["-c", "import debugpy.adapter"])
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(Stdio::null())
|
||||
@@ -301,13 +326,22 @@ pub trait AdapterLauncher: Send + Sync + 'static {
|
||||
pub struct ProcessLauncher;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// How long an adapter inside another program (a headless Godot editor) may take to listen.
|
||||
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Probe of a `connect` address before starting the command.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
impl AdapterLauncher for ProcessLauncher {
|
||||
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
|
||||
Box::pin(async move {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
if let Some(port) = reuse_port {
|
||||
let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?;
|
||||
// A remote adapter (`dlv --headless` on another machine) may answer slowly.
|
||||
let (host, timeout) = match spec.server_host.as_deref() {
|
||||
Some(host) => (host, CONNECT_TIMEOUT),
|
||||
None => (LOOPBACK, Duration::from_secs(5)),
|
||||
};
|
||||
let stream = connect_with_retries(host, port, None, timeout).await?;
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
@@ -317,6 +351,28 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
|
||||
Some(addr) => Some(
|
||||
parse_address(addr)
|
||||
.ok_or_else(|| IdeError::invalid(format!("invalid debug adapter address `{addr}`")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// An adapter inside a program the user runs (the Godot editor): use it, never stop it.
|
||||
if let Some((host, port)) = &embedded
|
||||
&& let Ok(Ok(stream)) =
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host.as_str(), *port))).await
|
||||
{
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
port: Some(*port),
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
prepare_debugpy(&mut spec).await?;
|
||||
}
|
||||
@@ -327,14 +383,20 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?;
|
||||
let port = match spec.transport {
|
||||
Transport::Tcp => Some(free_port()?),
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => Some(free_port()?),
|
||||
},
|
||||
Transport::Stdio => None,
|
||||
};
|
||||
let args = substitute_root(&spec.args, &spec.cwd);
|
||||
let args = match port {
|
||||
Some(port) => substitute_port(&spec.args, port),
|
||||
None => spec.args.clone(),
|
||||
Some(port) => substitute_port(&args, port),
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
@@ -363,7 +425,8 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone());
|
||||
}
|
||||
let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await {
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = child.start_kill();
|
||||
@@ -394,27 +457,32 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
let mut buf = sink.lock();
|
||||
if buf.len() >= 40 {
|
||||
buf.remove(0);
|
||||
}
|
||||
buf.push(line);
|
||||
}
|
||||
});
|
||||
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
push_line(&sink, line);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Appends to the recent-output buffer of an adapter (the last 40 lines).
|
||||
pub(crate) fn push_line(sink: &parking_lot::Mutex<Vec<String>>, line: String) {
|
||||
let mut buf = sink.lock();
|
||||
if buf.len() >= 40 {
|
||||
buf.remove(0);
|
||||
}
|
||||
buf.push(line);
|
||||
}
|
||||
|
||||
const LOOPBACK: &str = "127.0.0.1";
|
||||
|
||||
async fn connect_with_retries(
|
||||
host: &str,
|
||||
port: u16,
|
||||
mut child: Option<&mut Child>,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<tokio::net::TcpStream> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
|
||||
match tokio::net::TcpStream::connect((host, port)).await {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
return Ok(s);
|
||||
@@ -429,7 +497,7 @@ async fn connect_with_retries(
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"could not connect to debug adapter on port {port}: {e}"
|
||||
"could not connect to debug adapter at {host}:{port}: {e}"
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
@@ -170,7 +170,7 @@ pub fn is_go_binary(exe: &str) -> bool {
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
use std::collections::HashMap;
|
||||
let ps = |fields: &str| -> IdeResult<String> {
|
||||
let out = std::process::Command::new("ps")
|
||||
let out = ide_core::process::prepare(&mut std::process::Command::new("ps"))
|
||||
.args(["-x", "-ww", "-o", fields])
|
||||
.output()
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot list processes: {e}")))?;
|
||||
@@ -212,7 +212,9 @@ pub const NODE_INSPECTOR_PORT: u16 = 9229;
|
||||
/// picker does) and waits until it listens. Returns the inspector port.
|
||||
#[cfg(unix)]
|
||||
pub async fn enable_node_inspector(pid: u32) -> IdeResult<u16> {
|
||||
let status = tokio::process::Command::new("kill")
|
||||
let mut kill = tokio::process::Command::new("kill");
|
||||
ide_core::process::prepare(kill.as_std_mut());
|
||||
let status = kill
|
||||
.args(["-USR1", &pid.to_string()])
|
||||
.status()
|
||||
.await
|
||||
|
||||
@@ -55,6 +55,7 @@ pub async fn build(
|
||||
) -> IdeResult<PathBuf> {
|
||||
let cargo = find_binary("cargo", cwd).ok_or_else(|| IdeError::unavailable("`cargo` not found on PATH"))?;
|
||||
let mut cmd = tokio::process::Command::new(cargo);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(args)
|
||||
.arg("--message-format=json-render-diagnostics")
|
||||
.current_dir(cwd)
|
||||
|
||||
@@ -95,7 +95,21 @@ pub struct Connection {
|
||||
|
||||
impl Connection {
|
||||
/// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime.
|
||||
pub fn start<R, W>(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
pub fn start<R, W>(reader: R, writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start_with_output(reader, writer, Box::new(|_| {}))
|
||||
}
|
||||
|
||||
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
|
||||
/// own output on stdout, decoded) to `on_output`.
|
||||
pub fn start_with_output<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -133,9 +147,14 @@ impl Connection {
|
||||
let reader_inner = inner.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut noise = |line: &[u8]| {
|
||||
let text = ide_jsonrpc::output::decode(line);
|
||||
tracing::warn!("dap: output outside the protocol: {text}");
|
||||
on_output(text);
|
||||
};
|
||||
loop {
|
||||
let msg = tokio::select! {
|
||||
msg = framing::read_message(&mut reader) => msg,
|
||||
msg = framing::read_message_or_noise(&mut reader, &mut noise) => msg,
|
||||
_ = reader_inner.cancel.cancelled() => break,
|
||||
};
|
||||
match msg {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens,
|
||||
//! the remote JVM (`-agentlib:jdwp=…,server=n,address=<ide-host>:<port>`) connects to
|
||||
//! it. java-debug can only attach, so the connection is relayed: once the JVM has
|
||||
//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP
|
||||
//! stream (handshake included) both ways.
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// How long the relay waits for java-debug to attach once the JVM is connected.
|
||||
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The address to listen on for `host`: the interface it names, or every interface when
|
||||
/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to).
|
||||
async fn bind(host: &str, port: u16) -> IdeResult<TcpListener> {
|
||||
let host = host.trim();
|
||||
let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') };
|
||||
match TcpListener::bind((host, port)).await {
|
||||
Ok(l) => Ok(l),
|
||||
Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => {
|
||||
TcpListener::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}")))
|
||||
}
|
||||
Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays
|
||||
/// to it (accepts one connection). Cancelled through `cancel` while waiting.
|
||||
pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult<u16> {
|
||||
let listener = bind(host, port).await?;
|
||||
let jvm = tokio::select! {
|
||||
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
accepted = listener.accept() => accepted.map_err(IdeError::from)?.0,
|
||||
};
|
||||
drop(listener);
|
||||
let _ = jvm.set_nodelay(true);
|
||||
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = relay.local_addr()?.port();
|
||||
tokio::spawn(relay_one(relay, jvm));
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
|
||||
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
|
||||
Ok(Ok((s, _))) => s,
|
||||
_ => return,
|
||||
};
|
||||
drop(relay);
|
||||
let _ = debugger.set_nodelay(true);
|
||||
let mut debugger = debugger;
|
||||
if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await {
|
||||
tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn relays_the_jdwp_handshake_both_ways() {
|
||||
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
|
||||
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
|
||||
// The remote JVM (`server=n`) connects to the IDE.
|
||||
let mut jvm = loop {
|
||||
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
|
||||
break s;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let local = waiting.await.unwrap().unwrap();
|
||||
// java-debug attaches to the relay and starts the handshake.
|
||||
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
|
||||
debugger.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
let mut buf = [0u8; 14];
|
||||
jvm.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"JDWP-Handshake");
|
||||
jvm.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
debugger.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"JDWP-Handshake");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_is_cancellable() {
|
||||
let cancel = CancellationToken::new();
|
||||
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone()));
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
cancel.cancel();
|
||||
let err = waiting.await.unwrap().unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod adapter;
|
||||
pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -45,7 +46,8 @@ pub struct LaunchConfig {
|
||||
/// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
|
||||
/// and launches the produced executable; `"debugServer": <port>` connects to an
|
||||
/// adapter already listening on that local port (e.g. java-debug inside jdtls)
|
||||
/// instead of starting one.
|
||||
/// instead of starting one, `"debugServer": "host:port"` to one on another machine
|
||||
/// (`dlv --headless`, `debugpy --listen`).
|
||||
pub extra_json: Option<String>,
|
||||
}
|
||||
|
||||
@@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a running adapter listens: `debugServer` of the launch arguments.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DebugServer {
|
||||
/// `None` for the loopback interface (an adapter inside a local process such as jdtls).
|
||||
pub host: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the
|
||||
/// launch arguments and returns its port.
|
||||
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<u16>> {
|
||||
/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` /
|
||||
/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`).
|
||||
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<DebugServer>> {
|
||||
let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) };
|
||||
let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()));
|
||||
match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
|
||||
Some(p) => Ok(Some(p)),
|
||||
None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))),
|
||||
let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}"));
|
||||
let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0);
|
||||
if let Some(p) = v.as_u64() {
|
||||
return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid);
|
||||
}
|
||||
let s = v.as_str().map(str::trim).ok_or_else(invalid)?;
|
||||
let (host, port) = match s.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
(Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port)
|
||||
}
|
||||
None => (None, s),
|
||||
};
|
||||
let port = port.trim().parse::<u64>().ok().and_then(port_of).ok_or_else(invalid)?;
|
||||
Ok(Some(DebugServer { host, port }))
|
||||
}
|
||||
|
||||
/// DAP stack frame → [`StackFrame`] (1-based → 0-based).
|
||||
@@ -522,6 +543,17 @@ impl DebugManager {
|
||||
/// waits for `initialized`, sends breakpoints, then `configurationDone`.
|
||||
/// Events of the session (including cargo build output) go to `owner` only.
|
||||
pub async fn start(&self, config: LaunchConfig, owner: SessionOwner) -> IdeResult<DebugSessionId> {
|
||||
self.start_with(config, owner, |_| None).await
|
||||
}
|
||||
|
||||
/// [`start`](Self::start) with the adapter executable chosen by `custom_command`
|
||||
/// (the configured command → a user-chosen executable, from settings).
|
||||
pub async fn start_with(
|
||||
&self,
|
||||
config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
let cwd = config
|
||||
.cwd
|
||||
@@ -531,6 +563,9 @@ impl DebugManager {
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
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).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
@@ -544,6 +579,9 @@ impl DebugManager {
|
||||
}
|
||||
let id = DebugSessionId::next();
|
||||
let debug_server = take_debug_server(&mut arguments)?;
|
||||
if let Some(server) = &debug_server {
|
||||
spec.server_host = server.host.clone();
|
||||
}
|
||||
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
|
||||
let cargo_args: Vec<String> = cargo
|
||||
.get("args")
|
||||
@@ -582,11 +620,19 @@ impl DebugManager {
|
||||
if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() {
|
||||
arguments["__workspaceFolder"] = json!(cwd);
|
||||
}
|
||||
// Godot compares `project` with its own (resolved) project path: `/tmp/game` is
|
||||
// rejected by an editor that opened `/private/tmp/game`.
|
||||
if config.adapter == "godot"
|
||||
&& let Some(project) = arguments.get("project").and_then(Value::as_str)
|
||||
&& let Ok(real) = std::fs::canonicalize(project)
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
}
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server)
|
||||
.await
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
}
|
||||
|
||||
/// `startDebugging` reverse request: a child session on the same adapter.
|
||||
@@ -635,7 +681,10 @@ impl DebugManager {
|
||||
}
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
|
||||
let stderr = io.stderr.clone();
|
||||
let (conn, incoming) = Connection::start(io.reader, io.writer);
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
};
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
@@ -946,8 +995,8 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
let body = self
|
||||
.session(s)?
|
||||
let session = self.session(s)?;
|
||||
let body = session
|
||||
.conn
|
||||
.request_timeout(
|
||||
"stackTrace",
|
||||
@@ -955,11 +1004,14 @@ impl DebugManager {
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
Ok(body
|
||||
.get("stackFrames")
|
||||
.and_then(Value::as_array)
|
||||
.map(|f| f.iter().map(frame_from).collect())
|
||||
.unwrap_or_default())
|
||||
let frame = |f: &Value| {
|
||||
let mut frame = frame_from(f);
|
||||
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
|
||||
frame.path = Some(client.into());
|
||||
}
|
||||
frame
|
||||
};
|
||||
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
|
||||
|
||||
@@ -51,9 +51,16 @@ pub(crate) struct Session {
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
pub breakpoint_ids: Mutex<HashMap<i64, (PathBuf, usize)>>,
|
||||
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
|
||||
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
}
|
||||
|
||||
/// Adapters that only match files by their resolved path: Godot maps a script to
|
||||
/// `res://` by prefix and silently never stops at a breakpoint set through a
|
||||
/// symlinked path (`/tmp/game` while the editor has `/private/tmp/game`).
|
||||
const RESOLVES_PATHS: &[&str] = &["godot"];
|
||||
|
||||
impl Session {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
@@ -86,6 +93,7 @@ impl Session {
|
||||
terminated: AtomicBool::new(false),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -94,6 +102,25 @@ impl Session {
|
||||
self.owner.sink.emit(IdeEvent::Debug { session: self.id, event });
|
||||
}
|
||||
|
||||
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
|
||||
pub fn adapter_path(&self, path: &Path) -> PathBuf {
|
||||
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
match std::fs::canonicalize(path) {
|
||||
Ok(real) if real != path => {
|
||||
self.resolved_paths.lock().insert(real.clone(), path.to_path_buf());
|
||||
real
|
||||
}
|
||||
_ => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
|
||||
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
|
||||
self.resolved_paths.lock().get(Path::new(path)).cloned()
|
||||
}
|
||||
|
||||
pub fn supports(&self, cap: &str) -> bool {
|
||||
self.caps.lock().get(cap).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
@@ -135,7 +162,7 @@ impl Session {
|
||||
.request_timeout(
|
||||
"setBreakpoints",
|
||||
json!({
|
||||
"source": { "path": path, "name": name },
|
||||
"source": { "path": self.adapter_path(path), "name": name },
|
||||
"breakpoints": source_bps,
|
||||
"sourceModified": false,
|
||||
}),
|
||||
@@ -311,7 +338,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body:
|
||||
s.emit(DebugEvent::BreakpointsChanged { path: path.into() });
|
||||
}
|
||||
}
|
||||
"exited" => s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }),
|
||||
// Godot repeats `exited` / `terminated` when the client disconnects afterwards.
|
||||
"exited" if !s.terminated.load(Ordering::Acquire) => {
|
||||
s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 })
|
||||
}
|
||||
"exited" => {}
|
||||
"terminated" => {
|
||||
if !s.terminated.swap(true, Ordering::AcqRel) {
|
||||
s.emit(DebugEvent::Terminated);
|
||||
@@ -390,7 +421,13 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
return Err("runInTerminal without args".into());
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
cmd.args(rest).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(rest)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
@@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() {
|
||||
|
||||
#[test]
|
||||
fn debug_server_is_taken_from_launch_arguments() {
|
||||
let local = |port| Some(DebugServer { host: None, port });
|
||||
let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port });
|
||||
let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"});
|
||||
assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711));
|
||||
assert_eq!(take_debug_server(&mut args).unwrap(), local(4711));
|
||||
assert!(args.get("debugServer").is_none());
|
||||
assert_eq!(args["mainClass"], "a.B");
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005));
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005));
|
||||
assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None);
|
||||
// A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`.
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345));
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678));
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345));
|
||||
assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345));
|
||||
assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err());
|
||||
assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err());
|
||||
assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err());
|
||||
assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() {
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
|
||||
}
|
||||
|
||||
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
|
||||
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
|
||||
#[tokio::test]
|
||||
async fn connects_to_remote_debug_server_by_host() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
|
||||
let config = LaunchConfig {
|
||||
name: "Remote".into(),
|
||||
adapter: "delve".into(),
|
||||
request: "attach".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(std::env::temp_dir().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(
|
||||
json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
let id = manager.start(config, owner).await.unwrap();
|
||||
let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap();
|
||||
assert_eq!(attach["arguments"]["mode"], "remote");
|
||||
assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app");
|
||||
assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client");
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
fn requests(h: &Harness, command: &str) -> Vec<Value> {
|
||||
h.log
|
||||
.lock()
|
||||
@@ -837,3 +881,137 @@ async fn hot_swap_only_for_java_debug() {
|
||||
let failed = hot_swap_result(&json!({"changedClasses": [], "errorMessage": "boom"}));
|
||||
assert_eq!(failed.error_message.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegistry> {
|
||||
let lang = ide_language::LanguageConfig {
|
||||
id: ide_core::LanguageId::new("gdscript"),
|
||||
name: "GDScript".into(),
|
||||
extensions: vec!["gd".into()],
|
||||
filenames: vec![],
|
||||
first_line: None,
|
||||
line_comment: None,
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
adapter: "godot".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
transport: "tcp".into(),
|
||||
connect: Some(format!("127.0.0.1:{port}")),
|
||||
install_hint: Some("Open the project in the Godot editor".into()),
|
||||
}),
|
||||
};
|
||||
Arc::new(LanguageRegistry::from_configs(vec![lang]))
|
||||
}
|
||||
|
||||
fn godot_launch(project: &std::path::Path) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "Main Scene".into(),
|
||||
adapter: "godot".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(project.to_path_buf().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(json!({"project": project, "scene": "main"}).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Godot's debug adapter runs inside the editor (port 6006): an open editor is
|
||||
/// used as is, nothing is started.
|
||||
#[tokio::test]
|
||||
async fn godot_connects_to_the_editor_adapter() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
|
||||
let project = std::env::temp_dir();
|
||||
let id = manager.start(godot_launch(&project), owner).await.unwrap();
|
||||
let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap();
|
||||
assert_eq!(launch["arguments"]["scene"], "main");
|
||||
assert_eq!(launch["arguments"]["project"], json!(std::fs::canonicalize(&project).unwrap()));
|
||||
assert!(log.lock().iter().any(|m| m["command"] == "configurationDone"));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn godot_without_editor_or_executable_says_what_to_do() {
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
|
||||
let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("Open the project in the Godot editor"), "{}", err.message);
|
||||
}
|
||||
|
||||
/// No editor open: the command (a headless editor) is started for the project and
|
||||
/// the client connects once it listens; the started process ends with the session.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn godot_starts_a_headless_editor_when_none_is_open() {
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
// `sleep` stands in for `godot --headless --editor --path ${root} …`.
|
||||
let manager = DebugManager::new(godot_registry(port, "sleep", &["30"]));
|
||||
let id = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap();
|
||||
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Godot never stops at a breakpoint given through a symlinked path (it maps scripts
|
||||
/// to `res://` by its resolved project path): breakpoints and `project` go out resolved.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn godot_gets_resolved_paths() {
|
||||
let real = std::env::temp_dir().join(format!("openidea-godot-real-{}", std::process::id()));
|
||||
let link = std::env::temp_dir().join(format!("openidea-godot-link-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::fs::write(real.join("player.gd"), "extends Node\n").unwrap();
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
let real = std::fs::canonicalize(&real).unwrap();
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::with_launcher(
|
||||
godot_registry(1, "godot", &[]),
|
||||
Arc::new(FakeLauncher { log: log.clone(), script: Script::default() }),
|
||||
);
|
||||
let bp = Breakpoint { line: 0, condition: None, hit_condition: None, log_message: None, enabled: true };
|
||||
manager.set_breakpoints(link.join("player.gd"), vec![bp]).await.unwrap();
|
||||
let id = manager.start(godot_launch(&link), owner).await.unwrap();
|
||||
let sent = log.lock().iter().find(|m| m["command"] == "setBreakpoints").cloned().unwrap();
|
||||
assert_eq!(sent["arguments"]["source"]["path"], json!(real.join("player.gd")));
|
||||
let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap();
|
||||
assert_eq!(launch["arguments"]["project"], json!(real));
|
||||
manager.stop(id).await.unwrap();
|
||||
std::fs::remove_file(&link).ok();
|
||||
std::fs::remove_dir_all(&real).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_address_placeholders() {
|
||||
let args: Vec<String> = ["--path", "${root}", "--dap-port", "${port}"].iter().map(|s| s.to_string()).collect();
|
||||
let args = adapter::substitute_port(&adapter::substitute_root(&args, std::path::Path::new("/g")), 6010);
|
||||
assert_eq!(args, ["--path", "/g", "--dap-port", "6010"]);
|
||||
assert_eq!(adapter::parse_address("127.0.0.1:6006"), Some(("127.0.0.1".into(), 6006)));
|
||||
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
|
||||
assert_eq!(adapter::parse_address("6006"), None);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Drives Godot's debug adapter (inside the Godot editor) with the GDScript
|
||||
//! language definition: a headless editor is started for the project, the main
|
||||
//! scene is played, a breakpoint hits, stack / variables / stepping work, and the
|
||||
//! game and the editor end with the session.
|
||||
//!
|
||||
//! Ignored by default (needs Godot 4). Run with
|
||||
//! `GODOT_BIN=<godot executable> cargo test -p ide-dap --test godot -- --ignored --nocapture`.
|
||||
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_dap::*;
|
||||
use ide_language::{LanguageConfig, LanguageRegistry};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SCRIPT: &str = "extends Node
|
||||
|
||||
var health: int = 3
|
||||
|
||||
func _ready() -> void:
|
||||
\tvar total := add(2, 3)
|
||||
\tprint(\"total = \", total)
|
||||
\thealth -= 1
|
||||
\tget_tree().quit()
|
||||
|
||||
func add(a: int, b: int) -> int:
|
||||
\treturn a + b
|
||||
";
|
||||
/// 0-based line of `return a + b`.
|
||||
const BREAK_LINE: u32 = 11;
|
||||
|
||||
fn project(dir: &Path) {
|
||||
std::fs::write(
|
||||
dir.join("project.godot"),
|
||||
"config_version=5\n\n[application]\nconfig/name=\"Probe\"\nrun/main_scene=\"res://main.tscn\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("main.tscn"),
|
||||
"[gd_scene load_steps=2 format=3]\n\n[ext_resource type=\"Script\" path=\"res://player.gd\" id=\"1\"]\n\n[node name=\"Main\" type=\"Node\"]\nscript = ExtResource(\"1\")\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("player.gd"), SCRIPT).unwrap();
|
||||
}
|
||||
|
||||
/// The built-in GDScript definition, with the editor's usual port moved aside so an
|
||||
/// open editor can't answer instead of the one this test starts.
|
||||
fn registry() -> Arc<LanguageRegistry> {
|
||||
let builtin = LanguageRegistry::load(None).unwrap();
|
||||
let mut gd: LanguageConfig = builtin.get(&ide_core::LanguageId::new("gdscript")).unwrap();
|
||||
let dbg = gd.debugger.as_mut().unwrap();
|
||||
let unused = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
dbg.connect = Some(format!("127.0.0.1:{unused}"));
|
||||
Arc::new(LanguageRegistry::from_configs(vec![gd]))
|
||||
}
|
||||
|
||||
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(e) = events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return e;
|
||||
}
|
||||
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn godot_processes(project: &Path) -> usize {
|
||||
let out = std::process::Command::new("pgrep").args(["-f", &project.to_string_lossy()]).output().unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).lines().count()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn breakpoints_stepping_and_variables_in_a_real_godot() {
|
||||
let Ok(godot) = std::env::var("GODOT_BIN") else {
|
||||
eprintln!("GODOT_BIN not set; skipping");
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("openidea-godot-dap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let root = std::fs::canonicalize(&dir).unwrap();
|
||||
project(&root);
|
||||
let manager = DebugManager::new(registry());
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let script_as_opened = dir.join("player.gd");
|
||||
manager
|
||||
.set_breakpoints(
|
||||
script_as_opened.clone(),
|
||||
vec![Breakpoint {
|
||||
line: BREAK_LINE,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
log_message: None,
|
||||
enabled: true,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// As the UI builds it for "Godot Project" (`godot --path .`).
|
||||
let config = LaunchConfig {
|
||||
name: "Godot Project".into(),
|
||||
adapter: "godot".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
// The project as opened, through a symlink on macOS (`/var` → `/private/var`).
|
||||
cwd: Some(dir.clone().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({ "project": dir, "scene": "main" }).to_string()),
|
||||
};
|
||||
let id = manager
|
||||
.start_with(config, SessionOwner::new("main", sink), |_| Some(godot.clone()))
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
let stopped = wait_for(&events, Duration::from_secs(120), |e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
|
||||
let frames = manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].name, "add");
|
||||
assert_eq!(frames[0].line, BREAK_LINE);
|
||||
// Frames name the file as the IDE opened it, not Godot's resolved path.
|
||||
assert_eq!(frames[0].path.as_deref().map(Path::new), Some(script_as_opened.as_path()));
|
||||
assert_eq!(frames[1].name, "_ready");
|
||||
let scopes = manager.scopes(id, frames[0].id).await.unwrap();
|
||||
let locals = scopes.iter().find(|s| s.name == "Locals").expect("Locals scope");
|
||||
let vars = manager.variables(id, locals.variables_reference).await.unwrap();
|
||||
let value = |n: &str| vars.iter().find(|v| v.name == n).map(|v| v.value.clone());
|
||||
assert_eq!((value("a").as_deref(), value("b").as_deref()), (Some("2"), Some("3")), "{vars:?}");
|
||||
|
||||
events.lock().clear();
|
||||
manager.step(id, thread_id, StepKind::Over).await.unwrap();
|
||||
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let frames = manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!((frames[0].name.as_str(), frames[0].line), ("_ready", 6), "stepped back into `_ready`, at the print");
|
||||
manager.resume(id, thread_id).await.unwrap();
|
||||
// (The game's last output is not always forwarded when it quits right away.)
|
||||
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Terminated)).await;
|
||||
let _ = manager.stop(id).await;
|
||||
// The headless editor started for the session ends with it, the game too.
|
||||
let start = Instant::now();
|
||||
while godot_processes(&root) > 0 {
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "Godot still running for {}", root.display());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -17,8 +17,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
dirs.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
ide-ssh.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -15,7 +15,8 @@ use sqlx::{
|
||||
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
|
||||
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -39,6 +40,8 @@ pub struct QueryResult {
|
||||
/// More rows exist beyond the limit.
|
||||
pub has_more: bool,
|
||||
pub elapsed_ms: f64,
|
||||
/// Until the server's first answer; the rest of `elapsed_ms` is fetching.
|
||||
pub execution_ms: f64,
|
||||
}
|
||||
|
||||
pub enum Pool {
|
||||
@@ -129,7 +132,9 @@ macro_rules! fetch {
|
||||
let mut affected: Option<u64> = None;
|
||||
let mut has_more = false;
|
||||
let mut failure = None;
|
||||
let mut first: Option<Instant> = None;
|
||||
while let Some(item) = stream.next().await {
|
||||
first.get_or_insert_with(Instant::now);
|
||||
match item {
|
||||
Ok(sqlx::Either::Left(done)) => affected = Some(affected.unwrap_or(0) + done.rows_affected()),
|
||||
Ok(sqlx::Either::Right(row)) => {
|
||||
@@ -151,12 +156,13 @@ macro_rules! fetch {
|
||||
drop(stream);
|
||||
match failure {
|
||||
Some(e) => Err(e),
|
||||
None => Ok((columns, rows, affected, has_more)),
|
||||
None => Ok((columns, rows, affected, has_more, first)),
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool);
|
||||
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
|
||||
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
|
||||
/// The server-side id of a connection (to cancel its statement from another one).
|
||||
macro_rules! backend_id {
|
||||
@@ -184,6 +190,90 @@ fn looks_like_query(sql: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn file_setting(path: &str) -> Option<PathBuf> {
|
||||
let path = path.trim();
|
||||
(!path.is_empty()).then(|| ide_ssh::expand(path))
|
||||
}
|
||||
|
||||
fn is_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
|
||||
fn mysql_value(v: &str) -> String {
|
||||
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
|
||||
if keyword || v.parse::<f64>().is_ok() {
|
||||
v.to_owned()
|
||||
} else {
|
||||
format!("'{}'", v.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How the Options and Advanced tabs reach a connection.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Setup {
|
||||
/// PostgreSQL run-time parameters (startup `options`), SQLite pragmas.
|
||||
pub params: Vec<(String, String)>,
|
||||
/// Run on every new connection: MySQL session settings, then the startup script.
|
||||
pub statements: Vec<String>,
|
||||
pub application_name: Option<String>,
|
||||
pub charset: Option<String>,
|
||||
pub collation: Option<String>,
|
||||
pub statement_cache: Option<usize>,
|
||||
pub connect_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
pub fn of(source: &DataSource) -> IdeResult<Self> {
|
||||
let mut s = Setup::default();
|
||||
let o = &source.options;
|
||||
match source.driver {
|
||||
DbDriver::Postgres => {
|
||||
if source.read_only {
|
||||
s.params.push(("default_transaction_read_only".into(), "on".into()));
|
||||
}
|
||||
if !o.default_schema.trim().is_empty() {
|
||||
s.params.push(("search_path".into(), o.default_schema.trim().into()));
|
||||
}
|
||||
if !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("TimeZone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
}
|
||||
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
|
||||
_ => {}
|
||||
}
|
||||
for p in &source.properties {
|
||||
let value = p.value.trim();
|
||||
let name = p.name.trim().replace('-', "_");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_identifier(&name) {
|
||||
return Err(IdeError::invalid(format!("invalid driver property name: {}", p.name.trim())));
|
||||
}
|
||||
let number = |what: &str| {
|
||||
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
|
||||
};
|
||||
match (source.driver, name.to_ascii_lowercase().as_str()) {
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub fn driver(&self) -> DbDriver {
|
||||
match self {
|
||||
@@ -196,7 +286,24 @@ impl Pool {
|
||||
/// Opens a pool and one connection (so bad settings fail here).
|
||||
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
const MAX: u32 = 4;
|
||||
let timeout = Duration::from_secs(30);
|
||||
let setup = Setup::of(source)?;
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
macro_rules! after_connect {
|
||||
() => {{
|
||||
let init = init.clone();
|
||||
move |conn, _| {
|
||||
let init = init.clone();
|
||||
Box::pin(async move {
|
||||
for sql in init.iter() {
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}};
|
||||
}
|
||||
match source.driver {
|
||||
DbDriver::Sqlite => {
|
||||
let file = source.file.as_deref().ok_or_else(|| IdeError::invalid("no database file"))?;
|
||||
@@ -204,11 +311,18 @@ impl Pool {
|
||||
Some(r) if file.is_relative() => r.join(file),
|
||||
_ => file.to_path_buf(),
|
||||
};
|
||||
let opts =
|
||||
let mut opts =
|
||||
SqliteConnectOptions::new().filename(&file).create_if_missing(false).read_only(source.read_only);
|
||||
for (k, v) in setup.params {
|
||||
opts = opts.pragma(k, v);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -219,24 +333,39 @@ impl Pool {
|
||||
.host(&source.host)
|
||||
.port(source.port.unwrap_or(5432))
|
||||
.username(&source.user)
|
||||
.application_name("OpenIDEA")
|
||||
.application_name(setup.application_name.as_deref().unwrap_or("OpenIDEA"))
|
||||
.ssl_mode(match source.ssl {
|
||||
DbSslMode::Disable => PgSslMode::Disable,
|
||||
DbSslMode::Prefer => PgSslMode::Prefer,
|
||||
DbSslMode::Require => PgSslMode::Require,
|
||||
DbSslMode::VerifyCa => PgSslMode::VerifyCa,
|
||||
DbSslMode::VerifyFull => PgSslMode::VerifyFull,
|
||||
});
|
||||
if let Some(f) = file_setting(&source.ssl_ca_file) {
|
||||
opts = opts.ssl_root_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_cert_file) {
|
||||
opts = opts.ssl_client_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_key_file) {
|
||||
opts = opts.ssl_client_key(f);
|
||||
}
|
||||
if !source.database.is_empty() {
|
||||
opts = opts.database(&source.database);
|
||||
}
|
||||
if let Some(p) = password {
|
||||
opts = opts.password(p);
|
||||
}
|
||||
if source.read_only {
|
||||
opts = opts.options([("default_transaction_read_only", "on")]);
|
||||
if !setup.params.is_empty() {
|
||||
opts = opts.options(setup.params);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -251,25 +380,41 @@ impl Pool {
|
||||
DbSslMode::Disable => MySqlSslMode::Disabled,
|
||||
DbSslMode::Prefer => MySqlSslMode::Preferred,
|
||||
DbSslMode::Require => MySqlSslMode::Required,
|
||||
DbSslMode::VerifyCa => MySqlSslMode::VerifyCa,
|
||||
DbSslMode::VerifyFull => MySqlSslMode::VerifyIdentity,
|
||||
});
|
||||
if let Some(f) = file_setting(&source.ssl_ca_file) {
|
||||
opts = opts.ssl_ca(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_cert_file) {
|
||||
opts = opts.ssl_client_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_key_file) {
|
||||
opts = opts.ssl_client_key(f);
|
||||
}
|
||||
if !source.database.is_empty() {
|
||||
opts = opts.database(&source.database);
|
||||
}
|
||||
if let Some(p) = password {
|
||||
opts = opts.password(p);
|
||||
}
|
||||
let read_only = source.read_only;
|
||||
if let Some(c) = &setup.charset {
|
||||
opts = opts.charset(c);
|
||||
}
|
||||
if let Some(c) = &setup.collation {
|
||||
opts = opts.collation(c);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let tz = source.options.time_zone.trim();
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(move |conn, _| {
|
||||
Box::pin(async move {
|
||||
if read_only {
|
||||
conn.execute("SET SESSION TRANSACTION READ ONLY").await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -282,6 +427,10 @@ impl Pool {
|
||||
with_pool!(self, p => p.close().await)
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection.
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
|
||||
@@ -408,14 +557,16 @@ impl Held {
|
||||
}
|
||||
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
|
||||
};
|
||||
let (columns, rows, affected, has_more) = fetched;
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
affected: if columns.is_empty() { Some(affected.unwrap_or(0) as f64) } else { None },
|
||||
columns,
|
||||
rows,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
elapsed_ms,
|
||||
execution_ms: first.map_or(elapsed_ms, |t| t.duration_since(started).as_secs_f64() * 1000.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -434,3 +585,68 @@ fn statement_columns<S: sqlx::Statement>(s: &S) -> Vec<ResultColumn> {
|
||||
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::{DbOptions, DbProperty};
|
||||
|
||||
fn prop(name: &str, value: &str) -> DbProperty {
|
||||
DbProperty { name: name.into(), value: value.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_options_become_runtime_parameters() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Postgres,
|
||||
read_only: true,
|
||||
options: DbOptions {
|
||||
default_schema: "app, public".into(),
|
||||
time_zone: "Europe/Paris".into(),
|
||||
startup_script: "SET work_mem = '64MB'; select 1;".into(),
|
||||
..Default::default()
|
||||
},
|
||||
properties: vec![prop("statement_timeout", "5s"), prop("application_name", "me"), prop("", "x")],
|
||||
..Default::default()
|
||||
};
|
||||
let setup = Setup::of(&s).unwrap();
|
||||
let names: Vec<_> = setup.params.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(names, ["default_transaction_read_only", "search_path", "TimeZone", "statement_timeout"]);
|
||||
assert_eq!(setup.application_name.as_deref(), Some("me"));
|
||||
assert_eq!(setup.statements, ["SET work_mem = '64MB'", "select 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_properties_become_session_variables() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Mysql,
|
||||
read_only: true,
|
||||
properties: vec![
|
||||
prop("sql_mode", "ANSI_QUOTES"),
|
||||
prop("max_execution_time", "1000"),
|
||||
prop("charset", "utf8mb4"),
|
||||
prop("connect-timeout", "5"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let setup = Setup::of(&s).unwrap();
|
||||
assert_eq!(
|
||||
setup.statements,
|
||||
[
|
||||
"SET SESSION TRANSACTION READ ONLY",
|
||||
"SET SESSION sql_mode = 'ANSI_QUOTES'",
|
||||
"SET SESSION max_execution_time = 1000"
|
||||
]
|
||||
);
|
||||
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
|
||||
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_properties_become_pragmas() {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Query history (IDEA's Browse Query History): the statements each data
|
||||
//! source ran, newest first. One JSON file in the app's data directory for all
|
||||
//! projects, keyed by project root, then data source id — never committed.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Statements kept per data source.
|
||||
pub const MAX_ENTRIES: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbHistoryEntry")]
|
||||
pub struct HistoryEntry {
|
||||
pub text: String,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
/// Project root → data source id → entries, newest first.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, Vec<HistoryEntry>>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct HistoryStore {
|
||||
path: PathBuf,
|
||||
project: String,
|
||||
}
|
||||
|
||||
impl HistoryStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
pub fn list(&self, source: &str) -> Vec<HistoryEntry> {
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project).and_then(|mut p| p.remove(source)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Records `text`; running a statement again moves it to the top.
|
||||
pub fn add(&self, source: &str, text: &str, time: f64) -> IdeResult<()> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let entries = all.entry(self.project.clone()).or_default().entry(source.to_owned()).or_default();
|
||||
entries.retain(|e| e.text != text);
|
||||
entries.insert(0, HistoryEntry { text: text.to_owned(), time });
|
||||
entries.truncate(MAX_ENTRIES);
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
/// Removes the entries with these texts (all of them for `None`).
|
||||
pub fn remove(&self, source: &str, texts: Option<&[String]>) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return Ok(()) };
|
||||
match texts {
|
||||
None => {
|
||||
project.remove(source);
|
||||
}
|
||||
Some(texts) => {
|
||||
if let Some(entries) = project.get_mut(source) {
|
||||
entries.retain(|e| !texts.contains(&e.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("query history: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("query history: {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 bytes = serde_json::to_vec(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(&tmp, bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_newest_first_without_duplicates_per_project_and_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("h.json");
|
||||
let a = HistoryStore::new(file.clone(), Path::new("/p/a"));
|
||||
let b = HistoryStore::new(file, Path::new("/p/b"));
|
||||
a.add("s1", "select 1", 1.0).unwrap();
|
||||
a.add("s1", " select 2 ", 2.0).unwrap();
|
||||
a.add("s1", "select 1", 3.0).unwrap();
|
||||
a.add("s2", "select 3", 4.0).unwrap();
|
||||
b.add("s1", "select 4", 5.0).unwrap();
|
||||
let texts = |v: Vec<HistoryEntry>| v.into_iter().map(|e| e.text).collect::<Vec<_>>();
|
||||
assert_eq!(texts(a.list("s1")), ["select 1", "select 2"]);
|
||||
assert_eq!(texts(a.list("s2")), ["select 3"]);
|
||||
assert_eq!(texts(b.list("s1")), ["select 4"]);
|
||||
a.remove("s1", Some(&["select 2".to_owned()])).unwrap();
|
||||
assert_eq!(texts(a.list("s1")), ["select 1"]);
|
||||
a.remove("s1", None).unwrap();
|
||||
assert!(a.list("s1").is_empty());
|
||||
assert_eq!(texts(b.list("s1")), ["select 4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let h = HistoryStore::new(dir.path().join("h.json"), Path::new("/p"));
|
||||
for i in 0..MAX_ENTRIES + 5 {
|
||||
h.add("s", &format!("select {i}"), i as f64).unwrap();
|
||||
}
|
||||
let list = h.list("s");
|
||||
assert_eq!(list.len(), MAX_ENTRIES);
|
||||
assert_eq!(list[0].text, format!("select {}", MAX_ENTRIES + 4));
|
||||
}
|
||||
}
|
||||
+338
-73
@@ -3,17 +3,24 @@
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
pub mod plan;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
pub mod sql;
|
||||
pub mod tunnel;
|
||||
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use source::{DataSource, DbDriver, DbSsh, DbSshAuth, DbSslMode};
|
||||
pub use history::HistoryEntry;
|
||||
pub use plan::{PlanNode, PlanProperty};
|
||||
pub use source::{
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
|
||||
};
|
||||
pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
use history::HistoryStore;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use ide_ssh::{SshManager, SshTarget, Tunnel};
|
||||
use parking_lot::Mutex;
|
||||
use secrets::SecretStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,13 +28,74 @@ use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbTableKind {
|
||||
Table,
|
||||
View,
|
||||
MaterializedView,
|
||||
}
|
||||
|
||||
impl DbTableKind {
|
||||
fn from_catalog(kind: Option<&str>) -> Self {
|
||||
match kind {
|
||||
Some("view") => Self::View,
|
||||
Some("materialized view") => Self::MaterializedView,
|
||||
_ => Self::Table,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema objects other than tables and columns (Go to DDL, Drop).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbTriggerInfo")]
|
||||
pub struct TriggerInfo {
|
||||
pub name: String,
|
||||
/// `before`, `after`, `instead of`.
|
||||
pub timing: String,
|
||||
/// `insert`, `update or delete`…
|
||||
pub events: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbRoutineInfo")]
|
||||
pub struct RoutineInfo {
|
||||
pub name: String,
|
||||
/// `Function` or `Procedure`.
|
||||
pub kind: DbObjectKind,
|
||||
pub arguments: String,
|
||||
/// Result type of a function.
|
||||
pub returns: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSequenceInfo")]
|
||||
pub struct SequenceInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -65,6 +133,7 @@ pub struct TableDetails {
|
||||
pub columns: Vec<ColumnInfo>,
|
||||
pub indexes: Vec<IndexInfo>,
|
||||
pub foreign_keys: Vec<ForeignKey>,
|
||||
pub triggers: Vec<TriggerInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -140,11 +209,12 @@ pub struct DbManager {
|
||||
tunnels: Mutex<HashMap<String, Arc<Tunnel>>>,
|
||||
/// Serialises opening tunnels so concurrent connects share one.
|
||||
tunnel_open: tokio::sync::Mutex<()>,
|
||||
/// Host keys awaiting the user's trust, by data source id.
|
||||
unknown_host_keys: Mutex<HashMap<String, UnknownHostKey>>,
|
||||
known_hosts: PathBuf,
|
||||
ssh_config: Option<PathBuf>,
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
/// Passwords entered this session (not saved, or not yet), by credential key.
|
||||
session_passwords: Mutex<HashMap<String, String>>,
|
||||
/// Manual-commit data sources, each with the connection of its open
|
||||
@@ -210,15 +280,33 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
|
||||
/// Says which leg failed when the database is unreachable from the SSH host.
|
||||
fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|_| tunneled && e.kind == ErrorKind::Io) else { return e };
|
||||
fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeError {
|
||||
let Some(via) = via.filter(|_| e.kind == ErrorKind::Io) else { return e };
|
||||
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("{} through the SSH tunnel ({}:{port} as seen from {})", e.message, source.host, ssh.host),
|
||||
format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host),
|
||||
)
|
||||
}
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
|
||||
if let Err(e) = p.rows("SELECT 1", 1).await {
|
||||
tracing::debug!("keep-alive: {}", e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rolls back a session's transaction and releases its connection.
|
||||
async fn end_session(session: &Session) {
|
||||
let held = session.lock().await.take();
|
||||
@@ -230,28 +318,33 @@ async fn end_session(session: &Session) {
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// `passwords` is the app-wide file of saved passwords ([`SecretStore`]).
|
||||
pub fn new(root: PathBuf, passwords: PathBuf) -> Self {
|
||||
let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh");
|
||||
Self::with_ssh_files(root, passwords, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config")))
|
||||
}
|
||||
|
||||
/// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests).
|
||||
pub fn with_ssh_files(
|
||||
root: PathBuf,
|
||||
passwords: PathBuf,
|
||||
known_hosts: PathBuf,
|
||||
ssh_config: Option<PathBuf>,
|
||||
) -> Self {
|
||||
/// `passwords` / `history` are the app-wide files of saved passwords
|
||||
/// ([`SecretStore`]) and query history ([`HistoryStore`]).
|
||||
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> Self {
|
||||
let history = Arc::new(HistoryStore::new(history, &root));
|
||||
let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>();
|
||||
{
|
||||
let history = history.clone();
|
||||
let spawned = std::thread::Builder::new().name("openidea-db-history".into()).spawn(move || {
|
||||
for (source, text, time) in statements {
|
||||
if let Err(e) = history.add(&source, &text, time) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!("query history recorder: {e}");
|
||||
}
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
pools: Mutex::default(),
|
||||
tunnels: Mutex::default(),
|
||||
tunnel_open: tokio::sync::Mutex::new(()),
|
||||
unknown_host_keys: Mutex::default(),
|
||||
known_hosts,
|
||||
ssh_config,
|
||||
ssh,
|
||||
session_passwords: Mutex::default(),
|
||||
sessions: Mutex::default(),
|
||||
}
|
||||
@@ -261,14 +354,16 @@ impl DbManager {
|
||||
source::load(&self.root)
|
||||
}
|
||||
|
||||
/// The data source of connection target `target`, with its database for `<id>:<database>`.
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
/// its database for `<id>:<database>`.
|
||||
pub fn source(&self, target: &str) -> IdeResult<DataSource> {
|
||||
let (id, database) = split_target(target);
|
||||
let mut source = self
|
||||
.sources()?
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?;
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?
|
||||
.resolved()?;
|
||||
if let Some(db) = database {
|
||||
source.database = db.to_owned();
|
||||
}
|
||||
@@ -303,7 +398,14 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
/// A data source using an SSH configuration sets that configuration's secret.
|
||||
pub async fn set_ssh_secret(&self, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
|
||||
if let Some(c) = config.filter(|c| !c.trim().is_empty()) {
|
||||
let save = self.ssh.config(&c).map(|c| c.save_secret).unwrap_or(save);
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
@@ -341,15 +443,35 @@ impl DbManager {
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
self.secret(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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`).
|
||||
pub async fn trust_ssh_host(&self, id: &str) -> IdeResult<()> {
|
||||
let key =
|
||||
self.unknown_host_keys.lock().remove(id).ok_or_else(|| IdeError::not_found("no host key to trust"))?;
|
||||
let path = self.known_hosts.clone();
|
||||
tokio::task::spawn_blocking(move || key.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
if self.ssh.trust_host(&self.host_key(id)).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
|
||||
match config.filter(|c| !c.trim().is_empty()) {
|
||||
Some(c) => self.ssh.trust_host(&c).await,
|
||||
None => Err(IdeError::not_found("no host key to trust")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the unknown host keys of data source `id`'s own SSH settings.
|
||||
fn host_key(&self, id: &str) -> String {
|
||||
format!("db:{}:{id}", self.root.display())
|
||||
}
|
||||
|
||||
/// How the SSH host of `ssh` is shown in errors.
|
||||
fn ssh_label(&self, ssh: &DbSsh) -> String {
|
||||
match self.ssh.config(ssh.config.trim()) {
|
||||
Ok(c) if !ssh.config.trim().is_empty() => c.label(),
|
||||
_ => ssh.host.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the SSH tunnel of `source` when it has one, returning the settings to
|
||||
@@ -373,35 +495,34 @@ impl DbManager {
|
||||
return Ok((local(&t), Some(t)));
|
||||
}
|
||||
}
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
let config = ssh.config.trim();
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
port: ssh.port,
|
||||
user: ssh.user.clone(),
|
||||
auth: ssh.auth,
|
||||
key_file: ssh.key_file.clone(),
|
||||
};
|
||||
Arc::new(self.ssh.connect(&self.host_key(&source.id), &target, secret.as_deref()).await?)
|
||||
} else {
|
||||
if let Some(s) = ssh_secret {
|
||||
let save = self.ssh.config(config)?.save_secret;
|
||||
self.ssh.set_secret(config, Some(s), save);
|
||||
}
|
||||
self.ssh.session(config).await?
|
||||
};
|
||||
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let spec = TunnelSpec {
|
||||
ssh,
|
||||
secret: secret.as_deref(),
|
||||
target_host,
|
||||
target_port,
|
||||
known_hosts: &self.known_hosts,
|
||||
ssh_config: self.ssh_config.as_deref(),
|
||||
};
|
||||
match Tunnel::open(spec).await {
|
||||
Ok(t) => {
|
||||
let t = Arc::new(t);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
Err(OpenError::Other(e)) => Err(e),
|
||||
Err(OpenError::UnknownHostKey(k)) => {
|
||||
let message = format!("Unknown SSH host key for {}:{} ({})", k.host, k.port, k.fingerprint());
|
||||
self.unknown_host_keys.lock().insert(source.id.clone(), *k);
|
||||
Err(IdeError::new(ErrorKind::Protocol, message))
|
||||
}
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
|
||||
/// Connects with `source` as given (not necessarily saved): Test Connection.
|
||||
@@ -411,14 +532,19 @@ impl DbManager {
|
||||
password: Option<String>,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let source = &source.resolved()?;
|
||||
let password = match password {
|
||||
Some(p) => Some(p),
|
||||
None => self.password(source).await,
|
||||
};
|
||||
let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root))
|
||||
.await
|
||||
.map_err(|e| through_tunnel(e, source, tunnel.is_some()))?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
|
||||
through_tunnel(
|
||||
e,
|
||||
source,
|
||||
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
|
||||
)
|
||||
})?;
|
||||
let version = match source.driver {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
@@ -455,12 +581,21 @@ impl DbManager {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
self.drop_unused_tunnel(source_id);
|
||||
return Err(through_tunnel(e, &source, tunnel.is_some()));
|
||||
return Err(through_tunnel(
|
||||
e,
|
||||
&source,
|
||||
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut pools = self.pools.lock();
|
||||
// Another caller may have connected meanwhile; keep theirs.
|
||||
Ok(pools.entry(id.to_owned()).or_insert(pool).clone())
|
||||
let kept = self.pools.lock().entry(id.to_owned()).or_insert_with(|| pool.clone()).clone();
|
||||
if Arc::ptr_eq(&kept, &pool)
|
||||
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
|
||||
{
|
||||
keep_alive(&pool, Duration::from_secs(secs.into()));
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Closes the tunnel of `source_id` once none of its targets is connected.
|
||||
@@ -496,24 +631,30 @@ impl DbManager {
|
||||
|
||||
/// The databases of the server of data source `id` (PostgreSQL), the connected one first.
|
||||
pub async fn databases(&self, id: &str) -> IdeResult<Databases> {
|
||||
let pool = self.pool(split_target(id).0).await?;
|
||||
let id = split_target(id).0;
|
||||
let introspection = self.source(id)?.introspection;
|
||||
let system = introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
if pool.driver() != DbDriver::Postgres {
|
||||
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
|
||||
}
|
||||
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
|
||||
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
|
||||
let rows = pool.rows(sql::DATABASES, MAX_META_ROWS).await?;
|
||||
let rows = pool.rows(sql::databases(introspection.show_templates), MAX_META_ROWS).await?;
|
||||
let mut names: Vec<String> =
|
||||
first(rows).filter(|n| *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
|
||||
first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
|
||||
if !names.contains(¤t) {
|
||||
names.insert(0, current.clone());
|
||||
}
|
||||
Ok(Databases { current, names })
|
||||
}
|
||||
|
||||
/// Every schema (the Schemas tab picks the ones the tree shows); system schemas
|
||||
/// only with [`DbIntrospection::show_system`].
|
||||
pub async fn schemas(&self, id: &str) -> IdeResult<Vec<String>> {
|
||||
let system = self.source(id)?.introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
let rows = pool.rows(sql::schemas(pool.driver()), MAX_META_ROWS).await?;
|
||||
let rows = pool.rows(sql::schemas(pool.driver(), system), MAX_META_ROWS).await?;
|
||||
Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect())
|
||||
}
|
||||
|
||||
@@ -525,9 +666,7 @@ impl DbManager {
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
let name = it.next().flatten()?;
|
||||
let kind =
|
||||
if it.next().flatten().as_deref() == Some("view") { DbTableKind::View } else { DbTableKind::Table };
|
||||
Some(TableInfo { name, kind })
|
||||
Some(TableInfo { name, kind: DbTableKind::from_catalog(it.next().flatten().as_deref()) })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -575,7 +714,85 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
Ok(TableDetails { columns, indexes, foreign_keys })
|
||||
let triggers = pool
|
||||
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("triggers: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
let name = it.next().flatten()?;
|
||||
let (timing, events) = if driver == DbDriver::Sqlite {
|
||||
sql::sqlite_trigger_event(&it.next().flatten().unwrap_or_default())
|
||||
} else {
|
||||
(it.next().flatten().unwrap_or_default(), it.next().flatten().unwrap_or_default())
|
||||
};
|
||||
Some(TriggerInfo { name, timing, events })
|
||||
})
|
||||
.collect();
|
||||
Ok(TableDetails { columns, indexes, foreign_keys, triggers })
|
||||
}
|
||||
|
||||
/// The routines and sequences of `schema`.
|
||||
pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let mut out = SchemaObjects::default();
|
||||
if let Some(q) = sql::routines(driver, schema) {
|
||||
out.routines = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
let name = it.next().flatten()?;
|
||||
let kind = if it.next().flatten().as_deref() == Some("procedure") {
|
||||
DbObjectKind::Procedure
|
||||
} else {
|
||||
DbObjectKind::Function
|
||||
};
|
||||
let arguments = it.next().flatten().unwrap_or_default();
|
||||
let returns = it.next().flatten().filter(|r| !r.is_empty() && kind == DbObjectKind::Function);
|
||||
Some(RoutineInfo { name, kind, arguments, returns })
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
Some(SequenceInfo {
|
||||
name: it.next().flatten()?,
|
||||
data_type: it.next().flatten().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| r.into_iter().nth(column).flatten())
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect();
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
Ok(defs.join("\n"))
|
||||
}
|
||||
|
||||
/// The DDL of table or view `name`: SQLite's stored statements, MySQL's
|
||||
@@ -671,7 +888,7 @@ impl DbManager {
|
||||
_ => tables.push(CompletionTable {
|
||||
schema,
|
||||
name,
|
||||
kind: if kind == "view" { DbTableKind::View } else { DbTableKind::Table },
|
||||
kind: DbTableKind::from_catalog(Some(&kind)),
|
||||
columns: vec![column],
|
||||
}),
|
||||
}
|
||||
@@ -695,12 +912,60 @@ impl DbManager {
|
||||
Ok(CompletionModel { default_schema, tables, foreign_keys })
|
||||
}
|
||||
|
||||
/// Runs `statement` on `id` (in its open transaction in manual-commit mode),
|
||||
/// recording it in the data source's query history.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, max_rows, cancel).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
};
|
||||
if ran {
|
||||
self.remember_statement(id, statement);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn remember_statement(&self, id: &str, statement: &str) {
|
||||
let time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0.0, |d| d.as_secs_f64() * 1000.0);
|
||||
let _ = self.recorder.send((split_target(id).0.to_owned(), statement.to_owned(), time));
|
||||
}
|
||||
|
||||
/// The query history of data source `id`, newest first.
|
||||
pub async fn history(&self, id: &str) -> IdeResult<Vec<HistoryEntry>> {
|
||||
let (history, source) = (self.history.clone(), split_target(id).0.to_owned());
|
||||
tokio::task::spawn_blocking(move || history.list(&source)).await.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Forgets statements of the query history of `id` (all of them for `None`).
|
||||
pub async fn remove_history(&self, id: &str, texts: Option<Vec<String>>) -> IdeResult<()> {
|
||||
let (history, source) = (self.history.clone(), split_target(id).0.to_owned());
|
||||
tokio::task::spawn_blocking(move || history.remove(&source, texts.as_deref()))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Execution plans (IDEA's Explain Plan): the `EXPLAIN` statement of each
|
||||
//! dialect and its output read into one flat tree of operations.
|
||||
|
||||
use crate::DbDriver;
|
||||
use crate::conn::QueryResult;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPlanProperty")]
|
||||
pub struct PlanProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// One operation of a plan; `parent` indexes the plan's node list (roots: `None`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPlanNode")]
|
||||
pub struct PlanNode {
|
||||
pub parent: Option<u32>,
|
||||
/// `Full Scan`, `Index Scan`, `Hash Join`…
|
||||
pub operation: String,
|
||||
/// The object it works on and its key details (`table: o;`).
|
||||
pub params: String,
|
||||
pub properties: Vec<PlanProperty>,
|
||||
}
|
||||
|
||||
/// The statement that explains `statement` without running it.
|
||||
pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
let s = statement.trim().trim_end_matches(';');
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
|
||||
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the result of [`explain_statement`].
|
||||
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
|
||||
match driver {
|
||||
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
|
||||
DbDriver::Postgres => {
|
||||
let text: String =
|
||||
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
|
||||
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for root in json.as_array().into_iter().flatten() {
|
||||
if let Some(plan) = root.get("Plan") {
|
||||
postgres(plan, None, &mut out);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
Ok(mysql(&names, &result.rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prop(name: &str, value: impl Into<String>) -> PlanProperty {
|
||||
PlanProperty { name: name.to_owned(), value: value.into() }
|
||||
}
|
||||
|
||||
/// SQLite rows: `id, parent, notused, detail` (`SCAN o`, `SEARCH c USING INTEGER PRIMARY KEY (rowid=?)`).
|
||||
fn sqlite(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
|
||||
let (id, parent, detail) = (cell(0), cell(1), cell(3));
|
||||
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
|
||||
let (operation, params) = sqlite_operation(&detail);
|
||||
out.push(PlanNode { parent, operation, params, properties: vec![prop("Detail", detail.clone())] });
|
||||
ids.push(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sqlite_operation(detail: &str) -> (String, String) {
|
||||
let words: Vec<&str> = detail.split_whitespace().collect();
|
||||
let object = |i: usize| words.get(i).map(|t| format!("table: {t};")).unwrap_or_default();
|
||||
let using = || detail.find(" USING ").map(|i| detail[i + 1..].to_owned()).unwrap_or_default();
|
||||
match words.first().copied() {
|
||||
Some("SCAN") if detail.contains(" USING ") => ("Index Scan".into(), format!("{} {}", object(1), using())),
|
||||
Some("SCAN") => ("Full Scan".into(), object(1)),
|
||||
Some("SEARCH") => ("Index Scan".into(), format!("{} {}", object(1), using()).trim().to_owned()),
|
||||
Some("USE") if detail.contains("ORDER BY") => ("Order By".into(), String::new()),
|
||||
Some("USE") if detail.contains("GROUP BY") => ("Group By".into(), String::new()),
|
||||
Some("USE") if detail.contains("DISTINCT") => ("Distinct".into(), String::new()),
|
||||
_ => (detail.to_owned(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL JSON plan node: `Node Type`, `Relation Name`, nested `Plans`.
|
||||
fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
|
||||
let text = |k: &str| node.get(k).and_then(Value::as_str).unwrap_or_default();
|
||||
let mut operation = text("Node Type").to_owned();
|
||||
if let Some(j) = node.get("Join Type").and_then(Value::as_str).filter(|j| *j != "Inner") {
|
||||
operation = format!("{operation} ({j})");
|
||||
}
|
||||
let mut params = Vec::new();
|
||||
if let Some(r) = node.get("Relation Name").and_then(Value::as_str) {
|
||||
let alias = text("Alias");
|
||||
params.push(if alias.is_empty() || alias == r {
|
||||
format!("table: {r};")
|
||||
} else {
|
||||
format!("table: {r} {alias};")
|
||||
});
|
||||
}
|
||||
if let Some(i) = node.get("Index Name").and_then(Value::as_str) {
|
||||
params.push(format!("index: {i};"));
|
||||
}
|
||||
for k in ["Index Cond", "Hash Cond", "Merge Cond", "Filter", "Join Filter"] {
|
||||
if let Some(c) = node.get(k).and_then(Value::as_str) {
|
||||
params.push(format!("{}: {c};", k.to_lowercase()));
|
||||
}
|
||||
}
|
||||
let properties = node
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, _)| k.as_str() != "Plans")
|
||||
.map(|(k, v)| {
|
||||
let value = match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
prop(k, value)
|
||||
})
|
||||
.collect();
|
||||
let me = out.len() as u32;
|
||||
out.push(PlanNode { parent, operation, params: params.join(" "), properties });
|
||||
for child in node.get("Plans").and_then(Value::as_array).into_iter().flatten() {
|
||||
postgres(child, Some(me), out);
|
||||
}
|
||||
}
|
||||
|
||||
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
|
||||
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], n: &str| {
|
||||
names.iter().position(|c| c.eq_ignore_ascii_case(n)).and_then(|i| r.get(i).cloned().flatten())
|
||||
};
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let access = col(r, "type").unwrap_or_default();
|
||||
let operation = match access.as_str() {
|
||||
"ALL" => "Full Scan".to_owned(),
|
||||
"index" => "Full Index Scan".to_owned(),
|
||||
"range" => "Index Range Scan".to_owned(),
|
||||
"ref" | "eq_ref" | "ref_or_null" => "Index Lookup".to_owned(),
|
||||
"const" | "system" => "Constant Lookup".to_owned(),
|
||||
"" => col(r, "Extra").unwrap_or_else(|| "Select".to_owned()),
|
||||
other => other.to_owned(),
|
||||
};
|
||||
let mut params = Vec::new();
|
||||
if let Some(t) = col(r, "table") {
|
||||
params.push(format!("table: {t};"));
|
||||
}
|
||||
if let Some(k) = col(r, "key") {
|
||||
params.push(format!("key: {k};"));
|
||||
}
|
||||
let properties = names.iter().zip(r).filter_map(|(n, v)| v.as_ref().map(|v| prop(n, v.clone()))).collect();
|
||||
PlanNode { parent: None, operation, params: params.join(" "), properties }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::conn::ResultColumn;
|
||||
|
||||
fn result(columns: &[&str], rows: Vec<Vec<Option<&str>>>) -> QueryResult {
|
||||
QueryResult {
|
||||
statement: String::new(),
|
||||
columns: columns.iter().map(|c| ResultColumn { name: (*c).into(), type_name: String::new() }).collect(),
|
||||
rows: rows.into_iter().map(|r| r.into_iter().map(|v| v.map(str::to_owned)).collect()).collect(),
|
||||
affected: None,
|
||||
has_more: false,
|
||||
elapsed_ms: 0.0,
|
||||
execution_ms: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_per_dialect() {
|
||||
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_sqlite_plans_like_idea() {
|
||||
let r = result(
|
||||
&["id", "parent", "notused", "detail"],
|
||||
vec![
|
||||
vec![Some("3"), Some("0"), Some("0"), Some("SCAN o")],
|
||||
vec![Some("5"), Some("0"), Some("0"), Some("SEARCH c USING INTEGER PRIMARY KEY (rowid=?)")],
|
||||
vec![Some("9"), Some("0"), Some("0"), Some("USE TEMP B-TREE FOR ORDER BY")],
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Sqlite, &r).unwrap();
|
||||
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
|
||||
assert_eq!(
|
||||
ops,
|
||||
[
|
||||
("Full Scan", "table: o;"),
|
||||
("Index Scan", "table: c; USING INTEGER PRIMARY KEY (rowid=?)"),
|
||||
("Order By", "")
|
||||
]
|
||||
);
|
||||
assert!(plan.iter().all(|n| n.parent.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nests_sqlite_children_under_their_parent() {
|
||||
let r = result(
|
||||
&["id", "parent", "notused", "detail"],
|
||||
vec![
|
||||
vec![Some("2"), Some("0"), Some("0"), Some("CO-ROUTINE sub")],
|
||||
vec![Some("4"), Some("2"), Some("0"), Some("SCAN t")],
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Sqlite, &r).unwrap();
|
||||
assert_eq!(plan[1].parent, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_postgres_json_trees() {
|
||||
let json = r#"[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Hash Cond": "(o.customer_id = c.id)",
|
||||
"Total Cost": 12.5, "Plans": [
|
||||
{"Node Type": "Seq Scan", "Relation Name": "orders", "Alias": "o", "Filter": "(status <> 'x')"},
|
||||
{"Node Type": "Hash", "Plans": [{"Node Type": "Seq Scan", "Relation Name": "customers", "Alias": "c"}]}
|
||||
]}}]"#;
|
||||
let plan = parse(DbDriver::Postgres, &result(&["QUERY PLAN"], vec![vec![Some(json)]])).unwrap();
|
||||
assert_eq!(plan.len(), 4);
|
||||
assert_eq!(plan[0].operation, "Hash Join");
|
||||
assert_eq!(plan[0].params, "hash cond: (o.customer_id = c.id);");
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "Total Cost" && p.value == "12.5"));
|
||||
assert_eq!((plan[1].parent, plan[1].params.as_str()), (Some(0), "table: orders o; filter: (status <> 'x');"));
|
||||
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mysql_rows() {
|
||||
let r = result(
|
||||
&["id", "select_type", "table", "type", "key", "rows", "Extra"],
|
||||
vec![
|
||||
vec![Some("1"), Some("SIMPLE"), Some("o"), Some("ALL"), None, Some("20"), Some("Using where")],
|
||||
vec![Some("1"), Some("SIMPLE"), Some("c"), Some("eq_ref"), Some("PRIMARY"), Some("1"), None],
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Mysql, &r).unwrap();
|
||||
assert_eq!((plan[0].operation.as_str(), plan[0].params.as_str()), ("Full Scan", "table: o;"));
|
||||
assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;"));
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20"));
|
||||
}
|
||||
}
|
||||
@@ -35,25 +35,103 @@ pub enum DbSslMode {
|
||||
#[default]
|
||||
Prefer,
|
||||
Require,
|
||||
/// Require, and check the server certificate against the CA file.
|
||||
VerifyCa,
|
||||
/// [`DbSslMode::VerifyCa`], and check that the certificate names the host.
|
||||
VerifyFull,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type").
|
||||
/// IDEA's data source colours (tree node and console tab tint), e.g. red for production.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbColor {
|
||||
Red,
|
||||
Orange,
|
||||
Yellow,
|
||||
Green,
|
||||
Blue,
|
||||
Violet,
|
||||
}
|
||||
|
||||
/// Transaction control of the data source's consoles.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbSshAuth {
|
||||
Password,
|
||||
/// A private key file, optionally protected by a passphrase.
|
||||
KeyPair,
|
||||
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
|
||||
pub enum DbTxMode {
|
||||
#[default]
|
||||
OpenSsh,
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Options tab: session settings applied to every connection.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbOptions {
|
||||
pub transaction: DbTxMode,
|
||||
/// PostgreSQL `search_path` (unqualified names resolve there).
|
||||
pub default_schema: String,
|
||||
/// Session time zone (`TimeZone` / `time_zone`); empty keeps the default.
|
||||
pub time_zone: String,
|
||||
/// Statements run on every new connection.
|
||||
pub startup_script: String,
|
||||
/// Run `SELECT 1` this often while connected.
|
||||
pub keep_alive_secs: Option<u32>,
|
||||
/// Disconnect after this long without statements (enforced by the UI, which sees the activity).
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbIntrospection {
|
||||
pub all_databases: bool,
|
||||
pub databases: Vec<String>,
|
||||
pub all_schemas: bool,
|
||||
pub schemas: Vec<String>,
|
||||
/// `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…).
|
||||
pub show_system: bool,
|
||||
/// PostgreSQL template databases (`template0`, `template1`).
|
||||
pub show_templates: bool,
|
||||
/// Object filter: the tree lists only tables, views, routines and sequences matching
|
||||
/// one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all.
|
||||
pub object_filter: String,
|
||||
}
|
||||
|
||||
impl Default for DbIntrospection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
all_databases: true,
|
||||
databases: Vec::new(),
|
||||
all_schemas: true,
|
||||
schemas: Vec::new(),
|
||||
show_system: false,
|
||||
show_templates: false,
|
||||
object_filter: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced tab: a driver property. PostgreSQL: run-time parameters (and
|
||||
/// `application_name`); MySQL: session variables (and `charset`, `collation`);
|
||||
/// SQLite: pragmas. See [`crate::conn`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations).
|
||||
pub use ide_core::remote_settings::SshAuth as DbSshAuth;
|
||||
|
||||
/// Connect through an SSH tunnel: the database host and port are reached from `host`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbSsh {
|
||||
pub enabled: bool,
|
||||
/// A Settings › SSH Configurations id to connect with; empty = the fields below.
|
||||
pub config: String,
|
||||
/// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`].
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
@@ -84,6 +162,20 @@ pub struct DataSource {
|
||||
pub read_only: bool,
|
||||
/// SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`).
|
||||
pub ssh: Option<DbSsh>,
|
||||
/// Wire-compatible product preset (`cockroachdb`, `tidb`…; empty: the driver itself).
|
||||
pub vendor: String,
|
||||
pub color: Option<DbColor>,
|
||||
pub comment: String,
|
||||
/// Connect with [`DataSource::url`] instead of the host / port / database fields.
|
||||
pub url_only: bool,
|
||||
pub url: String,
|
||||
/// PEM files; `~/` is the home directory.
|
||||
pub ssl_ca_file: String,
|
||||
pub ssl_cert_file: String,
|
||||
pub ssl_key_file: String,
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -101,10 +193,188 @@ impl Default for DataSource {
|
||||
save_password: false,
|
||||
read_only: false,
|
||||
ssh: None,
|
||||
vendor: String::new(),
|
||||
color: None,
|
||||
comment: String::new(),
|
||||
url_only: false,
|
||||
url: String::new(),
|
||||
ssl_ca_file: String::new(),
|
||||
ssl_cert_file: String::new(),
|
||||
ssl_key_file: String::new(),
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection settings of a URL (`jdbc:` prefix allowed).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ParsedUrl {
|
||||
pub driver: Option<DbDriver>,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub password: Option<String>,
|
||||
/// Query parameters, in order.
|
||||
pub params: Vec<(String, String)>,
|
||||
/// SQLite file.
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
let hex = |i: usize| b.get(i).and_then(|&c| (c as char).to_digit(16));
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& let (Some(h), Some(l)) = (hex(i + 1), hex(i + 2))
|
||||
{
|
||||
out.push((h * 16 + l) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The driver speaking the protocol of URL scheme `scheme` (wire-compatible products included).
|
||||
pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
|
||||
/// included, and `sqlite:path`.
|
||||
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
|
||||
let url = raw.trim();
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
let driver = scheme_driver(scheme).ok_or_else(bad)?;
|
||||
if driver == DbDriver::Sqlite {
|
||||
let file = rest.strip_prefix("//").unwrap_or(rest);
|
||||
let file = file.split('?').next().unwrap_or_default();
|
||||
if file.is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
return Ok(ParsedUrl { driver: Some(driver), file: Some(percent_decode(file)), ..Default::default() });
|
||||
}
|
||||
let rest = rest.strip_prefix("//").ok_or_else(bad)?;
|
||||
let (main, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let (authority, database) = main.split_once('/').unwrap_or((main, ""));
|
||||
let (userinfo, hostport) = match authority.rsplit_once('@') {
|
||||
Some((u, h)) => (Some(u), h),
|
||||
None => (None, authority),
|
||||
};
|
||||
// Several hosts (failover lists): the first.
|
||||
let hostport = hostport.split(',').next().unwrap_or_default();
|
||||
let (host, port) = if let Some(v6) = hostport.strip_prefix('[') {
|
||||
let (h, after) = v6.split_once(']').ok_or_else(bad)?;
|
||||
(h.to_owned(), after.strip_prefix(':'))
|
||||
} else {
|
||||
match hostport.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_owned(), Some(p)),
|
||||
None => (hostport.to_owned(), None),
|
||||
}
|
||||
};
|
||||
let port = match port.filter(|p| !p.is_empty()) {
|
||||
Some(p) => Some(p.parse::<u16>().map_err(|_| bad())?),
|
||||
None => None,
|
||||
};
|
||||
let mut parsed = ParsedUrl {
|
||||
driver: Some(driver),
|
||||
host,
|
||||
port,
|
||||
database: percent_decode(database.trim_end_matches('/')),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(u) = userinfo {
|
||||
let (user, password) = match u.split_once(':') {
|
||||
Some((u, p)) => (u, Some(percent_decode(p))),
|
||||
None => (u, None),
|
||||
};
|
||||
parsed.user = percent_decode(user);
|
||||
parsed.password = password;
|
||||
}
|
||||
for pair in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
let (k, v) = (percent_decode(k), percent_decode(v));
|
||||
match k.as_str() {
|
||||
"user" if parsed.user.is_empty() => parsed.user = v,
|
||||
"password" if parsed.password.is_none() => parsed.password = Some(v),
|
||||
_ => parsed.params.push((k, v)),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
/// The settings to connect with: with [`DataSource::url_only`], host, port,
|
||||
/// database, user, SSL and driver properties come from the URL (properties
|
||||
/// of the Advanced tab still apply after the URL's).
|
||||
pub fn resolved(&self) -> IdeResult<DataSource> {
|
||||
if !self.url_only {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
let u = parse_url(&self.url)?;
|
||||
let mut s = self.clone();
|
||||
if let Some(d) = u.driver
|
||||
&& d != s.driver
|
||||
{
|
||||
return Err(IdeError::invalid(format!("the URL is not for the {:?} driver", s.driver)));
|
||||
}
|
||||
if s.driver == DbDriver::Sqlite {
|
||||
s.file = u.file.map(IpcPath::from);
|
||||
return Ok(s);
|
||||
}
|
||||
s.host = u.host;
|
||||
s.port = u.port;
|
||||
s.database = u.database;
|
||||
if !u.user.is_empty() {
|
||||
s.user = u.user;
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (k, v) in u.params {
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"sslmode" | "ssl-mode" => {
|
||||
s.ssl = match v.to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"disable" | "disabled" => DbSslMode::Disable,
|
||||
"require" | "required" => DbSslMode::Require,
|
||||
"verify_ca" => DbSslMode::VerifyCa,
|
||||
"verify_full" | "verify_identity" => DbSslMode::VerifyFull,
|
||||
_ => DbSslMode::Prefer,
|
||||
}
|
||||
}
|
||||
"sslrootcert" | "ssl-ca" | "sslca" => s.ssl_ca_file = v,
|
||||
"sslcert" | "ssl-cert" => s.ssl_cert_file = v,
|
||||
"sslkey" | "ssl-key" => s.ssl_key_file = v,
|
||||
_ => properties.push(DbProperty { name: k, value: v }),
|
||||
}
|
||||
}
|
||||
properties.append(&mut s.properties);
|
||||
s.properties = properties;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The password written in the URL of a [`DataSource::url_only`] data source.
|
||||
pub fn url_password(&self) -> Option<String> {
|
||||
if !self.url_only {
|
||||
return None;
|
||||
}
|
||||
parse_url(&self.url).ok()?.password
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct SourcesFile {
|
||||
#[serde(default, rename = "data-source")]
|
||||
@@ -164,4 +434,77 @@ mod tests {
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
|
||||
assert!(save(tmp.path(), &[DataSource::default()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_configs_written_before_the_new_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join(".openidea");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(FILE),
|
||||
"[[data-source]]\nid = \"a\"\nname = \"old\"\ndriver = \"postgres\"\nhost = \"db\"\nssl = \"require\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let s = load(tmp.path()).unwrap().remove(0);
|
||||
assert_eq!(s.ssl, DbSslMode::Require);
|
||||
assert!(s.introspection.all_databases && s.introspection.all_schemas);
|
||||
assert_eq!((s.color, s.url_only, s.options.transaction), (None, false, DbTxMode::Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_the_new_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let s = DataSource {
|
||||
id: "b".into(),
|
||||
name: "prod".into(),
|
||||
driver: DbDriver::Mysql,
|
||||
vendor: "tidb".into(),
|
||||
color: Some(DbColor::Red),
|
||||
comment: "careful".into(),
|
||||
ssl: DbSslMode::VerifyFull,
|
||||
ssl_ca_file: "~/ca.pem".into(),
|
||||
options: DbOptions { time_zone: "UTC".into(), keep_alive_secs: Some(60), ..Default::default() },
|
||||
introspection: DbIntrospection { all_schemas: false, schemas: vec!["app*".into()], ..Default::default() },
|
||||
properties: vec![DbProperty { name: "sql_mode".into(), value: "ANSI".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
save(tmp.path(), std::slice::from_ref(&s)).unwrap();
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Postgres));
|
||||
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(5433), "shop"));
|
||||
assert_eq!((u.user.as_str(), u.password.as_deref()), ("ann", Some("s@cret")));
|
||||
assert_eq!(u.params, vec![("sslmode".into(), "require".into()), ("search_path".into(), "app".into())]);
|
||||
let u = parse_url("mysql://[::1]/app?user=root").unwrap();
|
||||
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
assert!(parse_url("oracle://x").is_err());
|
||||
assert!(parse_url("postgres://h:99999/db").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_only_sources_connect_with_the_url() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Postgres,
|
||||
user: "fallback".into(),
|
||||
url_only: true,
|
||||
url: "postgres://ann:pw@h:6543/app?sslmode=verify-full&sslrootcert=/ca.pem&statement_timeout=5000".into(),
|
||||
properties: vec![DbProperty { name: "work_mem".into(), value: "64MB".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let r = s.resolved().unwrap();
|
||||
assert_eq!((r.host.as_str(), r.port, r.database.as_str(), r.user.as_str()), ("h", Some(6543), "app", "ann"));
|
||||
assert_eq!((r.ssl, r.ssl_ca_file.as_str()), (DbSslMode::VerifyFull, "/ca.pem"));
|
||||
let names: Vec<_> = r.properties.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["statement_timeout", "work_mem"]);
|
||||
assert_eq!(s.url_password().as_deref(), Some("pw"));
|
||||
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
|
||||
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,26 @@ pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases one can connect to (PostgreSQL).
|
||||
pub const DATABASES: &str = "SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname";
|
||||
/// PostgreSQL databases one can connect to; template databases only with `templates`.
|
||||
pub fn databases(templates: bool) -> &'static str {
|
||||
if templates {
|
||||
"SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname"
|
||||
} else {
|
||||
"SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname"
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases of managed services that their users cannot use (hidden unless connected to).
|
||||
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
|
||||
|
||||
pub fn schemas(driver: DbDriver) -> &'static str {
|
||||
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
@@ -53,7 +64,7 @@ pub fn schemas(driver: DbDriver) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, kind` where kind is `table` or `view`.
|
||||
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
|
||||
pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!(
|
||||
@@ -61,7 +72,13 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
_ => format!(
|
||||
DbDriver::Postgres => format!(
|
||||
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
WHERE n.nspname = {} AND c.relkind IN ('r', 'v', 'm', 'p', 'f') ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Mysql => format!(
|
||||
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
|
||||
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
@@ -134,7 +151,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY m.name, p.cid"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"SELECT n.nspname, c.relname, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END, a.attname, \
|
||||
"SELECT n.nspname, c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END, a.attname, \
|
||||
format_type(a.atttypid, a.atttypmod), CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, \
|
||||
CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary \
|
||||
AND a.attnum = ANY(i.indkey)) THEN 1 ELSE 0 END, \
|
||||
@@ -363,6 +380,123 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Rows: `name, timing, events` of the triggers of a table (SQLite: `name, sql`).
|
||||
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
DbDriver::Postgres => format!(
|
||||
"SELECT t.tgname, CASE WHEN t.tgtype & 2 <> 0 THEN 'before' WHEN t.tgtype & 64 <> 0 THEN 'instead of' \
|
||||
ELSE 'after' END, concat_ws(' or ', CASE WHEN t.tgtype & 4 <> 0 THEN 'insert' END, \
|
||||
CASE WHEN t.tgtype & 16 <> 0 THEN 'update' END, CASE WHEN t.tgtype & 8 <> 0 THEN 'delete' END, \
|
||||
CASE WHEN t.tgtype & 32 <> 0 THEN 'truncate' END) \
|
||||
FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
WHERE NOT t.tgisinternal AND n.nspname = {s} AND c.relname = {t} ORDER BY 1"
|
||||
),
|
||||
DbDriver::Mysql => format!(
|
||||
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
|
||||
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `before`, `insert` of SQLite's `CREATE TRIGGER name BEFORE INSERT ON …` (its catalog keeps only the text).
|
||||
pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
|
||||
let upper = sql.to_ascii_uppercase();
|
||||
let words: Vec<&str> = upper.split_whitespace().collect();
|
||||
let timing = if words.contains(&"BEFORE") {
|
||||
"before"
|
||||
} else if words.windows(2).any(|w| w == ["INSTEAD", "OF"]) {
|
||||
"instead of"
|
||||
} else {
|
||||
"after"
|
||||
};
|
||||
let event = ["INSERT", "UPDATE", "DELETE"].into_iter().find(|e| words.contains(e)).unwrap_or_default();
|
||||
(timing.to_owned(), event.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Rows: `name, kind (function/procedure), arguments, result` of a schema's routines (none in SQLite).
|
||||
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
let s = quote_literal(driver, schema);
|
||||
match driver {
|
||||
DbDriver::Sqlite => None,
|
||||
// Functions of extensions are left out, as in IDEA.
|
||||
DbDriver::Postgres => Some(format!(
|
||||
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
|
||||
pg_get_function_identity_arguments(p.oid), pg_get_function_result(p.oid) \
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
|
||||
WHERE n.nspname = {s} AND p.prokind IN ('f', 'p') \
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') ORDER BY 1, 3"
|
||||
)),
|
||||
DbDriver::Mysql => Some(format!(
|
||||
"SELECT r.routine_name, lower(r.routine_type), \
|
||||
(SELECT GROUP_CONCAT(CONCAT(p.parameter_name, ' ', p.dtd_identifier) ORDER BY p.ordinal_position \
|
||||
SEPARATOR ', ') FROM information_schema.parameters p WHERE p.specific_schema = r.routine_schema \
|
||||
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
(driver == DbDriver::Postgres).then(|| {
|
||||
format!(
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The statement reading the definition of a routine (a row per overload), trigger
|
||||
/// or sequence, and the column that holds it.
|
||||
pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, name: &str) -> Option<(String, usize)> {
|
||||
use crate::DbObjectKind as K;
|
||||
let (s, n) = (quote_literal(driver, schema), quote_literal(driver, name));
|
||||
let target = qualified(driver, schema, name);
|
||||
Some(match (driver, kind) {
|
||||
(DbDriver::Sqlite, K::Trigger) => (
|
||||
format!(
|
||||
"SELECT sql FROM {}.sqlite_master WHERE type = 'trigger' AND name = {n}",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Postgres, K::Function | K::Procedure) => (
|
||||
format!(
|
||||
"SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
|
||||
WHERE n.nspname = {s} AND p.proname = {n} ORDER BY pg_get_function_identity_arguments(p.oid)"
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Postgres, K::Trigger) => (
|
||||
format!(
|
||||
"SELECT pg_get_triggerdef(t.oid, true) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid \
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
WHERE NOT t.tgisinternal AND n.nspname = {s} AND t.tgname = {n}"
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Postgres, K::Sequence) => (
|
||||
format!(
|
||||
"SELECT format(E'create sequence %s\\n as %s\\n increment by %s\\n minvalue %s\\n \
|
||||
maxvalue %s\\n start with %s%s', {}, data_type, increment_by, min_value, max_value, start_value, \
|
||||
CASE WHEN cycle THEN E'\\n cycle' ELSE '' END) \
|
||||
FROM pg_sequences WHERE schemaname = {s} AND sequencename = {n}",
|
||||
quote_literal(driver, &target)
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
|
||||
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
|
||||
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A column of [`postgres_table_ddl`].
|
||||
pub struct DdlColumn {
|
||||
pub name: String,
|
||||
@@ -452,6 +586,31 @@ mod tests {
|
||||
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_trigger_events() {
|
||||
let ev = sqlite_trigger_event;
|
||||
assert_eq!(
|
||||
ev("CREATE TRIGGER t BEFORE UPDATE OF a ON x BEGIN SELECT 1; END"),
|
||||
("before".into(), "update".into())
|
||||
);
|
||||
assert_eq!(ev("create trigger t instead of insert on v begin end"), ("instead of".into(), "insert".into()));
|
||||
assert_eq!(ev("CREATE TRIGGER t DELETE ON x BEGIN END"), ("after".into(), "delete".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
object_ddl(DbDriver::Mysql, "db", K::Procedure, "p"),
|
||||
Some(("SHOW CREATE PROCEDURE `db`.`p`".into(), 2))
|
||||
);
|
||||
assert!(object_ddl(DbDriver::Sqlite, "main", K::Function, "f").is_none());
|
||||
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_ddl() {
|
||||
let cols = [
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
//! SSH tunnels for data sources: a listener on 127.0.0.1 whose connections are
|
||||
//! forwarded to the database through `direct-tcpip` channels of an SSH session
|
||||
//! (russh), optionally reached through `ProxyJump` hosts.
|
||||
//!
|
||||
//! Host keys are checked against `known_hosts`; an unknown key fails the
|
||||
//! connection with [`OpenError::UnknownHostKey`] so the UI can ask the user,
|
||||
//! and a changed key is always refused.
|
||||
|
||||
use crate::source::{DbSsh, DbSshAuth};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
use russh::client::{self, Handle};
|
||||
use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/// A host key the user has not trusted yet.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnknownHostKey {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub key: PublicKey,
|
||||
}
|
||||
|
||||
impl UnknownHostKey {
|
||||
/// `ED25519 SHA256:…`, as OpenSSH prints it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256))
|
||||
}
|
||||
|
||||
/// Records the key in `known_hosts` (blocking).
|
||||
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
|
||||
known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display())))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OpenError {
|
||||
UnknownHostKey(Box<UnknownHostKey>),
|
||||
Other(IdeError),
|
||||
}
|
||||
|
||||
impl From<IdeError> for OpenError {
|
||||
fn from(e: IdeError) -> Self {
|
||||
OpenError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where and how to connect.
|
||||
pub struct TunnelSpec<'a> {
|
||||
pub ssh: &'a DbSsh,
|
||||
/// SSH password or key passphrase.
|
||||
pub secret: Option<&'a str>,
|
||||
/// The database, as seen from the SSH host.
|
||||
pub target_host: &'a str,
|
||||
pub target_port: u16,
|
||||
pub known_hosts: &'a Path,
|
||||
/// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]).
|
||||
pub ssh_config: Option<&'a Path>,
|
||||
}
|
||||
|
||||
/// An open tunnel; dropping it stops forwarding and closes the SSH sessions.
|
||||
pub struct Tunnel {
|
||||
port: u16,
|
||||
session: Arc<Handle<Client>>,
|
||||
/// Sessions of the `ProxyJump` hosts, kept alive under `session`.
|
||||
_jumps: Vec<Handle<Client>>,
|
||||
accept: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Connects and starts listening on a free local port.
|
||||
pub async fn open(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22));
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_inner(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let hops = resolve_hops(spec.ssh, spec.ssh_config)?;
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
let target = i == hops.len() - 1;
|
||||
let mut handle = match sessions.last() {
|
||||
None => connect::<tokio::net::TcpStream>(hop, spec.known_hosts, None).await?,
|
||||
Some(prev) => {
|
||||
let channel = prev
|
||||
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
|
||||
connect(hop, spec.known_hosts, Some(channel.into_stream())).await?
|
||||
}
|
||||
};
|
||||
// Jump hosts always use the agent / identity files; the secret belongs to the target.
|
||||
let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh };
|
||||
authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?;
|
||||
sessions.push(handle);
|
||||
}
|
||||
let session = Arc::new(sessions.pop().expect("at least one hop"));
|
||||
// Fail fast with the real reason when the SSH host cannot reach the database
|
||||
// (a refused forward otherwise surfaces as the driver's connect timeout).
|
||||
let via = hops.last().expect("at least one hop").label();
|
||||
session
|
||||
.channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?;
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?;
|
||||
let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port();
|
||||
let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port));
|
||||
Ok(Tunnel { port, session, _jumps: sessions, accept })
|
||||
}
|
||||
|
||||
/// The local port to connect to instead of the database.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// The SSH session ended (network loss, server restart).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.session.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tunnel {
|
||||
fn drop(&mut self) {
|
||||
self.accept.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(listener: TcpListener, session: Arc<Handle<Client>>, host: String, port: u16) {
|
||||
loop {
|
||||
let Ok((mut socket, peer)) = listener.accept().await else { return };
|
||||
let session = session.clone();
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
match session
|
||||
.channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port()))
|
||||
.await
|
||||
{
|
||||
Ok(channel) => {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await;
|
||||
}
|
||||
Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError {
|
||||
let why = match e {
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => {
|
||||
"connection refused or host unreachable"
|
||||
}
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => {
|
||||
"port forwarding is not allowed by the SSH server (AllowTcpForwarding)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let why = if why.is_empty() { e.to_string() } else { why.to_owned() };
|
||||
io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}"))
|
||||
}
|
||||
|
||||
fn io(message: impl Into<String>) -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, message)
|
||||
}
|
||||
|
||||
// --- hosts ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Hop {
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
identity_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Hop {
|
||||
fn label(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
/// `~/x` → `$HOME/x`.
|
||||
fn expand(path: &str) -> PathBuf {
|
||||
match (path.strip_prefix("~/"), home()) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_user() -> String {
|
||||
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
|
||||
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
|
||||
let c = config
|
||||
.and_then(|p| russh_config::parse_path(p, alias).ok())
|
||||
.unwrap_or_else(|| russh_config::Config::default(alias));
|
||||
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
|
||||
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
|
||||
(hop, c.host_config.proxy_jump.clone())
|
||||
}
|
||||
|
||||
/// `[user@]host[:port]` of a `ProxyJump` entry.
|
||||
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
||||
let (user, rest) = match spec.rsplit_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, spec),
|
||||
};
|
||||
let (host, port) = match rest.rsplit_once(':') {
|
||||
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
||||
_ => (rest, None),
|
||||
};
|
||||
let (mut hop, _) = config_hop(config, host);
|
||||
if let Some(u) = user {
|
||||
hop.user = u.to_owned();
|
||||
}
|
||||
if let Some(p) = port {
|
||||
hop.port = p;
|
||||
}
|
||||
hop
|
||||
}
|
||||
|
||||
/// The SSH hosts to go through, the tunnel's host last.
|
||||
fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
|
||||
let host = ssh.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("SSH host is empty"));
|
||||
}
|
||||
let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh {
|
||||
config_hop(config, host)
|
||||
} else {
|
||||
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
|
||||
};
|
||||
if let Some(p) = ssh.port {
|
||||
hop.port = p;
|
||||
}
|
||||
if !ssh.user.trim().is_empty() {
|
||||
hop.user = ssh.user.trim().to_owned();
|
||||
}
|
||||
let mut hops: Vec<Hop> = proxy_jump
|
||||
.filter(|j| !j.eq_ignore_ascii_case("none"))
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default();
|
||||
hops.push(hop);
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
// --- connection ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HostKeyIssue {
|
||||
Unknown(PublicKey),
|
||||
Changed(usize),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
known_hosts: PathBuf,
|
||||
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let key = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
||||
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
||||
};
|
||||
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
||||
Ok(true) => return Ok(true),
|
||||
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
||||
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
||||
};
|
||||
*self.issue.lock() = Some(issue);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, OpenError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(Duration::from_secs(30)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let issue = Arc::new(Mutex::new(None));
|
||||
let handler =
|
||||
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
|
||||
let result = match stream {
|
||||
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
||||
Some(s) => client::connect_stream(config, s, handler).await,
|
||||
};
|
||||
result.map_err(|e| match issue.lock().take() {
|
||||
Some(HostKeyIssue::Unknown(key)) => {
|
||||
OpenError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key }))
|
||||
}
|
||||
Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
|
||||
hop.host, hop.port
|
||||
),
|
||||
)),
|
||||
None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
|
||||
})
|
||||
}
|
||||
|
||||
// --- authentication ------------------------------------------------------------
|
||||
|
||||
fn denied(message: String) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
|
||||
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
|
||||
}
|
||||
|
||||
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
|
||||
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
|
||||
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
|
||||
Ok(r.success())
|
||||
}
|
||||
|
||||
/// Every identity of the running SSH agent, until one is accepted.
|
||||
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
let agent = keys::agent::client::AgentClient::connect_env().await;
|
||||
#[cfg(windows)]
|
||||
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
|
||||
let Ok(mut agent) = agent else { return false };
|
||||
let Ok(identities) = agent.request_identities().await else { return false };
|
||||
let hash = rsa_hash(handle).await;
|
||||
for id in identities {
|
||||
let ok = match id {
|
||||
keys::agent::AgentIdentity::PublicKey { key, .. } => {
|
||||
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
|
||||
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
};
|
||||
if ok.unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
handle: &mut Handle<Client>,
|
||||
hop: &Hop,
|
||||
mode: DbSshAuth,
|
||||
key_file: &str,
|
||||
secret: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
let user = hop.user.as_str();
|
||||
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
|
||||
match mode {
|
||||
DbSshAuth::Password => {
|
||||
let Some(password) = secret else {
|
||||
return Err(denied(format!("SSH password required for {}", hop.label())));
|
||||
};
|
||||
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
|
||||
if r.success() { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::KeyPair => {
|
||||
if key_file.trim().is_empty() {
|
||||
return Err(IdeError::invalid("SSH private key file is not set"));
|
||||
}
|
||||
let path = expand(key_file.trim());
|
||||
let key = load_key(&path, secret)?;
|
||||
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::OpenSsh => {
|
||||
if with_agent(handle, user).await {
|
||||
return Ok(());
|
||||
}
|
||||
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
|
||||
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
|
||||
};
|
||||
let mut locked: Option<PathBuf> = None;
|
||||
for path in files.iter().filter(|p| p.is_file()) {
|
||||
match load_key(path, secret) {
|
||||
Ok(key) => {
|
||||
if with_key(handle, user, key).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
|
||||
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
|
||||
}
|
||||
}
|
||||
match locked {
|
||||
// An encrypted key could not be tried: ask for its passphrase.
|
||||
Some(path) if secret.is_none() => {
|
||||
Err(denied(format!("SSH key passphrase required for {}", path.display())))
|
||||
}
|
||||
_ => Err(failed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
|
||||
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
|
||||
match keys::load_secret_key(path, passphrase) {
|
||||
Ok(k) => Ok(k),
|
||||
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
|
||||
Err(e) if passphrase.is_some() => {
|
||||
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
|
||||
}
|
||||
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
|
||||
)
|
||||
.unwrap();
|
||||
let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
|
||||
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
|
||||
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
|
||||
|
||||
// Explicit fields win over the config.
|
||||
let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh };
|
||||
assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "[email protected]:22");
|
||||
|
||||
// Without OpenSSH mode the host is used as is.
|
||||
let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
|
||||
assert!(resolve_hops(&DbSsh::default(), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Introspection, queries and table edits against a real SQLite file.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbTableKind, SortKey};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
std::fs::File::create(root.join("app.db")).unwrap();
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"));
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "s1".into(),
|
||||
name: "app".into(),
|
||||
@@ -246,3 +247,74 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
|
||||
assert!(db.ddl("s1", "main", "nope").await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explains_queries_without_running_them() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
|
||||
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
|
||||
assert_eq!(ops, vec!["Index Scan", "Order By"]);
|
||||
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
|
||||
db.explain("s1", "DELETE FROM users", &c).await?;
|
||||
assert_eq!(count(&db).await, "3");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_executed_statements_in_the_query_history() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
db.execute("s1", "SELECT 1", 1, &c).await?;
|
||||
assert!(db.execute("s1", "SELECT nope", 1, &c).await.is_err());
|
||||
// Recorded off the async threads.
|
||||
let mut texts = Vec::new();
|
||||
for _ in 0..50 {
|
||||
texts = db.history("s1").await?.into_iter().map(|e| e.text).collect::<Vec<_>>();
|
||||
if texts.first().map(String::as_str) == Some("SELECT nope") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
assert_eq!(texts[..2], ["SELECT nope", "SELECT 1"]);
|
||||
db.remove_history("s1", Some(vec!["SELECT 1".into()])).await?;
|
||||
assert!(!db.history("s1").await?.iter().any(|e| e.text == "SELECT 1"));
|
||||
db.remove_history("s1", None).await?;
|
||||
assert!(db.history("s1").await?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_triggers_and_their_ddl() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
db.execute("s1", "CREATE TRIGGER users_touch AFTER UPDATE ON users BEGIN SELECT 1; END", 1, &c).await?;
|
||||
let d = db.table_details("s1", "main", "users").await?;
|
||||
let triggers: Vec<_> = d.triggers.iter().map(|t| (t.name.as_str(), t.timing.as_str(), t.events.as_str())).collect();
|
||||
assert_eq!(triggers, vec![("users_touch", "after", "update")]);
|
||||
let ddl = db.object_ddl("s1", "main", ide_db::DbObjectKind::Trigger, "users_touch").await?;
|
||||
assert!(ddl.starts_with("CREATE TRIGGER users_touch"), "{ddl}");
|
||||
// SQLite has neither routines nor sequences.
|
||||
assert_eq!(db.schema_objects("s1", "main").await?, ide_db::SchemaObjects::default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeResult<()> {
|
||||
let (tmp, db) = setup().await;
|
||||
let mut s = db.sources()?.remove(0);
|
||||
s.properties = vec![DbProperty { name: "cache_size".into(), value: "-1234".into() }];
|
||||
s.options = DbOptions {
|
||||
startup_script: "CREATE TEMP TABLE boot (x); INSERT INTO boot VALUES (7);".into(),
|
||||
..Default::default()
|
||||
};
|
||||
db.save_sources(vec![s.clone()]).await?;
|
||||
let c = CancellationToken::new();
|
||||
let r = db.execute("s1", "SELECT (SELECT x FROM boot), (SELECT cache_size FROM pragma_cache_size)", 10, &c).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("7".into()), Some("-1234".into())]]);
|
||||
// URL only: the file comes from the URL.
|
||||
let url = format!("jdbc:sqlite:{}", tmp.path().join("app.db").display());
|
||||
db.save_sources(vec![DataSource { url_only: true, url, file: None, ..s }]).await?;
|
||||
assert_eq!(db.schemas("s1").await?, vec!["main"]);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
//! SSH tunnels against an in-process russh server that forwards `direct-tcpip`
|
||||
//! channels to a local echo server.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec};
|
||||
use ide_db::{DbSsh, DbSshAuth};
|
||||
use russh::Channel;
|
||||
use russh::keys::{PrivateKey, PublicKey, decode_secret_key};
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name)
|
||||
}
|
||||
|
||||
fn key(name: &str, passphrase: Option<&str>) -> PrivateKey {
|
||||
decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Sshd {
|
||||
client_key: PublicKey,
|
||||
}
|
||||
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Sshd;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Sshd {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for Sshd {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_direct_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
host: &str,
|
||||
port: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
tokio::spawn(async move {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo server standing in for the database.
|
||||
async fn echo() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut s, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = s.split();
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
async fn sshd() -> u16 {
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![key("host", None)],
|
||||
auth_rejection_time: std::time::Duration::from_millis(10),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
||||
..Default::default()
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
|
||||
tokio::spawn(async move {
|
||||
let _ = server.run_on_socket(config, &listener).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
known_hosts: PathBuf,
|
||||
ssh_port: u16,
|
||||
db_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let known_hosts = tmp.path().join("known_hosts");
|
||||
Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn ssh(&self, auth: DbSshAuth) -> DbSsh {
|
||||
DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
Tunnel::open(TunnelSpec {
|
||||
ssh,
|
||||
secret,
|
||||
target_host: "127.0.0.1",
|
||||
target_port: self.db_port,
|
||||
known_hosts: &self.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Trusts the server's key on the first connection, as the UI does.
|
||||
async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
match self.open(ssh, secret).await {
|
||||
Err(OpenError::UnknownHostKey(k)) => {
|
||||
k.trust(&self.known_hosts).unwrap();
|
||||
self.open(ssh, secret).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip(t: &Tunnel) {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
|
||||
s.write_all(b"SELECT 1").await.unwrap();
|
||||
let mut buf = [0u8; 8];
|
||||
s.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"SELECT 1");
|
||||
}
|
||||
|
||||
fn denied(r: Result<Tunnel, OpenError>) -> String {
|
||||
match r {
|
||||
Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
|
||||
Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"),
|
||||
Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
|
||||
Ok(_) => panic!("expected permission denied, connected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else {
|
||||
panic!("expected an unknown host key");
|
||||
};
|
||||
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
|
||||
k.trust(&env.known_hosts).unwrap();
|
||||
round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await;
|
||||
|
||||
// Another key recorded for the same host: refused, never offered for trust.
|
||||
let other = key("client", None).public_key().to_openssh().unwrap();
|
||||
std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
||||
let msg = denied(env.open(&ssh, Some("secret")).await);
|
||||
assert!(msg.contains("has changed"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_authentication() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap();
|
||||
round_trip(&tunnel).await;
|
||||
round_trip(&tunnel).await;
|
||||
assert!(!tunnel.is_closed());
|
||||
|
||||
assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required"));
|
||||
assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_authentication_with_and_without_passphrase() {
|
||||
let env = env().await;
|
||||
let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) };
|
||||
round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await;
|
||||
|
||||
// An encrypted key the server does not accept: passphrase first, then the key is refused.
|
||||
let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() };
|
||||
assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required"));
|
||||
assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase"));
|
||||
assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_database_fails_fast_with_the_reason() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap());
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let r = Tunnel::open(TunnelSpec {
|
||||
ssh: &ssh,
|
||||
secret: Some("secret"),
|
||||
target_host: "127.0.0.1",
|
||||
target_port: closed,
|
||||
known_hosts: &env.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await;
|
||||
let Err(OpenError::Other(e)) = r else { panic!("expected an error") };
|
||||
assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message);
|
||||
}
|
||||
@@ -15,3 +15,6 @@ which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Docker connections (which daemon the CLI talks to) and registries, as the
|
||||
//! Services tool window's "Add Service" menu creates them. Both are app-wide
|
||||
//! like in IDEA: `docker.json` in the app data directory; registry passwords
|
||||
//! live beside it in an owner-only `docker-passwords.json`.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Which daemon a docker command talks to.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum DockerEndpoint {
|
||||
/// Whatever the CLI is configured for (`DOCKER_HOST`, the current context).
|
||||
#[default]
|
||||
Default,
|
||||
/// A local daemon socket, e.g. `/var/run/docker.sock` (or a `npipe://` URL on Windows).
|
||||
Socket { path: String },
|
||||
/// A remote daemon, e.g. `tcp://build:2376` or `ssh://me@build`; with TLS when
|
||||
/// `tls_cert_dir` holds `ca.pem`, `cert.pem` and `key.pem`.
|
||||
Tcp { host: String, tls_cert_dir: Option<String> },
|
||||
/// A named `docker context`.
|
||||
Context { name: String },
|
||||
}
|
||||
|
||||
impl DockerEndpoint {
|
||||
/// Global CLI options selecting this endpoint (before the subcommand).
|
||||
pub fn global_args(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Default => Vec::new(),
|
||||
Self::Socket { path } => {
|
||||
let path = path.trim();
|
||||
let url = if path.contains("://") { path.to_owned() } else { format!("unix://{path}") };
|
||||
vec!["--host".into(), url]
|
||||
}
|
||||
Self::Tcp { host, tls_cert_dir } => {
|
||||
let host = host.trim();
|
||||
let url = if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") };
|
||||
let mut args = vec!["--host".into(), url];
|
||||
if let Some(dir) = tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
let file = |name: &str| Path::new(dir).join(name).to_string_lossy().into_owned();
|
||||
args.extend([
|
||||
"--tlsverify".into(),
|
||||
"--tlscacert".into(),
|
||||
file("ca.pem"),
|
||||
"--tlscert".into(),
|
||||
file("cert.pem"),
|
||||
"--tlskey".into(),
|
||||
file("key.pem"),
|
||||
]);
|
||||
}
|
||||
args
|
||||
}
|
||||
Self::Context { name } => vec!["--context".into(), name.trim().to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the global options to `cmd`; an explicit endpoint also drops the
|
||||
/// inherited `DOCKER_HOST` / `DOCKER_CONTEXT`, which would conflict.
|
||||
pub(crate) fn apply(&self, cmd: &mut Command) {
|
||||
if *self != Self::Default {
|
||||
cmd.env_remove("DOCKER_HOST").env_remove("DOCKER_CONTEXT").env_remove("DOCKER_TLS_VERIFY");
|
||||
}
|
||||
cmd.args(self.global_args());
|
||||
}
|
||||
|
||||
/// The same selection as environment variables, for docker command lines run by
|
||||
/// others (run configurations). An empty value clears an inherited variable: the
|
||||
/// CLI treats `DOCKER_HOST=""` as unset.
|
||||
pub fn env_vars(&self) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
let mut set = |k: &str, v: String| {
|
||||
env.insert(k.to_owned(), v);
|
||||
};
|
||||
match self {
|
||||
Self::Default => {}
|
||||
Self::Socket { path } => {
|
||||
let path = path.trim();
|
||||
set("DOCKER_HOST", if path.contains("://") { path.to_owned() } else { format!("unix://{path}") });
|
||||
set("DOCKER_CONTEXT", String::new());
|
||||
set("DOCKER_TLS_VERIFY", String::new());
|
||||
}
|
||||
Self::Tcp { host, tls_cert_dir } => {
|
||||
let host = host.trim();
|
||||
set("DOCKER_HOST", if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") });
|
||||
set("DOCKER_CONTEXT", String::new());
|
||||
match tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
// `DOCKER_CERT_PATH` holds `ca.pem`, `cert.pem` and `key.pem`, as `global_args` expects.
|
||||
Some(dir) => {
|
||||
set("DOCKER_TLS_VERIFY", "1".into());
|
||||
set("DOCKER_CERT_PATH", dir.to_owned());
|
||||
}
|
||||
None => set("DOCKER_TLS_VERIFY", String::new()),
|
||||
}
|
||||
}
|
||||
Self::Context { name } => {
|
||||
set("DOCKER_CONTEXT", name.trim().to_owned());
|
||||
set("DOCKER_HOST", String::new());
|
||||
set("DOCKER_TLS_VERIFY", String::new());
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> IdeResult<()> {
|
||||
let empty = match self {
|
||||
Self::Default => false,
|
||||
Self::Socket { path } => path.trim().is_empty(),
|
||||
Self::Tcp { host, .. } => host.trim().is_empty(),
|
||||
Self::Context { name } => name.trim().is_empty() || name.trim().starts_with('-'),
|
||||
};
|
||||
if empty { Err(IdeError::invalid("the Docker connection needs a socket, host or context")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub endpoint: DockerEndpoint,
|
||||
}
|
||||
|
||||
/// A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerRegistry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `docker.io`, `ghcr.io`, `registry.example.com:5000`.
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerSettings {
|
||||
#[serde(default = "default_connections")]
|
||||
pub connections: Vec<DockerConnection>,
|
||||
#[serde(default)]
|
||||
pub registries: Vec<DockerRegistry>,
|
||||
}
|
||||
|
||||
impl Default for DockerSettings {
|
||||
fn default() -> Self {
|
||||
Self { connections: default_connections(), registries: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
|
||||
fn default_connections() -> Vec<DockerConnection> {
|
||||
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
|
||||
}
|
||||
|
||||
const SETTINGS_FILE: &str = "docker.json";
|
||||
const PASSWORDS_FILE: &str = "docker-passwords.json";
|
||||
|
||||
pub fn load_settings(data_dir: &Path) -> DockerSettings {
|
||||
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{SETTINGS_FILE}: {e}");
|
||||
DockerSettings::default()
|
||||
}),
|
||||
Err(_) => DockerSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(data_dir: &Path, settings: &DockerSettings) -> IdeResult<()> {
|
||||
for c in &settings.connections {
|
||||
if c.id.is_empty() || c.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every Docker connection needs a name"));
|
||||
}
|
||||
c.endpoint.validate()?;
|
||||
}
|
||||
for r in &settings.registries {
|
||||
if r.id.is_empty() || r.address.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every Docker registry needs an address"));
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(settings).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
write_file(&data_dir.join(SETTINGS_FILE), &bytes, false)?;
|
||||
// Forget passwords of registries that are gone.
|
||||
let mut passwords = read_passwords(data_dir);
|
||||
let before = passwords.len();
|
||||
passwords.retain(|id, _| settings.registries.iter().any(|r| &r.id == id));
|
||||
if passwords.len() != before { write_passwords(data_dir, &passwords) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn read_passwords(data_dir: &Path) -> BTreeMap<String, String> {
|
||||
std::fs::read(data_dir.join(PASSWORDS_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_passwords(data_dir: &Path, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
write_file(&data_dir.join(PASSWORDS_FILE), &bytes, true)
|
||||
}
|
||||
|
||||
pub fn registry_password(data_dir: &Path, id: &str) -> Option<String> {
|
||||
read_passwords(data_dir).remove(id)
|
||||
}
|
||||
|
||||
/// Saves (or with `None` forgets) the password of registry `id`.
|
||||
pub fn set_registry_password(data_dir: &Path, id: &str, password: Option<&str>) -> IdeResult<()> {
|
||||
let mut all = read_passwords(data_dir);
|
||||
match password {
|
||||
Some(p) => all.insert(id.to_owned(), p.to_owned()),
|
||||
None => all.remove(id),
|
||||
};
|
||||
write_passwords(data_dir, &all)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8], private: bool) -> IdeResult<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
std::io::Write::write_all(&mut options.open(&tmp)?, bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical registry host: no scheme or path; Docker Hub's aliases become `docker.io`.
|
||||
pub fn registry_host(address: &str) -> String {
|
||||
let a = address.trim();
|
||||
let a = a.split_once("://").map_or(a, |(_, rest)| rest);
|
||||
let host = a.split('/').next().unwrap_or_default().to_ascii_lowercase();
|
||||
match host.as_str() {
|
||||
"index.docker.io" | "registry-1.docker.io" | "registry.hub.docker.com" | "hub.docker.com" => "docker.io".into(),
|
||||
_ => host,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry host an image reference pulls from (`postgres:16` → `docker.io`).
|
||||
pub fn reference_registry(reference: &str) -> String {
|
||||
match reference.split_once('/') {
|
||||
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => registry_host(first),
|
||||
_ => "docker.io".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials for a `docker login`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegistryAuth {
|
||||
pub address: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// The saved registry (with a password) matching `reference`, if any.
|
||||
pub fn auth_for(data_dir: &Path, reference: &str) -> Option<RegistryAuth> {
|
||||
let host = reference_registry(reference);
|
||||
let settings = load_settings(data_dir);
|
||||
let registry =
|
||||
settings.registries.into_iter().find(|r| registry_host(&r.address) == host && !r.username.is_empty())?;
|
||||
let password = registry_password(data_dir, ®istry.id)?;
|
||||
Some(RegistryAuth { address: registry.address, username: registry.username, password })
|
||||
}
|
||||
|
||||
/// A throwaway docker config directory: logins happen there, never in the user's
|
||||
/// `~/.docker/config.json`. Contexts are copied so a context endpoint still resolves.
|
||||
pub(crate) struct TempConfig {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TempConfig {
|
||||
pub fn new() -> IdeResult<Self> {
|
||||
let nanos =
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
let dir = std::env::temp_dir().join(format!("openidea-docker-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
if let Some(contexts) = user_config_dir().map(|d| d.join("contexts")).filter(|d| d.is_dir()) {
|
||||
let _ = copy_dir(&contexts, &dir.join("contexts"));
|
||||
}
|
||||
Ok(Self { dir })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempConfig {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn user_config_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })?;
|
||||
Some(PathBuf::from(home).join(".docker"))
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for e in std::fs::read_dir(from)?.flatten() {
|
||||
let target = to.join(e.file_name());
|
||||
if e.file_type()?.is_dir() {
|
||||
copy_dir(&e.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(e.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_args() {
|
||||
assert!(DockerEndpoint::Default.global_args().is_empty());
|
||||
assert_eq!(
|
||||
DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.global_args(),
|
||||
["--host", "unix:///var/run/docker.sock"]
|
||||
);
|
||||
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.global_args();
|
||||
assert_eq!(tls[..3], ["--host", "tcp://build:2376", "--tlsverify"]);
|
||||
assert!(tls.contains(&Path::new("/certs").join("key.pem").to_string_lossy().into_owned()));
|
||||
assert_eq!(
|
||||
DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: Some(" ".into()) }.global_args(),
|
||||
["--host", "ssh://me@box"]
|
||||
);
|
||||
assert_eq!(DockerEndpoint::Context { name: "orbstack".into() }.global_args(), ["--context", "orbstack"]);
|
||||
assert!(DockerEndpoint::Context { name: "--x".into() }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_env() {
|
||||
assert!(DockerEndpoint::Default.env_vars().is_empty());
|
||||
let socket = DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.env_vars();
|
||||
assert_eq!(socket["DOCKER_HOST"], "unix:///var/run/docker.sock");
|
||||
assert_eq!(socket["DOCKER_CONTEXT"], "");
|
||||
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.env_vars();
|
||||
assert_eq!(
|
||||
(tls["DOCKER_HOST"].as_str(), tls["DOCKER_TLS_VERIFY"].as_str(), tls["DOCKER_CERT_PATH"].as_str()),
|
||||
("tcp://build:2376", "1", "/certs")
|
||||
);
|
||||
let ssh = DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: None }.env_vars();
|
||||
assert_eq!((ssh["DOCKER_HOST"].as_str(), ssh["DOCKER_TLS_VERIFY"].as_str()), ("ssh://me@box", ""));
|
||||
assert!(!ssh.contains_key("DOCKER_CERT_PATH"));
|
||||
let ctx = DockerEndpoint::Context { name: " orbstack ".into() }.env_vars();
|
||||
assert_eq!((ctx["DOCKER_CONTEXT"].as_str(), ctx["DOCKER_HOST"].as_str()), ("orbstack", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registries_match_references() {
|
||||
assert_eq!(reference_registry("postgres:16"), "docker.io");
|
||||
assert_eq!(reference_registry("library/postgres"), "docker.io");
|
||||
assert_eq!(reference_registry("ghcr.io/org/app:1"), "ghcr.io");
|
||||
assert_eq!(reference_registry("localhost:5000/app"), "localhost:5000");
|
||||
assert_eq!(registry_host("https://index.docker.io/v1/"), "docker.io");
|
||||
assert_eq!(registry_host("GHCR.io"), "ghcr.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_passwords() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
|
||||
let settings = DockerSettings {
|
||||
connections: vec![DockerConnection {
|
||||
id: "c1".into(),
|
||||
name: "Build box".into(),
|
||||
endpoint: DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None },
|
||||
}],
|
||||
registries: vec![DockerRegistry {
|
||||
id: "r1".into(),
|
||||
name: "GHCR".into(),
|
||||
address: "ghcr.io".into(),
|
||||
username: "me".into(),
|
||||
}],
|
||||
};
|
||||
save_settings(dir, &settings).unwrap();
|
||||
assert_eq!(load_settings(dir), settings);
|
||||
set_registry_password(dir, "r1", Some("s3cret")).unwrap();
|
||||
let auth = auth_for(dir, "ghcr.io/org/app").unwrap();
|
||||
assert_eq!((auth.username.as_str(), auth.password.as_str()), ("me", "s3cret"));
|
||||
assert!(auth_for(dir, "postgres").is_none());
|
||||
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
|
||||
assert_eq!(registry_password(dir, "r1"), None);
|
||||
// An empty list stays empty (the default connection is only for a missing file).
|
||||
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
|
||||
assert!(load_settings(dir).connections.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod connection;
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use connection::*;
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -61,21 +63,38 @@ pub fn docker_executable() -> Option<PathBuf> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Environment for docker command lines a run configuration launches: the endpoint's
|
||||
/// variables plus a `PATH` that starts with docker's directory (GUI apps on macOS
|
||||
/// lack the usual install locations), so `docker` / `docker compose` resolve.
|
||||
pub fn run_env(ep: &DockerEndpoint) -> std::collections::BTreeMap<String, String> {
|
||||
let mut env = ep.env_vars();
|
||||
if let Some(dir) = docker_executable().as_deref().and_then(Path::parent) {
|
||||
let current = ide_core::process::host_var_os("PATH").unwrap_or_default();
|
||||
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
|
||||
dirs.extend(std::env::split_paths(¤t).filter(|d| d != dir));
|
||||
if let Ok(path) = std::env::join_paths(dirs) {
|
||||
env.insert("PATH".into(), path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
fn binary() -> IdeResult<PathBuf> {
|
||||
docker_executable()
|
||||
.ok_or_else(|| IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH"))
|
||||
}
|
||||
|
||||
fn command(args: &[&str]) -> IdeResult<Command> {
|
||||
fn command(ep: &DockerEndpoint, args: &[&str]) -> IdeResult<Command> {
|
||||
let mut cmd = Command::new(binary()?);
|
||||
ep.apply(&mut cmd);
|
||||
cmd.args(args).env("DOCKER_CLI_HINTS", "false").env("NO_COLOR", "1");
|
||||
tracing::debug!(?args, "docker");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Runs a docker command, mapping a non-zero exit to an error with docker's message.
|
||||
fn run(args: &[&str], timeout: Duration) -> IdeResult<String> {
|
||||
let out = process::run(command(args)?, timeout, None)?;
|
||||
fn run(ep: &DockerEndpoint, args: &[&str], timeout: Duration) -> IdeResult<String> {
|
||||
let out = process::run(command(ep, args)?, timeout, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
@@ -94,8 +113,8 @@ fn failure(args: &[&str], stderr: &str, stdout: &str) -> IdeError {
|
||||
}
|
||||
|
||||
/// Whether docker can be used right now.
|
||||
pub fn availability() -> DockerAvailability {
|
||||
let Ok(cmd) = command(&["version", "--format", "{{json .}}"]) else {
|
||||
pub fn availability(ep: &DockerEndpoint) -> DockerAvailability {
|
||||
let Ok(cmd) = command(ep, &["version", "--format", "{{json .}}"]) else {
|
||||
return DockerAvailability::NotInstalled;
|
||||
};
|
||||
match process::run(cmd, Duration::from_secs(15), None) {
|
||||
@@ -105,8 +124,8 @@ pub fn availability() -> DockerAvailability {
|
||||
}
|
||||
|
||||
/// Availability plus containers, images, volumes and networks (listed in parallel).
|
||||
pub fn snapshot() -> DockerSnapshot {
|
||||
let availability = availability();
|
||||
pub fn snapshot(ep: &DockerEndpoint) -> DockerSnapshot {
|
||||
let availability = availability(ep);
|
||||
let executable = docker_executable().map(|p| ide_core::ipc_path::to_ipc(&p));
|
||||
let mut snap = DockerSnapshot {
|
||||
availability,
|
||||
@@ -116,16 +135,17 @@ pub fn snapshot() -> DockerSnapshot {
|
||||
volumes: Vec::new(),
|
||||
networks: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
global_args: ep.global_args(),
|
||||
};
|
||||
if !matches!(snap.availability, DockerAvailability::Ready { .. }) {
|
||||
return snap;
|
||||
}
|
||||
const FMT: &str = "{{json .}}";
|
||||
let (containers, images, volumes, networks) = std::thread::scope(|s| {
|
||||
let c = s.spawn(|| run(&["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
|
||||
let i = s.spawn(|| run(&["image", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let v = s.spawn(|| run(&["volume", "ls", "--format", FMT], QUICK));
|
||||
let n = s.spawn(|| run(&["network", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let c = s.spawn(|| run(ep, &["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
|
||||
let i = s.spawn(|| run(ep, &["image", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let v = s.spawn(|| run(ep, &["volume", "ls", "--format", FMT], QUICK));
|
||||
let n = s.spawn(|| run(ep, &["network", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let join = |h: std::thread::ScopedJoinHandle<'_, IdeResult<String>>| {
|
||||
h.join().unwrap_or_else(|_| Err(IdeError::internal("docker listing panicked")))
|
||||
};
|
||||
@@ -178,13 +198,13 @@ impl ContainerOp {
|
||||
}
|
||||
|
||||
/// Starts / stops / … the containers `ids`.
|
||||
pub fn container_op(ids: &[String], op: ContainerOp) -> IdeResult<()> {
|
||||
pub fn container_op(ep: &DockerEndpoint, ids: &[String], op: ContainerOp) -> IdeResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut args = vec!["container", op.verb()];
|
||||
args.extend(ids.iter().map(String::as_str));
|
||||
run(&args, LIFECYCLE).map(drop)
|
||||
run(ep, &args, LIFECYCLE).map(drop)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -208,7 +228,7 @@ impl DockerObject {
|
||||
}
|
||||
|
||||
/// `docker <object> rm` (`force`: stop running containers / untag used images first).
|
||||
pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
|
||||
pub fn remove(ep: &DockerEndpoint, object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -217,12 +237,12 @@ pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()
|
||||
args.push("--force");
|
||||
}
|
||||
args.extend(ids.iter().map(String::as_str));
|
||||
run(&args, LIFECYCLE).map(drop)
|
||||
run(ep, &args, LIFECYCLE).map(drop)
|
||||
}
|
||||
|
||||
/// `docker inspect` of one object, pretty-printed.
|
||||
pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
let out = run(&["inspect", "--type", object.noun(), id], QUICK)?;
|
||||
pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
let out = run(ep, &["inspect", "--type", object.noun(), id], QUICK)?;
|
||||
match serde_json::from_str::<serde_json::Value>(&out) {
|
||||
// A one-element array for a single id: show the object itself.
|
||||
Ok(serde_json::Value::Array(mut items)) if items.len() == 1 => {
|
||||
@@ -236,6 +256,7 @@ pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
|
||||
/// until it ends or `cancel` fires.
|
||||
pub fn logs(
|
||||
ep: &DockerEndpoint,
|
||||
id: &str,
|
||||
tail: u32,
|
||||
follow: bool,
|
||||
@@ -252,16 +273,16 @@ pub fn logs(
|
||||
args.push("--timestamps");
|
||||
}
|
||||
args.push(id);
|
||||
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, text| {
|
||||
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, text| {
|
||||
on_line(DockerLogLine { stderr: stream == Stream::Stderr, text })
|
||||
})?;
|
||||
if ok { Ok(()) } else { Err(failure(&args, &stderr.join("\n"), "")) }
|
||||
}
|
||||
|
||||
/// Follows `docker events` until it ends (daemon gone) or `cancel` fires.
|
||||
pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
|
||||
pub fn watch_events(ep: &DockerEndpoint, cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
|
||||
let args = ["events", "--format", "{{json .}}"];
|
||||
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, line| {
|
||||
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, line| {
|
||||
if stream == Stream::Stdout
|
||||
&& let Some(e) = parse_event(&line)
|
||||
{
|
||||
@@ -272,9 +293,19 @@ pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent))
|
||||
}
|
||||
|
||||
/// Runs a long docker command (pull, prune, compose…), streaming its output lines.
|
||||
fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
fn run_streaming(
|
||||
ep: &DockerEndpoint,
|
||||
config: Option<&Path>,
|
||||
args: &[&str],
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
on_output(format!("docker {}", args.join(" ")));
|
||||
let (ok, stderr) = process::stream(command(args)?, cancel, &mut |_, line| {
|
||||
let mut cmd = command(ep, args)?;
|
||||
if let Some(dir) = config {
|
||||
cmd.env("DOCKER_CONFIG", dir);
|
||||
}
|
||||
let (ok, stderr) = process::stream(cmd, cancel, &mut |_, line| {
|
||||
if !line.trim().is_empty() {
|
||||
on_output(line);
|
||||
}
|
||||
@@ -282,13 +313,57 @@ fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(S
|
||||
if ok { Ok(()) } else { Err(failure(args, &stderr.join("\n"), "")) }
|
||||
}
|
||||
|
||||
/// `docker pull <reference>`.
|
||||
pub fn pull(reference: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
/// `docker pull <reference>`; with `auth` it logs in to the registry first, in a
|
||||
/// throwaway config directory (the user's docker config stays untouched).
|
||||
pub fn pull(
|
||||
ep: &DockerEndpoint,
|
||||
reference: &str,
|
||||
auth: Option<&RegistryAuth>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let reference = reference.trim();
|
||||
if reference.is_empty() || reference.starts_with('-') {
|
||||
return Err(IdeError::invalid("enter an image name, e.g. postgres:16"));
|
||||
}
|
||||
run_streaming(&["pull", reference], cancel, on_output)
|
||||
let Some(auth) = auth else { return run_streaming(ep, None, &["pull", reference], cancel, on_output) };
|
||||
let config = TempConfig::new()?;
|
||||
on_output(format!("Logging in to {} as {}", auth.address, auth.username));
|
||||
login_in(ep, &config.dir, auth, Some(cancel))?;
|
||||
run_streaming(ep, Some(&config.dir), &["pull", reference], cancel, on_output)
|
||||
}
|
||||
|
||||
/// `docker login` with `auth`; returns docker's answer (`Login Succeeded`). Nothing is
|
||||
/// stored in the user's docker config.
|
||||
pub fn login(ep: &DockerEndpoint, auth: &RegistryAuth) -> IdeResult<String> {
|
||||
let config = TempConfig::new()?;
|
||||
login_in(ep, &config.dir, auth, None)
|
||||
}
|
||||
|
||||
fn login_in(
|
||||
ep: &DockerEndpoint,
|
||||
config: &Path,
|
||||
auth: &RegistryAuth,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<String> {
|
||||
let host = registry_host(&auth.address);
|
||||
let user = auth.username.trim();
|
||||
if host.is_empty() || host.starts_with('-') || user.is_empty() || user.starts_with('-') {
|
||||
return Err(IdeError::invalid("the registry needs an address and a username"));
|
||||
}
|
||||
let mut args = vec!["login", "--username", user, "--password-stdin"];
|
||||
// Docker Hub is the CLI's default server.
|
||||
if host != "docker.io" {
|
||||
args.push(&host);
|
||||
}
|
||||
let mut cmd = command(ep, &args)?;
|
||||
cmd.env("DOCKER_CONFIG", config);
|
||||
let out = process::run_with_input(cmd, QUICK, cancel, Some(&auth.password))?;
|
||||
if out.success {
|
||||
Ok(out.stdout.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("Login Succeeded").to_owned())
|
||||
} else {
|
||||
Err(failure(&args, &out.stderr, &out.stdout))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -306,7 +381,12 @@ pub enum PruneTarget {
|
||||
}
|
||||
|
||||
/// `docker <object> prune --force`.
|
||||
pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
pub fn prune(
|
||||
ep: &DockerEndpoint,
|
||||
target: PruneTarget,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let args: &[&str] = match target {
|
||||
PruneTarget::Containers => &["container", "prune", "--force"],
|
||||
PruneTarget::Images => &["image", "prune", "--force"],
|
||||
@@ -315,7 +395,7 @@ pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn
|
||||
PruneTarget::Networks => &["network", "prune", "--force"],
|
||||
PruneTarget::System => &["system", "prune", "--force"],
|
||||
};
|
||||
run_streaming(args, cancel, on_output)
|
||||
run_streaming(ep, None, args, cancel, on_output)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -332,6 +412,7 @@ pub enum ComposeOp {
|
||||
/// labels. `files` / `working_dir` (from the labels too) are passed when they still
|
||||
/// exist, so commands that need the model keep working.
|
||||
pub fn compose_op(
|
||||
ep: &DockerEndpoint,
|
||||
project: &str,
|
||||
files: &[String],
|
||||
working_dir: Option<&str>,
|
||||
@@ -357,5 +438,5 @@ pub fn compose_op(
|
||||
ComposeOp::Restart => "restart",
|
||||
ComposeOp::Down => "down",
|
||||
});
|
||||
run_streaming(&args, cancel, on_output)
|
||||
run_streaming(ep, None, &args, cancel, on_output)
|
||||
}
|
||||
@@ -105,6 +105,9 @@ pub struct DockerSnapshot {
|
||||
pub networks: Vec<DockerNetwork>,
|
||||
/// Lists that failed although the daemon answered (shown, not fatal).
|
||||
pub errors: Vec<String>,
|
||||
/// Global CLI options selecting the connection's daemon (for `docker exec` in a terminal).
|
||||
#[serde(default)]
|
||||
pub global_args: Vec<String>,
|
||||
}
|
||||
|
||||
/// One relevant `docker events` entry.
|
||||
|
||||
@@ -25,11 +25,7 @@ fn prepare(cmd: &mut Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
@@ -64,9 +60,26 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, timeout, cancel, None)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (e.g. `--password-stdin`).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
input: Option<&str>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
// Dropping `stdin` afterwards closes it, so the CLI sees EOF.
|
||||
let _ = std::io::Write::write_all(&mut stdin, text.as_bytes());
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
@@ -25,11 +25,7 @@ fn prepare(cmd: &mut Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "ide-host"
|
||||
description = "The IDE backend without a UI toolkit: projects per window, terminals, processes, and the JSON-RPC server for remote development"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-index.workspace = true
|
||||
ide-syntax.workspace = true
|
||||
ide-search.workspace = true
|
||||
ide-lsp.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-dap.workspace = true
|
||||
ide-build.workspace = true
|
||||
ide-analysis.workspace = true
|
||||
ide-coverage.workspace = true
|
||||
ide-spell.workspace = true
|
||||
ide-terminal.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-sdk.workspace = true
|
||||
ide-http.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `openidea-server --stdio`: the IDE backend for remote development, serving
|
||||
//! one desktop client over stdin / stdout (started by the client over SSH).
|
||||
|
||||
use ide_host::rpc::{self, Dispatcher};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_language::LanguageRegistry;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version") {
|
||||
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
|
||||
return;
|
||||
}
|
||||
if !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_env("OPENIDEA_LOG").unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea"));
|
||||
// An SSH exec channel gets a minimal PATH: take the login shell's, as the
|
||||
// desktop app does, plus the IDE-managed tools. Before any other thread starts.
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server");
|
||||
let data_dir = base.join("data");
|
||||
ide_tools::extend_process_path(&data_dir);
|
||||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
|
||||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
|
||||
if let Some(refresh) = refresh_shell_path {
|
||||
refresh();
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
|
||||
let code = runtime.block_on(run(base));
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
async fn run(base: PathBuf) -> i32 {
|
||||
let (config_dir, data_dir) = (base.join("config"), base.join("data"));
|
||||
for dir in [&config_dir, &data_dir] {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) {
|
||||
Ok(l) => Arc::new(l),
|
||||
Err(e) => {
|
||||
eprintln!("language definitions: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
}));
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
// `process::exit` skips destructors: nothing started for the client may outlive it.
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! The IDE backend without a UI toolkit: open projects per window, terminals,
|
||||
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod ops;
|
||||
mod project;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId, SharedSink};
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Event sink for one window (by label).
|
||||
pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
pub struct HostConfig {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
/// Grammars, possibly still being built on another thread.
|
||||
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub sink_for: SinkFactory,
|
||||
/// The window that owns what `window` starts (e.g. a settings window's parent).
|
||||
pub owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
/// Debug sessions (adapters run on this machine, next to the project).
|
||||
pub debugger: Arc<ide_dap::DebugManager>,
|
||||
/// SSH connections of Settings › SSH Configurations.
|
||||
pub ssh: Arc<ide_ssh::SshManager>,
|
||||
/// SFTP deployment, remote file editing and runs on run targets.
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: Mutex<HashMap<RequestId, CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config;
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
Self {
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
global_settings,
|
||||
// Sessions, runs and terminals report to the window that started them (`window_sink`).
|
||||
terminals: TerminalManager::new(),
|
||||
processes: ProcessManager::new(),
|
||||
debugger: ide_dap::DebugManager::new(languages.clone()),
|
||||
languages,
|
||||
syntax,
|
||||
config_dir,
|
||||
data_dir,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
|
||||
pub fn syntax(&self) -> Arc<Syntax> {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// The project of `window` (or of the window owning it).
|
||||
pub fn project(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
self.projects
|
||||
.read()
|
||||
.get((self.owner)(window))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
}
|
||||
|
||||
/// Event sink for things `window` starts (debug sessions, runs, terminals): its
|
||||
/// project's window-scoped sink, or a sink for that window when no project is open.
|
||||
pub fn window_sink(&self, window: &str) -> SharedSink {
|
||||
match self.project(window) {
|
||||
Ok(p) => p.sink.clone(),
|
||||
Err(_) => (self.sink_for)((self.owner)(window)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
project.agents.shutdown();
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
self.processes.kill_all();
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in projects {
|
||||
project.agents.shutdown();
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ----
|
||||
|
||||
pub fn begin_request(&self, id: RequestId) -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
if let Some(old) = self.requests.lock().insert(id, token.clone()) {
|
||||
old.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
self.requests.lock().remove(&id);
|
||||
}
|
||||
|
||||
pub fn cancel_request(&self, id: RequestId) {
|
||||
if let Some(token) = self.requests.lock().remove(&id) {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Agents tool window: coding agent tasks of the project (over ACP). In a remote
|
||||
//! window they run on the host, next to the project they work on.
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
Ok(host.project(window)?.agents.subscribe(on_event))
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
Ok(host.project(window)?.agents.providers())
|
||||
}
|
||||
|
||||
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
|
||||
/// before a task exists (skills and custom commands on disk included).
|
||||
pub async fn commands(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
provider: String,
|
||||
task: Option<String>,
|
||||
) -> IdeResult<Vec<AgentCommand>> {
|
||||
host.project(window)?.agents.commands(&provider, task.as_deref()).await
|
||||
}
|
||||
|
||||
/// Entries of `task`'s conversation, or of its subagent `subagent`'s.
|
||||
pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<Vec<AgentEntry>> {
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.project(window)?.agents.create(request).await
|
||||
}
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
/// Drops a queued message and returns it (to edit it).
|
||||
pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeResult<AgentQueued> {
|
||||
host.project(window)?.agents.unqueue(&task, &queued)
|
||||
}
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Stops the task's current turn, or with `subagent` only that subagent's work.
|
||||
pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<()> {
|
||||
host.project(window)?.agents.cancel(&task, subagent)
|
||||
}
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.archive(&task)
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
});
|
||||
d.add("agent_unqueue", |c| async move { unqueue(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) });
|
||||
d.add(
|
||||
"agent_send_queued_now",
|
||||
|c| async move { send_queued_now(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) },
|
||||
);
|
||||
d.add("agent_set_config", |c| async move {
|
||||
set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_respond", |c| async move {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add(
|
||||
"agent_delete",
|
||||
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics),
|
||||
//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`).
|
||||
//!
|
||||
//! Scopes are Find in Files scopes; excluded roots are never analysed. Every
|
||||
//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_analysis::{
|
||||
DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary,
|
||||
};
|
||||
use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId};
|
||||
use ide_language::LanguageRegistry;
|
||||
use ide_lsp::{FileInspection, InspectHost, InspectOptions};
|
||||
use ide_search::{FileMask, SearchScope};
|
||||
use ide_workspace::Documents;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Files of `scope`, minus excluded roots and ignored names.
|
||||
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
|
||||
let mask = FileMask::new(&[], &project.search_excludes())?;
|
||||
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
||||
.into_iter()
|
||||
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Current text of a file (the unsaved buffer when open) and its language.
|
||||
fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectedFile {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectSummary {
|
||||
/// Files inspected by a language server.
|
||||
pub files: u32,
|
||||
pub problems: u32,
|
||||
/// Files in scope no language server serves.
|
||||
pub skipped: u32,
|
||||
/// Files whose server failed or never became ready.
|
||||
pub failed: u32,
|
||||
pub cancelled: bool,
|
||||
pub elapsed_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InspectEvent {
|
||||
/// Files about to be inspected.
|
||||
Started {
|
||||
total: u32,
|
||||
},
|
||||
/// A file finished (with or without problems).
|
||||
File {
|
||||
file: InspectedFile,
|
||||
done: u32,
|
||||
},
|
||||
Done {
|
||||
summary: InspectSummary,
|
||||
},
|
||||
}
|
||||
|
||||
struct DocumentsHost(Arc<Documents>);
|
||||
|
||||
impl InspectHost for DocumentsHost {
|
||||
fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> {
|
||||
let was_open = self.0.by_path(path).is_some();
|
||||
Ok((self.0.open(path)?.id, was_open))
|
||||
}
|
||||
|
||||
fn close(&self, doc: DocId) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Locate Duplicates ----
|
||||
|
||||
// ---- Structural Search ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StructuralEvent {
|
||||
File { file: StructuralFile },
|
||||
Done { summary: StructuralSummary },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StructuralLanguage {
|
||||
pub id: LanguageId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Inspect Code: diagnostics of every file in `scope` that a language server
|
||||
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
|
||||
pub async fn inspect_code(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
on_event: Channel<InspectEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let started = Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
};
|
||||
let files = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
let mut paths: Vec<_> = rel.keys().cloned().collect();
|
||||
paths.sort();
|
||||
let tally = Mutex::new(summary.clone());
|
||||
let done = std::sync::atomic::AtomicU32::new(0);
|
||||
let docs = Arc::new(DocumentsHost(project.documents.clone()));
|
||||
project
|
||||
.lsp
|
||||
.inspect_files(docs, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| {
|
||||
let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let diagnostics = {
|
||||
let mut t = tally.lock();
|
||||
match outcome {
|
||||
FileInspection::Inspected(items) => {
|
||||
t.files += 1;
|
||||
t.problems += items.len() as u32;
|
||||
items
|
||||
}
|
||||
FileInspection::NoServer => {
|
||||
t.skipped += 1;
|
||||
Vec::new()
|
||||
}
|
||||
FileInspection::Failed(message) => {
|
||||
tracing::debug!("inspect {}: {message}", path.display());
|
||||
t.failed += 1;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
let rel_path = rel.get(&path).cloned().unwrap_or_default();
|
||||
let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics };
|
||||
let _ = on_event.send(InspectEvent::File { file, done });
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
summary = tally.into_inner();
|
||||
summary.cancelled = cancel.is_cancelled();
|
||||
summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
let _ = on_event.send(InspectEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate Duplicates over the files of `scope`.
|
||||
pub async fn analysis_locate_duplicates(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
options: DuplicateOptions,
|
||||
) -> IdeResult<DuplicatesReport> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Languages Search Structurally supports (those with a bundled grammar).
|
||||
pub async fn structural_languages(host: &Host, _window: &str) -> IdeResult<Vec<StructuralLanguage>> {
|
||||
let syntax = host.syntax();
|
||||
let mut out: Vec<StructuralLanguage> = host
|
||||
.languages
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| syntax.supports(&c.id))
|
||||
.map(|c| StructuralLanguage { id: c.id, name: c.name })
|
||||
.collect();
|
||||
out.sort_by_key(|l| l.name.to_lowercase());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a structural search template (and language) while the user types.
|
||||
pub async fn structural_validate(host: &Host, _window: &str, query: StructuralQuery) -> IdeResult<()> {
|
||||
let syntax = host.syntax();
|
||||
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
|
||||
}
|
||||
|
||||
/// Search Structurally over `scope`; files with matches stream through
|
||||
/// `on_event`, ending with `Done`. With a replacement, matches carry their
|
||||
/// replacement text (applied by the UI as a workspace edit).
|
||||
pub async fn structural_search(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
query: StructuralQuery,
|
||||
on_event: Channel<StructuralEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| {
|
||||
let _ = events.send(StructuralEvent::File { file });
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
let _ = on_event.send(StructuralEvent::Done { summary: result? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("inspect_code", |c| async move {
|
||||
inspect_code(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("analysis_locate_duplicates", |c| async move {
|
||||
analysis_locate_duplicates(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("structural_languages", |c| async move { structural_languages(&c.host, &c.window).await });
|
||||
d.add("structural_validate", |c| async move { structural_validate(&c.host, &c.window, c.arg("query")?).await });
|
||||
d.add("structural_search", |c| async move {
|
||||
structural_search(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("query")?, c.sender("onEvent")?)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Maven / Gradle tool windows (the build model and its background sync) and
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
|
||||
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
|
||||
use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
|
||||
/// when the project names no version).
|
||||
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
|
||||
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
|
||||
/// the Maven home and options from Settings › Maven.
|
||||
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
let mvn = ide_sdk::apply::maven_command(root, settings);
|
||||
let options = ide_sdk::apply::maven_options(root, settings);
|
||||
for p in &mut m.projects {
|
||||
p.env = java_env(jdk.as_ref(), p.java_version);
|
||||
if let Some(mvn) = &mvn {
|
||||
p.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
}
|
||||
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
|
||||
let missing = |release: Option<u32>| match jdk {
|
||||
Some(_) => None,
|
||||
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
|
||||
};
|
||||
let explain = |error: &mut Option<String>, hint: Option<String>| {
|
||||
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
|
||||
*e = format!("{hint}\n{e}");
|
||||
}
|
||||
};
|
||||
if let Some(m) = &mut model.maven
|
||||
&& m.sync_error.is_some()
|
||||
{
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = missing(g.java_version);
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
tool: BuildTool,
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
project: &Project,
|
||||
pom: &str,
|
||||
dependency: &ArtifactKey,
|
||||
) -> IdeResult<(std::path::PathBuf, String, Range)> {
|
||||
let model = ide_build::maven::load(&project.root);
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
|
||||
let path = project.root.join(&rel);
|
||||
let text = project.text_of(&path)?;
|
||||
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
|
||||
return Ok((path, text, range));
|
||||
}
|
||||
}
|
||||
Err(IdeError::not_found(format!(
|
||||
"{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
|
||||
pub async fn maven_dependency_declaration(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
pom: String,
|
||||
dependency: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
|
||||
/// `dependency` of the Maven module `pom`, through the open document when there is one
|
||||
/// (saved). Returns the declaration; nothing changes when it already excludes it.
|
||||
pub async fn maven_exclude_dependency(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
pom: String,
|
||||
dependency: ArtifactKey,
|
||||
exclusion: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit =
|
||||
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
|
||||
apply_workspace_edit(&project.documents, &edit)?;
|
||||
}
|
||||
Some(Exclusion::Present) => {}
|
||||
None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))),
|
||||
}
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
|
||||
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
|
||||
/// are cleared first; the new messages are published as `build` diagnostics.
|
||||
pub async fn build_run(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
kind: BuildKind,
|
||||
file: Option<std::path::PathBuf>,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("tool")?,
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("maven_dependency_declaration", |c| async move {
|
||||
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
|
||||
});
|
||||
d.add("maven_exclude_dependency", |c| async move {
|
||||
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
|
||||
});
|
||||
d.add("build_run", |c| async move {
|
||||
build_run(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("kind")?,
|
||||
c.arg("file")?,
|
||||
c.arg("request")?,
|
||||
c.arg("clear")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use crate::ops::doc::blocking;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::{ByteRange, FileProbe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Size and text/binary classification, from the file's head only.
|
||||
pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<FileProbe> {
|
||||
blocking(move || ide_vfs::probe(&path)).await
|
||||
}
|
||||
|
||||
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
|
||||
pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
blocking(move || ide_vfs::read_range(&path, offset, len)).await
|
||||
}
|
||||
|
||||
/// An image file as a `data:` URL.
|
||||
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
blocking(move || ide_vfs::read_data_url(&path)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("fs_read_range", |c| async move {
|
||||
fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await
|
||||
});
|
||||
d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Run with Coverage: launch plans and coverage reports.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Output directories kept per kind (older runs' data is deleted).
|
||||
pub const KEEP_RUNS: usize = 10;
|
||||
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
|
||||
Ok(ide_coverage::prepare(&root, &config, &out))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// with paths mapped into this project.
|
||||
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
blocking(move || ide_coverage::import(&path, &root)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await });
|
||||
d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
use ide_dap::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let settings = match host.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.debugger.start_with(config, owner, custom).await
|
||||
}
|
||||
|
||||
pub async fn debug_set_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
path: PathBuf,
|
||||
breakpoints: Vec<Breakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_breakpoints(path, breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
host.debugger.threads(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_stack_trace(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<Vec<StackFrame>> {
|
||||
host.debugger.stack_trace(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
|
||||
host.debugger.scopes(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_variables(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
reference: i64,
|
||||
) -> IdeResult<Vec<Variable>> {
|
||||
host.debugger.variables(session, reference).await
|
||||
}
|
||||
|
||||
pub async fn debug_evaluate(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate(session, frame_id, expression).await
|
||||
}
|
||||
|
||||
pub async fn debug_resume(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.resume(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
kind: StepKind,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step(session, thread_id, kind).await
|
||||
}
|
||||
|
||||
pub async fn debug_run_to(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
path: PathBuf,
|
||||
line: u32,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.run_to(session, thread_id, path, line, force).await
|
||||
}
|
||||
|
||||
pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.stop(session).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
pub async fn debug_evaluate_in(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
context: Option<String>,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate_in(session, frame_id, expression, context).await
|
||||
}
|
||||
|
||||
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
|
||||
pub fn debug_breakpoint_states(host: &Host, _window: &str, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
|
||||
Ok(host.debugger.breakpoint_states(&path))
|
||||
}
|
||||
|
||||
/// This window's live sessions, including adapter-started child sessions.
|
||||
pub fn debug_sessions(host: &Host, window: &str) -> IdeResult<Vec<SessionInfo>> {
|
||||
Ok(host.debugger.sessions(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult<Vec<SessionExceptionFilters>> {
|
||||
Ok(host.debugger.exception_filters(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub async fn debug_set_exception_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
choices: std::collections::BTreeMap<String, bool>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.set_exception_breakpoints(choices).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn debug_set_variable(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
evaluate_name: Option<String>,
|
||||
frame_id: Option<i64>,
|
||||
value: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
|
||||
}
|
||||
|
||||
/// Force Step Over: step over with every breakpoint suspended until the step ends.
|
||||
pub async fn debug_force_step_over(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.force_step_over(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Smart Step Into: the calls on the current line of `frame_id`.
|
||||
pub async fn debug_step_in_targets(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: i64,
|
||||
) -> IdeResult<Vec<StepInTarget>> {
|
||||
host.debugger.step_in_targets(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step_in_target(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
target_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step_in_target(session, thread_id, target_id).await
|
||||
}
|
||||
|
||||
/// Reset Frame (Drop Frame).
|
||||
pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
|
||||
host.debugger.restart_frame(session, frame_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
|
||||
/// Method breakpoints (all files); states aligned with `breakpoints`.
|
||||
pub async fn debug_set_function_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<FunctionBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_function_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
/// Field watchpoints; states aligned with `breakpoints`.
|
||||
pub async fn debug_set_data_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<DataBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_data_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_data_breakpoint_info(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
frame_id: Option<i64>,
|
||||
) -> IdeResult<DataBreakpointInfo> {
|
||||
host.debugger.data_breakpoint_info(session, container, name, frame_id).await
|
||||
}
|
||||
|
||||
/// Attach to Process: the user's processes a debugger can attach to.
|
||||
pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResult<Vec<attach::AttachableProcess>> {
|
||||
tokio::task::spawn_blocking(attach::attachable_processes)
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// HotSwap (Reload Changed Classes) in a java-debug session.
|
||||
pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<HotSwapResult> {
|
||||
host.debugger.redefine_classes(session).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_threads", |c| async move { debug_threads(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_stack_trace", |c| async move {
|
||||
debug_stack_trace(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_scopes",
|
||||
|c| async move { debug_scopes(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await },
|
||||
);
|
||||
d.add("debug_variables", |c| async move {
|
||||
debug_variables(&c.host, &c.window, c.arg("session")?, c.arg("reference")?).await
|
||||
});
|
||||
d.add("debug_evaluate", |c| async move {
|
||||
debug_evaluate(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?, c.arg("expression")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_resume",
|
||||
|c| async move { debug_resume(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
d.add("debug_run_to", |c| async move {
|
||||
debug_run_to(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("threadId")?,
|
||||
c.arg("path")?,
|
||||
c.arg("line")?,
|
||||
c.arg("force")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_evaluate_in", |c| async move {
|
||||
debug_evaluate_in(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("expression")?,
|
||||
c.arg("context")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_breakpoint_states", |c| async move { debug_breakpoint_states(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("debug_sessions", |c| async move { debug_sessions(&c.host, &c.window) });
|
||||
d.add("debug_exception_filters", |c| async move { debug_exception_filters(&c.host, &c.window) });
|
||||
d.add("debug_set_exception_breakpoints", |c| async move {
|
||||
debug_set_exception_breakpoints(&c.host, &c.window, c.arg("choices")?).await
|
||||
});
|
||||
d.add("debug_set_variable", |c| async move {
|
||||
debug_set_variable(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("evaluateName")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("value")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_force_step_over", |c| async move {
|
||||
debug_force_step_over(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add("debug_step_in_targets", |c| async move {
|
||||
debug_step_in_targets(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_step_in_target", |c| async move {
|
||||
debug_step_in_target(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("targetId")?).await
|
||||
});
|
||||
d.add("debug_restart_frame", |c| async move {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_set_data_breakpoints", |c| async move {
|
||||
debug_set_data_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_data_breakpoint_info", |c| async move {
|
||||
debug_data_breakpoint_info(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("frameId")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_redefine_classes",
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::Host;
|
||||
use ide_core::{ChangeSet, DocId, IdeResult};
|
||||
use ide_workspace::DocumentInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.open(&path)?;
|
||||
project.index.mark_recent(&path);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
host.project(window)?.documents.close(doc);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save(doc)).await
|
||||
}
|
||||
|
||||
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save_all()).await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,125 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
|
||||
use ide_language::{FoldRange, SyntaxHighlights};
|
||||
use ide_text::LineEnding;
|
||||
use ide_workspace::{DocumentInfo, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
|
||||
/// keeping it off the main thread and the async runtime's workers.
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
host.project(window)?.documents.set_line_ending(doc, line_ending)
|
||||
}
|
||||
|
||||
/// Structure view: LSP document symbols when available (with tree-sitter
|
||||
/// visibility), tree-sitter otherwise.
|
||||
/// Tree-sitter answers come from the per-document tree cache (incremental
|
||||
/// re-parse); large documents get no syntax structure.
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
Err(e) => return lsp.ok_or(e),
|
||||
};
|
||||
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
|
||||
Ok(match lsp {
|
||||
// Server symbols carry no visibility; borrow it from the syntax tree.
|
||||
Some(mut items) => {
|
||||
if !snap.large {
|
||||
ide_language::merge_visibility(&mut items, &syntax());
|
||||
}
|
||||
items
|
||||
}
|
||||
None if snap.large => Vec::new(),
|
||||
None => syntax(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FoldRange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
|
||||
/// empty when the grammar has no highlights query or the document is large.
|
||||
pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<SyntaxHighlights> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(SyntaxHighlights::default());
|
||||
}
|
||||
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
|
||||
host.project(window)?.history.list(&path)
|
||||
}
|
||||
|
||||
pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
host.project(window)?.history.content(&id)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
});
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let excludes = host
|
||||
.project(window)
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
}
|
||||
|
||||
/// Creates an empty file; inside a project, honours the EditorConfig
|
||||
/// `charset` (writes the BOM for `utf-8-bom` / UTF-16).
|
||||
pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
match host.project(window) {
|
||||
Ok(p) => {
|
||||
let defaults = p.documents.file_defaults();
|
||||
if defaults.editorconfig {
|
||||
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
|
||||
} else {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
|
||||
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
|
||||
let bom = defaults.new_file_bytes();
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.documents.set_writable(&path, writable),
|
||||
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || match project {
|
||||
Some(p) => p.text_of(&path),
|
||||
None => Ok(ide_vfs::load_text(&path)?.text),
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Local History beyond single files: folders, labels, restoring deleted files.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use ide_workspace::DirectoryHistory;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
|
||||
pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<DirectoryHistory> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.history.list_dir(&path)).await
|
||||
}
|
||||
|
||||
/// Local History › Put Label…: a named point in the history of every file.
|
||||
pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
project.history.put_label(&project.root, &label)
|
||||
}
|
||||
|
||||
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
|
||||
/// file and its folders); an open editor updates in place.
|
||||
pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.history.content(&id)?;
|
||||
if let Some(doc) = project.documents.by_path(&path) {
|
||||
project.documents.replace_all(doc, &text)?;
|
||||
return project.documents.save(doc);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if !path.exists() {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
let doc = project.documents.open(&path)?.id;
|
||||
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
|
||||
project.documents.close(doc);
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) });
|
||||
d.add(
|
||||
"history_restore",
|
||||
|c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Builds the workspace (when `build`), then resolves the runtime (or test)
|
||||
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
|
||||
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
|
||||
pub async fn java_resolve_launch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
|
||||
pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
|
||||
pub async fn lsp_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
|
||||
pub async fn lsp_inline_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_resolve_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
item: CompletionItem,
|
||||
) -> IdeResult<CompletionItem> {
|
||||
host.project(window)?.lsp.resolve_completion(doc, item).await
|
||||
}
|
||||
|
||||
pub async fn lsp_hover(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
host.project(window)?.lsp.goto(doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at `positions`, one list per position (the
|
||||
/// editor's "is implemented / overridden" gutter markers). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
pub async fn lsp_implementations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
positions: Vec<Pos>,
|
||||
) -> IdeResult<Vec<Vec<Location>>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Cancel with `cancel_request(request)`.
|
||||
pub async fn lsp_references(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
) -> IdeResult<References> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: String,
|
||||
) -> IdeResult<Vec<WorkspaceSymbol>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.workspace_symbols(&query, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_code_actions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
host.project(window)?.lsp.code_actions(doc, range, only).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
pub async fn lsp_resolve_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
action: CodeAction,
|
||||
) -> IdeResult<CodeAction> {
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
host.project(window)?.lsp.resolve_code_action(doc, action).await
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
project.lsp.resolve_code_action(doc, action).await?
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(edit) = &action.edit {
|
||||
apply_edit(&project.documents, edit)?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
host.project(window)?.lsp.semantic_tokens(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
|
||||
pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.resend_diagnostics();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
d.add("lsp_inline_completion", |c| async move {
|
||||
lsp_inline_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("automatic")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_resolve_completion", |c| async move {
|
||||
lsp_resolve_completion(&c.host, &c.window, c.arg("doc")?, c.arg("item")?).await
|
||||
});
|
||||
d.add("lsp_hover", |c| async move {
|
||||
lsp_hover(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_signature_help", |c| async move {
|
||||
lsp_signature_help(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_goto",
|
||||
|c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await },
|
||||
);
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("lsp_references", |c| async move {
|
||||
lsp_references(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("pos")?,
|
||||
c.arg("includeDeclaration")?,
|
||||
c.arg("projectWide")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_document_highlights", |c| async move {
|
||||
lsp_document_highlights(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_workspace_symbols", |c| async move {
|
||||
lsp_workspace_symbols(&c.host, &c.window, c.arg("request")?, c.arg("query")?).await
|
||||
});
|
||||
d.add("lsp_code_actions", |c| async move {
|
||||
lsp_code_actions(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("only")?).await
|
||||
});
|
||||
d.add("lsp_code_action_query", |c| async move {
|
||||
lsp_code_action_query(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("range")?, c.arg("only")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_resolve_code_action", |c| async move {
|
||||
lsp_resolve_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
|
||||
});
|
||||
d.add("lsp_apply_code_action", |c| async move {
|
||||
lsp_apply_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
|
||||
});
|
||||
d.add("lsp_prepare_rename", |c| async move {
|
||||
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_inlay_hints",
|
||||
|c| async move { lsp_inlay_hints(&c.host, &c.window, c.arg("doc")?, c.arg("range")?).await },
|
||||
);
|
||||
d.add("lsp_semantic_tokens", |c| async move { lsp_semantic_tokens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_call_hierarchy", |c| async move {
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Command implementations shared by the desktop shell's IPC commands and the
|
||||
//! remote server ([`crate::rpc`]): each takes the [`Host`], the calling window
|
||||
//! and the command's arguments, and streams through [`Emit`] callbacks.
|
||||
|
||||
pub mod agent;
|
||||
pub mod analysis;
|
||||
pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod doc;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod project;
|
||||
pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
pub mod vcs;
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stream of values to the caller (a Tauri channel, or channel notifications).
|
||||
pub type Emit<T> = Arc<dyn Fn(T) + Send + Sync>;
|
||||
|
||||
/// A streaming argument: what the desktop shell passes as a Tauri channel and
|
||||
/// the server as `channel` notifications.
|
||||
pub struct Channel<T>(Emit<T>);
|
||||
|
||||
impl<T> Channel<T> {
|
||||
pub fn new(send: impl Fn(T) + Send + Sync + 'static) -> Self {
|
||||
Self(Arc::new(send))
|
||||
}
|
||||
|
||||
/// Sends one value; never fails (a closed receiver drops it).
|
||||
pub fn send(&self, value: T) -> IdeResult<()> {
|
||||
(self.0)(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Channel<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the commands of every module with the remote server.
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
agent::register(d);
|
||||
lsp::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
syntax::register(d);
|
||||
spell::register(d);
|
||||
history::register(d);
|
||||
coverage::register(d);
|
||||
build::register(d);
|
||||
tools::register(d);
|
||||
sdk::register(d);
|
||||
java::register(d);
|
||||
debug::register(d);
|
||||
bytes::register(d);
|
||||
vcs::register(d);
|
||||
doc::register(d);
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool.
|
||||
pub async fn blocking<T: Send + 'static>(&self, f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
/// `--x:` declarations.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebGoto {
|
||||
pub reference: WebReference,
|
||||
pub targets: Vec<Location>,
|
||||
}
|
||||
|
||||
const MAX_WEB_TARGETS: usize = 200;
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
(project.root.clone(), resources)
|
||||
}
|
||||
|
||||
pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
|
||||
Ok(host.project(window)?.index.status())
|
||||
}
|
||||
|
||||
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub fn find_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: String,
|
||||
filter: SymbolFilter,
|
||||
limit: u32,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize))
|
||||
}
|
||||
|
||||
pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.mark_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.remove_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recently opened files, most recent first (⌘E).
|
||||
pub fn recent_files(host: &Host, window: &str) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
|
||||
pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
|
||||
let here = |range| Location { path: snap.path.clone(), range, uri: None };
|
||||
let targets = match reference.symbol() {
|
||||
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
|
||||
Some((name, kind)) => {
|
||||
// The open document from its (possibly unsaved) text, other files from the index.
|
||||
let mut targets: Vec<Location> = syntax
|
||||
.web_definitions(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|t| t.name == name && t.kind == kind)
|
||||
.map(|t| here(t.range))
|
||||
.collect();
|
||||
targets.extend(
|
||||
project
|
||||
.index
|
||||
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
|
||||
.into_iter()
|
||||
.filter(|m| *m.path != *snap.path)
|
||||
.map(|m| Location { path: m.path, range: m.range, uri: None }),
|
||||
);
|
||||
targets
|
||||
}
|
||||
};
|
||||
Ok(Some(WebGoto { reference, targets }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
value: String,
|
||||
) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files and folders that continue `prefix`, a path being typed in `from`.
|
||||
pub async fn path_completions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
prefix: String,
|
||||
) -> IdeResult<Vec<PathCompletion>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Paths in an HTML document or stylesheet that name no existing file.
|
||||
pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FileReference>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (root, resources) = reference_roots(&project);
|
||||
Ok(syntax
|
||||
.web_file_references(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
file_refs::is_file_reference(&r.value)
|
||||
&& !file_refs::path_part(&r.value).is_empty()
|
||||
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
});
|
||||
d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("path_completions", |c| async move {
|
||||
path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await
|
||||
});
|
||||
d.add("web_unresolved_references", |c| async move {
|
||||
web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use crate::{Host, Project};
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `path` in `window` (off the async workers: it touches the disk).
|
||||
pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<Project>> {
|
||||
let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf());
|
||||
host.blocking(move || h.open_project(&window, &path)).await
|
||||
}
|
||||
|
||||
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
|
||||
host.project(window).ok().map(|p| info(&p))
|
||||
}
|
||||
|
||||
/// Effective settings for the window's project, or global settings without one.
|
||||
pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
pub struct SettingsApplied {
|
||||
pub settings: Settings,
|
||||
/// A project value (else a global one).
|
||||
pub project_scope: bool,
|
||||
/// The project's root, when its content roots changed.
|
||||
pub roots_changed: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
|
||||
/// project or globally, and applies it to the open projects: file defaults,
|
||||
/// file types, language servers, index scope and SSH configurations.
|
||||
pub fn set_settings(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
scope: SettingsScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> IdeResult<SettingsApplied> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let current = host.project(window).ok();
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
host.global_settings.set(SettingsScope::Global, key, value)?;
|
||||
p.settings.reload()
|
||||
}
|
||||
None => host.global_settings.set(SettingsScope::Global, key, value)?,
|
||||
};
|
||||
if let Some(p) = ¤t {
|
||||
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
|
||||
}
|
||||
// A global value reaches every other open project too.
|
||||
if !project_scope {
|
||||
for project in host.projects() {
|
||||
if current.as_ref().is_some_and(|c| c.root == project.root) {
|
||||
continue;
|
||||
}
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
.documents
|
||||
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.apply_file_types(&project.settings.effective());
|
||||
}
|
||||
});
|
||||
}
|
||||
if key == "ssh" || key.starts_with("ssh.") {
|
||||
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
for project in affected() {
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.refresh_index_scope();
|
||||
}
|
||||
});
|
||||
}
|
||||
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
|
||||
Ok(SettingsApplied { settings, project_scope, roots_changed })
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! File move / rename refactoring: reference updates from the language
|
||||
//! servers, the move itself (one undoable step) and its undo.
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
|
||||
use ide_lsp::types::FileRenameEdits;
|
||||
use ide_workspace::{FileMove, UndoOutcome};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> {
|
||||
moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect()
|
||||
}
|
||||
|
||||
/// `workspace/willRenameFiles` on every interested server (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<FileRenameEdits> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
|
||||
/// files and notifies the servers. Returns the id for [`refactor_undo_file_move`].
|
||||
pub async fn refactor_move_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
moves: Vec<FileMove>,
|
||||
edit: WorkspaceEdit,
|
||||
label: String,
|
||||
) -> IdeResult<u32> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let moved = moves.clone();
|
||||
let id = blocking(move || {
|
||||
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
|
||||
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
|
||||
})
|
||||
.await?;
|
||||
project.lsp.did_rename_files(&pairs(&moves));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Undoes a file move: moves back and reverts the reference updates.
|
||||
pub async fn refactor_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<UndoOutcome> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?;
|
||||
project.lsp.did_rename_files(&pairs(&outcome.moves));
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Whether the file move `id` can still be undone.
|
||||
pub fn refactor_can_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<bool> {
|
||||
Ok(host.project(window)?.moves.can_undo(id))
|
||||
}
|
||||
|
||||
/// "Search in comments and strings": textual occurrences of the moved files'
|
||||
/// names, qualified names and paths (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_text_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let cancel = token.clone();
|
||||
let result = blocking(move || {
|
||||
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
|
||||
let edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
&project.root,
|
||||
&project.exclude_globs(),
|
||||
&replacements,
|
||||
&|| cancel.is_cancelled(),
|
||||
);
|
||||
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("refactor_file_move_edits", |c| async move {
|
||||
refactor_file_move_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
d.add("refactor_move_files", |c| async move {
|
||||
refactor_move_files(&c.host, &c.window, c.arg("moves")?, c.arg("edit")?, c.arg("label")?).await
|
||||
});
|
||||
d.add(
|
||||
"refactor_undo_file_move",
|
||||
|c| async move { refactor_undo_file_move(&c.host, &c.window, c.arg("id")?).await },
|
||||
);
|
||||
d.add(
|
||||
"refactor_can_undo_file_move",
|
||||
|c| async move { refactor_can_undo_file_move(&c.host, &c.window, c.arg("id")?) },
|
||||
);
|
||||
d.add("refactor_file_move_text_edits", |c| async move {
|
||||
refactor_file_move_text_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
ide_run::save_user_configs(&host.project(window)?.root, configs)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let settings = project.settings.effective();
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
let mut config = config;
|
||||
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let grace = Duration::from_millis(u64::from(settings.run.termination_timeout_ms));
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.stop(id, grace);
|
||||
}
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
host.processes.write_stdin(id, data)
|
||||
}
|
||||
|
||||
pub fn resize(host: &Host, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.resize(id, cols, rows);
|
||||
}
|
||||
host.processes.resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pub fn close_stdin(host: &Host, id: RunId) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.close_stdin(id);
|
||||
}
|
||||
host.processes.close_stdin(id)
|
||||
}
|
||||
|
||||
pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
let mut runs = host.processes.list();
|
||||
runs.extend(host.remote.runs.list());
|
||||
runs
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,48 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Configuration templates: per-type defaults applied to new configurations.
|
||||
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::load_templates(&root)
|
||||
}
|
||||
|
||||
pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>) -> IdeResult<()> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::save_templates(&root, &templates)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// 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(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: Option<std::path::PathBuf>,
|
||||
since_ms: f64,
|
||||
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
let dir = module.map(|m| root.join(m)).unwrap_or(root);
|
||||
let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64);
|
||||
tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages.
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use ide_sdk::{Sdk, SdkKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// Executables found for "run this file", and where single files are compiled to.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocatedTools {
|
||||
pub tools: Vec<ide_sdk::locate::LocatedTool>,
|
||||
pub compile_dir: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
|
||||
.into_iter()
|
||||
.map(|c| ide_sdk::libraries::BuildDependency {
|
||||
gradle: c.gradle,
|
||||
group: c.group_id,
|
||||
artifact: c.artifact_id,
|
||||
version: c.version,
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
upgrade: bool,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
if uninstall {
|
||||
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
|
||||
} else {
|
||||
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add("sdk_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
|
||||
});
|
||||
d.add("sdk_python_create_venv", |c| async move {
|
||||
sdk_python_create_venv(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("base")?,
|
||||
c.arg("location")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("sdk_python_packages", |c| async move {
|
||||
sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("sdk_python_pip", |c| async move {
|
||||
sdk_python_pip(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("python")?,
|
||||
c.arg("requirement")?,
|
||||
c.arg("uninstall")?,
|
||||
c.arg("upgrade")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum SearchEvent {
|
||||
File { file: FileMatches },
|
||||
Done { summary: SearchSummary },
|
||||
}
|
||||
|
||||
pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange {
|
||||
let kind = match l.kind {
|
||||
ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment,
|
||||
ide_language::LiteralKind::String => ide_search::LiteralKind::String,
|
||||
};
|
||||
ide_search::LiteralRange { start: l.start, end: l.end, kind }
|
||||
}
|
||||
|
||||
/// Find in Files. Results stream through `on_event`; ends with `Done`.
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
pub async fn start(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
mut query: SearchQuery,
|
||||
on_event: Emit<SearchEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| events(SearchEvent::File { file }),
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
on_event(SearchEvent::Done { summary: summary? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,133 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeResult, Pos, RequestId, TextEdit};
|
||||
use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery};
|
||||
use ide_workspace::Documents;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct DocumentsHost<'a>(&'a Documents);
|
||||
|
||||
impl ReplaceHost for DocumentsHost<'_> {
|
||||
fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult<Vec<TextEdit>>) -> IdeResult<u32> {
|
||||
let docs = self.0;
|
||||
let (doc, transient) = match docs.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (docs.open(path)?.id, true),
|
||||
};
|
||||
let result = (|| {
|
||||
// Recompute if the user typed between reading the text and applying.
|
||||
for _ in 0..3 {
|
||||
let snap = docs.text_snapshot(doc)?;
|
||||
let edits = compute(&snap.text)?;
|
||||
if edits.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if docs.text_snapshot(doc)?.version != snap.version {
|
||||
continue;
|
||||
}
|
||||
docs.apply_edits(doc, &edits)?;
|
||||
if transient {
|
||||
docs.save(doc)?;
|
||||
}
|
||||
return Ok(edits.len() as u32);
|
||||
}
|
||||
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
|
||||
})();
|
||||
if transient {
|
||||
docs.close(doc);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace in Files. Runs off the command thread; progress streams through
|
||||
/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`.
|
||||
///
|
||||
/// Edits go through the document layer as one patch per file, so open editors
|
||||
/// update live and undo the whole file's replacements in one step. Unsaved
|
||||
/// editor buffers are replaced in place and left unsaved; files that were not
|
||||
/// open are opened transiently, saved and closed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn replace_in_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
targets: Vec<ReplaceTarget>,
|
||||
on_event: Channel<ReplaceEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let events = on_event.clone();
|
||||
let summary = tokio::task::spawn_blocking(move || {
|
||||
let host = DocumentsHost(&project.documents);
|
||||
ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| {
|
||||
let _ = events.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()));
|
||||
host.end_request(request);
|
||||
let summary = summary??;
|
||||
let _ = on_event.send(ReplaceEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Before / after text of `path` for Replace › Preview (unsaved buffer included).
|
||||
pub async fn replace_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
path: PathBuf,
|
||||
only: Option<Vec<Pos>>,
|
||||
) -> IdeResult<ReplacePreview> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Validates a query (e.g. regex syntax) while the user types.
|
||||
pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> {
|
||||
ide_search::validate_query(&query)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("replace_in_files", |c| async move {
|
||||
replace_in_files(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("targets")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("replace_preview", |c| async move {
|
||||
replace_preview(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("path")?,
|
||||
c.arg("only")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) });
|
||||
}
|
||||
Loaded 100 of 1447 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача