commit 9a2025b76e37babdc8adebdc0014b8f4459c9c10 Author: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu Oct 22 18:55:10 2026 +0800 Initial open-source release Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..60ad34fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + rust: + name: Rust (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 11 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - uses: dtolnay/rust-toolchain@stable + with: { components: "clippy, rustfmt" } + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - name: Linux deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf + # tauri-build checks that frontendDist exists. + - run: pnpm install --frozen-lockfile && mkdir -p dist + shell: bash + - if: runner.os == 'Linux' + run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo nextest run --workspace + - name: Bindings are up to date + if: runner.os == 'Linux' + run: pnpm gen:bindings && git diff --exit-code src/bindings.ts + + frontend: + name: Frontend + runs-on: ubuntu-22.04 + 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 typecheck + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..88050b1f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g. v0.1.0); must already exist" + required: true + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + TAG: ${{ github.event.inputs.tag || github.ref_name }} + +jobs: + # Creates (or reuses) one draft release that every platform build uploads into. + create-release: + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.release.outputs.id }} + steps: + - uses: actions/checkout@v4 + with: { ref: "${{ env.TAG }}" } + - name: Tag matches app version + run: | + app="v$(jq -r .version src-tauri/tauri.conf.json)" + if [ "$app" != "$TAG" ]; then + echo "::error::tag $TAG does not match tauri.conf.json version $app" + exit 1 + fi + - id: release + uses: actions/github-script@v7 + with: + script: | + const tag = process.env.TAG; + const { owner, repo } = context.repo; + const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo }); + const existing = releases.find((r) => r.tag_name === tag); + if (existing) { + if (!existing.draft) core.setFailed(`release ${tag} is already published`); + core.setOutput("id", existing.id); + return; + } + const { data } = await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`, + draft: true, prerelease: tag.includes("-"), generate_release_notes: true, + }); + core.setOutput("id", data.id); + + build: + needs: create-release + name: Build (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: macOS arm64 + os: macos-latest + target: aarch64-apple-darwin + - name: macOS x64 + os: macos-latest + target: x86_64-apple-darwin + - name: Linux x64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - name: Windows x64 + os: windows-latest + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: { ref: "${{ env.TAG }}" } + - uses: pnpm/action-setup@v4 + with: { version: 11 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - uses: dtolnay/rust-toolchain@stable + with: { targets: "${{ matrix.target }}" } + - uses: Swatinem/rust-cache@v2 + with: { key: "${{ matrix.target }}" } + - name: Linux deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf + - run: pnpm install --frozen-lockfile + # Signing is optional: only secrets that are set get exported, so forks and + # unconfigured repos produce unsigned builds instead of failing. + - name: Signing env + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \ + APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do + [ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV" + done + true + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + releaseId: ${{ needs.create-release.outputs.release_id }} + tagName: ${{ env.TAG }} + args: --target ${{ matrix.target }} + + publish: + needs: [create-release, build] + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + RELEASE_ID: ${{ needs.create-release.outputs.release_id }} + with: + script: | + await github.rest.repos.updateRelease({ + ...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false, + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5223ec7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Rust +target/ +# Tauri generated schemas +/src-tauri/gen/schemas +*.tsbuildinfo +test-results/ +playwright-report/ + +# Local AI agent files +CLAUDE.md +.claude/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..dfd88ce1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +target +src/bindings.ts +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..734a4da5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "printWidth": 120, "semi": true, "singleQuote": false, "trailingComma": "all" } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..24d7cc6d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..9ae72dc3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,11132 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ast-grep-core" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd97b166e4a9b45b0337cad09f849607018c820489865488dd1cd0e7059c4f16" +dependencies = [ + "bit-set 0.8.0", + "regex", + "thiserror 2.0.21", + "tree-sitter", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.11.1", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases 0.2.2", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon 0.13.5", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81" + +[[package]] +name = "cranelift-control" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05" + +[[package]] +name = "cranelift-native" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "0.3.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" +dependencies = [ + "defmt 1.2.0", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.1", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg 0.56.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" +dependencies = [ + "autocfg", + "defmt 0.3.101", + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap 2.14.2", + "stable_deref_trait", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gix" +version = "0.89.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2156d2e07fc3afa7632a82bf3cb0db91f995645a2852f642105c9ac9b52e1cb2" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-note", + "gix-object", + "gix-odb", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-protocol", + "gix-quote", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", +] + +[[package]] +name = "gix-actor" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2127da41042965ed205568a52443465806944f71c8acd73f48ec94b8cd8e798e" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0feca0b8919aba9f123b75de54062dd61eb37d2a2130f34be520da6ed8b8ef72" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "408cb9e482e0dcc5fc481850556348dda6981ec054a4a8e81a8e1bbf84f85eef" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626335faf30ed235744642c2309aa370683b8bb2757f1c22a807e274dff60074" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac560c05e19455016a4a94b33a77debe59aaed8783da32a73d31c9975947152e" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-quote", + "gix-trace", +] + +[[package]] +name = "gix-commitgraph" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfede9936d4b789867c09722576aea4ceb0e3aacf962eef035ebab0686b41a80" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a9c000fa9a9154f22ffc8f8d74426e9300d759c2029e8701e2ba39d5d464ea" +dependencies = [ + "bstr", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-ref", + "gix-sec", + "gix-trace", + "gix-utils", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab206c2fccfd35e0de7fb1b424997cfd42912062c66dae024e08a06e22d46674" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "libc", +] + +[[package]] +name = "gix-date" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7eeeecc599082f32b5ea6a8c4c734e38be9730e2c09e3fe2851bb4692393e9" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.69.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa640963fdcbc6b4718e48593c3917f5ae764b59fb5a96313cb5a757246d041d" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", +] + +[[package]] +name = "gix-dir" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e916ef77f19161b738ae9913662bc36fed1e48256a943f40277b3a585841da" +dependencies = [ + "bstr", + "gix-discover", + "gix-error", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", +] + +[[package]] +name = "gix-discover" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7d0f8fd2c7e4b458eb3081362d49b54bd0131d23ecd49be802085a11f0d3cee" +dependencies = [ + "bstr", + "dunce", + "gix-error", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", +] + +[[package]] +name = "gix-error" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3308a948a01b94ef13d9ee7a9b28c1303d8d9a9dc33b401ff40c2184d9e04a" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-filter" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8d932ee4043f7d77d15ee58d2be2bc95accea5dd1d77fd145de1b7c504551b" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-error", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", +] + +[[package]] +name = "gix-fs" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44a05d043e670c055cd8b53d078562dc0bc1e322bff6b0376b407d37cf72dcc5" +dependencies = [ + "bstr", + "gix-error", + "gix-parallel", + "gix-path", + "gix-utils", + "libc", + "walkdir", +] + +[[package]] +name = "gix-glob" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0d03ab50fa841621031960679caf7fe94940968d9a0736d12a580f2c03e525" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-fs", + "gix-path", + "gix-trace", + "gix-utils", +] + +[[package]] +name = "gix-hash" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ca156064e378d8c657084365846cedb8dc498c6ea4500965a64d74f12af2be" +dependencies = [ + "faster-hex", + "gix-error", + "gix-utils", + "sha1dc", +] + +[[package]] +name = "gix-hashtable" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cb6b2ec42ffaca624e7867bb382a4673015cb4ffcd7330b48085d02e4c615" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1c93f60845d493a72fde38ae237498bc4e4464f45afa4af23e53f8295a3ba1" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6db8b20a3dba8836daf90b24199600bb27dcfce3a127ec24ae61e15a7c5b68" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", +] + +[[package]] +name = "gix-lock" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2f7f5bd6b9fcd0b0dc58b0e6a708f3a47c3313876571dfe800c926c069aa5c" +dependencies = [ + "gix-error", + "gix-fs", + "gix-tempfile", + "gix-utils", +] + +[[package]] +name = "gix-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f11f5429ca73cc3758c9f53af56a9fba100a8701c5b4bc30e29b8b0b15288991" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-note" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd364f8bdceaed9e7f3ae40f324b16e39f20e9c7e6a6a058f7c6ec66c3e6205c" +dependencies = [ + "gix-error", + "gix-hash", + "gix-object", +] + +[[package]] +name = "gix-object" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e38f50177e211642af154f9a7f710b7a3a637e635f9b6adb9d51973b0f9209" +dependencies = [ + "bstr", + "gix-actor", + "gix-command", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", +] + +[[package]] +name = "gix-odb" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca444f33f2140876ed6b5313c2b550b4af699008193c96f3bff5023afa9ef4ea" +dependencies = [ + "arc-swap", + "crc32fast", + "gix-error", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-pack" +version = "0.76.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5024cc503cd29461a705ae90b9eae772fa8f0e6fafc1c48513877dc374de05" +dependencies = [ + "clru", + "crc32fast", + "crossbeam-deque", + "gix-chunk", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-parallel", + "gix-path", + "gix-trace", + "gix-utils", + "gix-zlib", + "memmap2", + "smallvec", +] + +[[package]] +name = "gix-packetline" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e51b227f6a925fe9308858ac91170dd00fa2880f5480fc81e8a4594fe2c35" +dependencies = [ + "bstr", + "faster-hex", + "gix-error", + "gix-trace", +] + +[[package]] +name = "gix-parallel" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994cf1eec247ba4be7f9e7c0d58cc73f83eecb5fcb9feb595df5ba3d2078546" +dependencies = [ + "crossbeam-channel", + "gix-trace", + "once_cell", + "parking_lot", +] + +[[package]] +name = "gix-path" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d563d4d0c975ed0df4e853f9ec962a2ba46633b1d324e1a5582541fcae0c6b" +dependencies = [ + "bstr", + "gix-error", + "gix-trace", + "gix-validate", +] + +[[package]] +name = "gix-pathspec" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3b7f03e516a8435d18c3cc2d446925940c81d409145ac21edb5a576f0625e4" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-path", +] + +[[package]] +name = "gix-protocol" +version = "0.67.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832b5dc46a3c0a6f6fa8f8e571c11074add4b545c6789799ae6d6c36ae1a294e" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-hash", + "gix-macros", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", +] + +[[package]] +name = "gix-quote" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f92bc1b13c09e88388578e8aa1e987ec35f28fca158c13b1baa7ab1a85b124" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.69.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "073bc0928e1f747823b1eaed94918119dacef4c616532bc3d17e5b7457863471" +dependencies = [ + "gix-actor", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", +] + +[[package]] +name = "gix-refspec" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc7010dae0df2900aa77cc67f8aca042cd80e35938ccc179010d863b1d42a0c" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-validate", + "smallvec", +] + +[[package]] +name = "gix-revision" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63210c1943b67fa700806c3c3486fc16a92b3ea871f5cf092340b8eec10a0ca1" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "800479fbbf1e7f94f663e07943a91add5499343e7aac611a81e6a0460fd44af6" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", +] + +[[package]] +name = "gix-sec" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5640f642a1b471616d48230b1def7ca743cb30903441ff84800cafbab3c0fddf" +dependencies = [ + "bitflags 2.13.2", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bc0720a3d6b10f18e3ac382a7780bcfc4c6bee94a8b5857b8e4ec94c50d754" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-lock", + "nonempty", +] + +[[package]] +name = "gix-status" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3174a9f3fb991cf25a7a609ab6c50f19baa7d5f8ce05a48c202f7db4ce3ba" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-submodule" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d945257bd0d624ca844a5a36c79d748df080f355b0ac92d68793875aa529a3f" +dependencies = [ + "bstr", + "gix-config", + "gix-error", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", +] + +[[package]] +name = "gix-tempfile" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9275c8ae0646d840ae0156d3f90d39b7beea6e31a398afb9ee72feb293a269c" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-trace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133f2de116104d637541831b6acb157898556d5eeec363ab2eb1c583adc6bb56" + +[[package]] +name = "gix-transport" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e43d6b0097ea9f2f266d5d6c43b8f615ed3da7c686ec8771cd17c1cb33cb0de" +dependencies = [ + "bstr", + "gix-command", + "gix-error", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "gix-utils", +] + +[[package]] +name = "gix-traverse" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668a6e7f7688e5a43eaf161f1471ffc559de1a5fd8471e861c64d59d1699e1e" +dependencies = [ + "bitflags 2.13.2", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", +] + +[[package]] +name = "gix-url" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04317dea7a5dd5253bbcab060c230f3de6a57d17cc9600c6cd7030bf4795b414" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "percent-encoding", +] + +[[package]] +name = "gix-utils" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e770b8c34311044d1addc7b18957db0ac27183707e1140dc90e6f674a6354e" +dependencies = [ + "bstr", + "bytes", + "fastrand", + "getrandom 0.4.3", + "gix-error", + "prodash", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcd59cc52742468bc5c8a0e7123390d154f45e6cbd37461b0dcecdc681f2179" +dependencies = [ + "bstr", + "gix-error", +] + +[[package]] +name = "gix-worktree" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a285c0f6b41e504e8bb81ca3f861c4da00cb3d5ced3210a4591112f22da6d38f" +dependencies = [ + "bstr", + "gix-attributes", + "gix-error", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d2789610e8179d4e0e8e51170a52a66483c103a8ad3c7db4d075b571856cb6" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-parallel", + "gix-path", + "gix-trace", + "gix-traverse", + "gix-utils", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c2edb206bf06f3c801f4f6789c696396171558ab9b4c7f1fe3ba4d39b00b0f4" +dependencies = [ + "gix-error", + "zlib-rs", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "grep-matcher" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ide-analysis" +version = "0.1.0" +dependencies = [ + "ast-grep-core", + "ide-core", + "ide-language", + "rayon", + "serde", + "specta", + "tracing", + "tree-sitter", +] + +[[package]] +name = "ide-build" +version = "0.1.0" +dependencies = [ + "dirs 6.0.0", + "dunce", + "ide-core", + "libc", + "serde", + "serde_json", + "specta", + "tempfile", + "tracing", +] + +[[package]] +name = "ide-core" +version = "0.1.0" +dependencies = [ + "dunce", + "serde", + "serde_json", + "specta", + "thiserror 2.0.21", + "tokio-util", +] + +[[package]] +name = "ide-coverage" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-run", + "quick-xml", + "serde", + "serde_json", + "specta", + "tempfile", + "tracing", +] + +[[package]] +name = "ide-dap" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-jsonrpc", + "ide-language", + "memchr", + "parking_lot", + "serde", + "serde_json", + "specta", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ide-db" +version = "0.1.0" +dependencies = [ + "dirs 6.0.0", + "futures", + "ide-core", + "parking_lot", + "russh", + "russh-config", + "serde", + "serde_json", + "specta", + "sqlx", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", +] + +[[package]] +name = "ide-docker" +version = "0.1.0" +dependencies = [ + "ide-core", + "libc", + "serde", + "serde_json", + "specta", + "tracing", + "which", +] + +[[package]] +name = "ide-github" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-vcs", + "libc", + "serde", + "serde_json", + "specta", + "tracing", + "which", +] + +[[package]] +name = "ide-http" +version = "0.1.0" +dependencies = [ + "ide-core", + "parking_lot", + "reqwest", + "rustls", + "rustls-platform-verifier", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "ide-index" +version = "0.1.0" +dependencies = [ + "blake3", + "criterion", + "dunce", + "globset", + "ide-core", + "ide-language", + "ignore", + "nucleo-matcher", + "parking_lot", + "postcard", + "rayon", + "redb", + "serde", + "specta", + "tempfile", + "tracing", +] + +[[package]] +name = "ide-jsonrpc" +version = "0.1.0" +dependencies = [ + "futures", + "ide-core", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "ide-language" +version = "0.1.0" +dependencies = [ + "blake3", + "criterion", + "ide-core", + "ide-text", + "parking_lot", + "regex-lite", + "serde", + "specta", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-dart", + "tree-sitter-elixir", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-haskell", + "tree-sitter-hcl", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-kotlin-ng", + "tree-sitter-language", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-nix", + "tree-sitter-ocaml", + "tree-sitter-php", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-xml", + "tree-sitter-yaml", + "tree-sitter-zig", +] + +[[package]] +name = "ide-lsp" +version = "0.1.0" +dependencies = [ + "futures", + "globset", + "ide-core", + "ide-jsonrpc", + "ide-language", + "ide-text", + "lsp-types", + "parking_lot", + "percent-encoding", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", + "which", +] + +[[package]] +name = "ide-plugin" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-language", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "thiserror 2.0.21", + "toml 0.9.12+spec-1.1.0", + "tracing", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "ide-profiler" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-run", + "serde", + "serde_json", + "specta", + "tempfile", + "tracing", +] + +[[package]] +name = "ide-run" +version = "0.1.0" +dependencies = [ + "encoding_rs", + "ide-core", + "libc", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", + "tracing", +] + +[[package]] +name = "ide-sdk" +version = "0.1.0" +dependencies = [ + "dunce", + "ide-core", + "ide-language", + "ide-run", + "serde", + "serde_json", + "specta", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "tracing", +] + +[[package]] +name = "ide-search" +version = "0.1.0" +dependencies = [ + "criterion", + "globset", + "grep-matcher", + "grep-regex", + "grep-searcher", + "ide-core", + "ignore", + "insta", + "memchr", + "serde", + "specta", + "tempfile", + "tracing", +] + +[[package]] +name = "ide-syntax" +version = "0.1.0" +dependencies = [ + "ide-core", + "ide-language", + "ide-text", + "memchr", + "rayon", + "serde", + "specta", + "tracing", + "tree-sitter", +] + +[[package]] +name = "ide-terminal" +version = "0.1.0" +dependencies = [ + "ide-core", + "libc", + "parking_lot", + "portable-pty", + "serde", + "specta", + "tracing", +] + +[[package]] +name = "ide-text" +version = "0.1.0" +dependencies = [ + "criterion", + "ide-core", + "proptest", + "ropey", + "serde", + "specta", +] + +[[package]] +name = "ide-tools" +version = "0.1.0" +dependencies = [ + "flate2", + "hex", + "ide-core", + "ide-language", + "parking_lot", + "serde", + "serde_json", + "sha2 0.10.9", + "specta", + "tar", + "tempfile", + "tracing", + "which", + "zip 2.4.2", +] + +[[package]] +name = "ide-vcs" +version = "0.1.0" +dependencies = [ + "dunce", + "gix", + "ide-core", + "libc", + "regex-lite", + "serde", + "serde_json", + "specta", + "tempfile", + "tracing", + "which", +] + +[[package]] +name = "ide-vfs" +version = "0.1.0" +dependencies = [ + "chardetng", + "dunce", + "encoding_rs", + "globset", + "ide-core", + "ignore", + "notify-debouncer-full", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "tracing", + "trash", +] + +[[package]] +name = "ide-workspace" +version = "0.1.0" +dependencies = [ + "blake3", + "dunce", + "encoding_rs", + "globset", + "ide-core", + "ide-text", + "ide-vfs", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", + "tracing", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "insta" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt 1.2.0", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt 1.2.0", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.3", + "rand_core 0.10.1", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openidea" +version = "0.1.0" +dependencies = [ + "dirs 6.0.0", + "dunce", + "ide-analysis", + "ide-build", + "ide-core", + "ide-coverage", + "ide-dap", + "ide-db", + "ide-docker", + "ide-github", + "ide-http", + "ide-index", + "ide-language", + "ide-lsp", + "ide-plugin", + "ide-profiler", + "ide-run", + "ide-sdk", + "ide-search", + "ide-syntax", + "ide-terminal", + "ide-text", + "ide-tools", + "ide-vcs", + "ide-vfs", + "ide-workspace", + "parking_lot", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-mcp-bridge", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tauri-specta", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.1", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.1", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.1", +] + +[[package]] +name = "pageant" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.3", + "sha2 0.11.1", + "thiserror 2.0.21", + "tokio", + "windows", + "windows-strings", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "der", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.1", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf52d87428247c5aa3760ac8e7d8b13ec7b3579b955a8c3f311668b6860acba5" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand 0.10.3", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pulley-interpreter" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redb" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39178e0a867f9e3eaa6292a4ab1dd3613f2519c5b564341e1e7b29b270736cc4" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.1", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58" +dependencies = [ + "aes", + "bitflags 2.13.2", + "block-padding", + "byteorder", + "bytes", + "cbc", + "cipher", + "crypto-bigint", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash", + "hex-literal", + "hmac", + "inout", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval", + "rand 0.10.3", + "rand_core 0.10.1", + "ring", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.1", + "sha3", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.21", + "tokio", + "typenum", + "universal-hash", + "zeroize", +] + +[[package]] +name = "russh-config" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955" +dependencies = [ + "futures", + "globset", + "log", + "thiserror 2.0.21", + "tokio", + "whoami 1.6.1", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.1", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha1dc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" +dependencies = [ + "paste", + "rustc_version", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "specta-serde" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" +dependencies = [ + "specta", + "specta-macros", +] + +[[package]] +name = "specta-typescript" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-util" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" +dependencies = [ + "specta", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.2", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array 0.14.7", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.1", + "sqlx-core", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "whoami 2.1.3", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.21", + "tracing", + "url", +] + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "chacha20", + "cipher", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.1", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tauri" +version = "2.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 7.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "specta", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 7.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "static_vcruntime", + "strsim", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.2", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-mcp-bridge" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f1ce740e913f8b1be3f3c0e86ad622a99121f64eb81f4e316fd80110224b15" +dependencies = [ + "base64 0.22.1", + "block2 0.5.1", + "cairo-rs", + "futures-util", + "gio", + "image", + "jni 0.21.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "objc2-web-kit 0.2.2", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "uuid", + "webkit2gtk", + "webview2-com", + "windows", + "windows-core", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" +dependencies = [ + "base64 0.22.1", + "dirs 7.0.0", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2 0.6.5", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-serde", + "specta-typescript", + "specta-util", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" +dependencies = [ + "darling 0.23.0", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tauri-utils" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.21", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs 7.0.0", + "libappindicator", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22893fac54133fdb93497efc45713d53a96d93b035288c7a8ab0c2af77c0ddd" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-dart" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325dd1e24ee9ee21111e9c43680ae7d6010aaa9f282b048a99b9c7163c1cf553" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3628190860fa4d04703babffb9d70f4da8ee934b8f79423c86ce8f3c6214b6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-haskell" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-hcl" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7b2cc3d7121553b84309fab9d11b3ff3d420403eef9ae50f9fd1cd9d9cf012" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-ng" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-nix" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4952a9733f3a98f6683a0ccd1035d84ab7a52f7e84eeed58548d86765ad92de3" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ocaml" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43adb10fcccbcbbce0f4adc9dab7cd55f0a5eafc2d71bd4c03fcc5d6767066e0" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9779e392d38af4254f48be0e57b7eed4d50ec7a6e55a7e5e2e74df8f8f032d3" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c93b6f1ed20de442e900eb636f2176af6063953dfaa9f76bf168dd3b490a3a1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c554730d29306d9c7cdd26912638d2401378c26961e12d161a071df43f49b81b" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2688c06564afa397313e0db17d140a73e2c6a47f3884794614246167c9dd5dfd" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-xml" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap 2.14.2", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea" +dependencies = [ + "addr2line", + "async-trait", + "bitflags 2.13.2", + "bumpalo", + "cc", + "encoding_rs", + "futures", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.5", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon 0.13.5", + "wasm-encoder", + "wasmparser", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fca03bd4425856574c0807d1dee16fad43f947feaf4e9ae1d2129596ee4de4" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon 0.13.5", + "thiserror 2.0.21", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307" +dependencies = [ + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b" +dependencies = [ + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eaf45d4da7681d234bb6efd846b4a528b08a75384a7fc23f125d0145364b06" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "heck 0.5.0", + "indexmap 2.14.2", + "wit-component", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "821e45765c35555d3b0bdf9277cb99478f7d1f29a17b3315ba76e01220f02a38" +dependencies = [ + "async-trait", + "bitflags 2.13.2", + "bytes", + "cap-primitives", + "futures", + "rand 0.10.3", + "rustix", + "rustix-linux-procfs", + "thiserror 2.0.21", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cdff38d1c730bdaf4fcfbe0eedf3265fb9c7a4deb119deecbfc1c1778950716" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "indexmap 2.14.2", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap 2.14.2", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs 7.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.2", + "memchr", + "thiserror 2.0.21", + "zopfli", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..586e0112 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,104 @@ +[workspace] +resolver = "3" +members = ["crates/*", "src-tauri"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +license = "Apache-2.0" +authors = ["OpenIDEA contributors"] + +[workspace.dependencies] +# internal +ide-core = { path = "crates/ide-core" } +ide-vfs = { path = "crates/ide-vfs" } +ide-text = { path = "crates/ide-text" } +ide-language = { path = "crates/ide-language" } +ide-index = { path = "crates/ide-index" } +ide-syntax = { path = "crates/ide-syntax" } +ide-search = { path = "crates/ide-search" } +ide-jsonrpc = { path = "crates/ide-jsonrpc" } +ide-lsp = { path = "crates/ide-lsp" } +ide-dap = { path = "crates/ide-dap" } +ide-vcs = { path = "crates/ide-vcs" } +ide-terminal = { path = "crates/ide-terminal" } +ide-run = { path = "crates/ide-run" } +ide-workspace = { path = "crates/ide-workspace" } +ide-sdk = { path = "crates/ide-sdk" } +ide-build = { path = "crates/ide-build" } +ide-analysis = { path = "crates/ide-analysis" } +ide-coverage = { path = "crates/ide-coverage" } +ide-profiler = { path = "crates/ide-profiler" } +ide-http = { path = "crates/ide-http" } +ide-db = { path = "crates/ide-db" } + +# serialization & IPC typing +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" +toml_edit = "0.25" +specta = { version = "=2.0.0-rc.25", features = ["derive"] } + +# async & concurrency +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] } +tokio-util = { version = "0.7", features = ["rt"] } +parking_lot = "0.12" +rayon = "1" +dashmap = "6" +futures = "0.3" + +# errors & diagnostics +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# text & fs +ropey = "1.6" +encoding_rs = "0.8" +chardetng = "0.1" +ignore = "0.4" +globset = "0.4" +notify = "8" +notify-debouncer-full = "0.6" +trash = "5" +blake3 = "1" +dunce = "1" +dirs = "6" + +# search, index, language +grep-searcher = "0.1" +grep-regex = "0.1" +grep-matcher = "0.1" +nucleo-matcher = "0.3" +redb = "4" +tree-sitter = "0.25" + +# protocols & integrations +lsp-types = "0.97" +gix = { version = "0.89", default-features = false } +portable-pty = "0.9" +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "gzip", "deflate", "brotli"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-platform-verifier = "0.7" +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] } +uuid = { version = "1", features = ["v4"] } + +# testing +insta = { version = "1", features = ["yaml"] } +proptest = "1" +tempfile = "3" + +[profile.dev] +# Keep debug builds snappy while giving dependencies decent speed. +opt-level = 0 +[profile.dev.package."*"] +opt-level = 2 + +[profile.release] +codegen-units = 1 +lto = true +opt-level = 3 +panic = "abort" +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..0d6c0299 --- /dev/null +++ b/NOTICE @@ -0,0 +1,12 @@ +OpenIDEA +Copyright 2026 OpenIDEA contributors + +This product includes software and icons developed by JetBrains s.r.o. and +contributors as part of the IntelliJ Platform (https://github.com/JetBrains/intellij-community, +https://github.com/JetBrains/intellij-plugins), licensed under the Apache License 2.0. +Copyright 2000-2026 JetBrains s.r.o. and contributors. + +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. + +See THIRD_PARTY_LICENSES.md for the full list of bundled third-party assets and their licences. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2d8a6d34 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +

+ + + OpenIDEA + +

+ +An open-source desktop IDE in the spirit of IntelliJ IDEA, built with Rust and Tauri. + +Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. + +> [!WARNING] +> OpenIDEA is early-stage software (0.1). Expect rough edges and breaking changes. + +## Features + +- **Editor** — CodeMirror 6 with tree-sitter highlighting, folding, multi-caret, + live templates and an IDEA-style keymap. +- **Code insight** — LSP-backed completion, navigation, find usages, rename and + refactorings, diagnostics and inspections, plus a fast project-wide index. +- **Search** — Search Everywhere, Go to File / Symbol / Action, Find in Files. +- **Run & debug** — run configurations, DAP debugging, test runner, coverage and + a profiler. +- **Build tools** — Maven and Gradle project sync and builds. +- **Version control** — Git (status, diff, commit, history, branches, blame) and + GitHub pull requests. +- **Tools** — integrated terminal, database client (PostgreSQL, MySQL, SQLite, + SSH tunnels), HTTP client, Docker, scratch files and TODO view. +- **Languages** — 60+ languages via declarative definitions in + [`languages/`](languages): detection, comments, language server and debug + adapter in one TOML file each. +- **Plugins** — sandboxed WebAssembly plugins against the [`wit/plugin.wit`](wit) + interface; see [`plugins/`](plugins) for examples. + +## Building from source + +Requirements: Rust 1.96+, Node.js 22+, pnpm, and the +[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your platform. + +```sh +pnpm install +pnpm tauri dev # run the app in development mode +pnpm tauri build # produce a release bundle +``` + +Language servers and debug adapters are not bundled; OpenIDEA finds them on +`PATH` or offers to download them on first use. + +## Development + +```sh +pnpm typecheck && pnpm lint && pnpm test # frontend +cargo nextest run --workspace # backend tests +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all +pnpm gen:bindings # regenerate src/bindings.ts after IPC changes +pnpm perf && pnpm perf:rust # performance checks vs perf/budgets.json +``` + +Project layout: + +| Path | Contents | +| --- | --- | +| `crates/ide-*` | Backend crates, one domain each (VFS, index, LSP, DAP, VCS, run, …) | +| `src-tauri/` | Tauri app: state and thin IPC command adapters | +| `src/` | React UI: `platform/` (IPC, actions, keymaps), `workbench/`, `ui/` (design system), `features/` | +| `languages/` | Language definitions | +| `plugins/`, `wit/` | Example plugins and the plugin interface | + +## License + +[Apache License 2.0](LICENSE); see [`NOTICE`](NOTICE). Bundled third-party assets, +including icons from the IntelliJ Platform, are listed in +[`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000..46787cba --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,150 @@ +# Third-party licenses + +## JetBrains IntelliJ Platform icons + +`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui) +icon set, copied unmodified (some renamed) from JetBrains' open-source repositories: + +- [intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0): + `platform/icons/src/expui/**` (incl. `fileTypes/` c, cpp, h, Csharp, typeScript, + sql, graphql, docker, gitignore, gradle, groovy, jenkins, jupyter, rst, swiftLang, + terraform, vue, bazel); `python/python-parser` (`fileTypes/python`); + `python/python-requirements` (`fileTypes/requirements`); `python/pipenv` + (`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` → + `fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`, + `kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`); + `plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`); + `plugins/terminal` (`toolwindows/terminal`); `plugins/maven` + (`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*` + → `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`, + `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`); + `platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`). + Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below). +- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin + carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` → + `fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` → + `fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` → + `fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below). + +At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`. + +`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for +languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig, +Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`, +`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0 +expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains. +`src/ui/openidea/runConfigurations/springBoot*.svg` (the Spring Boot run configuration +leaf) is OpenIDEA's own drawing. + +The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined from +Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1, +https://github.com/rsms/inter-tight). + +### MIT License (makefile icon) + +Copyright (c) 2016 Victor Kropp + +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. + +## Tools downloaded on request (not bundled) + +The tool installer (`crates/ide-tools`, Settings › Languages & Frameworks › +Language Servers) downloads third-party language servers and debuggers into the +user's app-data directory only when the user asks; none of them ships with OpenIDEA. + +- **Microsoft java-debug** (Java debugger, loaded into jdtls as a bundle): + `com.microsoft.java.debug.plugin-*.jar` from , + Copyright (c) Microsoft Corporation, licensed under the + [Eclipse Public License 1.0](https://github.com/microsoft/java-debug/blob/main/LICENSE.txt) + (EPL-1.0; it embeds `commons-io` and `rxjava`/`reactive-streams`, Apache-2.0). OpenIDEA + takes the jar from the `vscjava.vscode-java-debug` extension package on Open VSX + (, the extension itself is + MIT-licensed), verifies its published SHA-256 and extracts only the jar; it is used + unmodified, and its source is available from the repository above. +- **Eclipse JDT Language Server** (jdtls): EPL-2.0, from download.eclipse.org. + +## nvim-treesitter queries + +`crates/ide-syntax/queries//locals.scm` are derived from the `locals.scm` +queries of [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) +(`runtime/queries/`, commit `29212d82`, October 2026), Apache License 2.0 (the +same text as the Apache-2.0 licence below). Copyright the nvim-treesitter +contributors. Modifications by OpenIDEA: `inherits:` chains flattened (ecma, +c → cpp, php_only), node names adapted to the bundled grammar versions +(Kotlin for tree-sitter-kotlin-ng, Dart, Swift, Haskell, C#, Bash), member +definitions re-kinded as `field` / `method`, and missing scopes, definitions and +references added; each file's header notes its changes. + +--- + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +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 diff --git a/crates/ide-analysis/Cargo.toml b/crates/ide-analysis/Cargo.toml new file mode 100644 index 00000000..9a436301 --- /dev/null +++ b/crates/ide-analysis/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ide-analysis" +description = "Project-wide code analysis over syntax trees: duplicate detection (Locate Duplicates) and structural search & replace" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-language.workspace = true +serde.workspace = true +specta.workspace = true +tree-sitter.workspace = true +rayon.workspace = true +tracing.workspace = true +# Pinned: 0.40.2+ moves to tree-sitter 0.26, the workspace is on 0.25. +ast-grep-core = "=0.40.1" diff --git a/crates/ide-analysis/src/duplicates.rs b/crates/ide-analysis/src/duplicates.rs new file mode 100644 index 00000000..9e70173e --- /dev/null +++ b/crates/ide-analysis/src/duplicates.rs @@ -0,0 +1,453 @@ +//! Locate Duplicates: token-based clone detection (CPD-style). +//! +//! 1. Every file is tokenised with its tree-sitter grammar: the leaves of the +//! tree, comments dropped, literals kept as one token each. Identifiers and +//! literals can be anonymised so renamed copies still match. +//! 2. A rolling hash over each window of `min_tokens` tokens indexes every +//! position; windows sharing a hash are verified token by token. +//! 3. Each verified pair that cannot be extended backwards is extended +//! forwards to its maximal length; equal token runs of equal length form one +//! group of fragments. + +use crate::lines::Lines; +use crate::{Loader, SourceFile}; +use ide_core::{CancellationToken, IpcPath, LanguageId, Range}; +use ide_language::Syntax; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::time::Instant; + +/// Fragments of one group whose text is sent with the report (each capped). +const MAX_FRAGMENT_TEXT: usize = 32 * 1024; +/// Window positions compared per hash bucket (pathologically repetitive code). +const MAX_BUCKET: usize = 64; +/// Groups reported at most. +const MAX_GROUPS: usize = 500; +/// Base of the polynomial rolling hash. +const BASE: u64 = 0x100_0000_01b3; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DuplicateOptions { + /// Shortest duplicate reported, in tokens. + pub min_tokens: u32, + /// Treat all identifiers as equal (finds copies with renamed variables). + pub anonymize_identifiers: bool, + /// Treat all literals as equal. + pub anonymize_literals: bool, +} + +impl Default for DuplicateOptions { + fn default() -> Self { + Self { min_tokens: 60, anonymize_identifiers: true, anonymize_literals: true } + } +} + +/// One occurrence of a duplicated code fragment. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Fragment { + pub path: IpcPath, + pub rel_path: String, + pub range: Range, + /// The fragment's source, from the start of its first line (capped). + pub text: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DuplicateGroup { + pub tokens: u32, + /// Lines of the first fragment. + pub lines: u32, + pub language: LanguageId, + pub fragments: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DuplicatesReport { + pub groups: Vec, + /// Files tokenised (files without a grammar are skipped). + pub files: u32, + pub tokens: u32, + /// More groups were found than reported. + pub truncated: bool, + pub cancelled: bool, + pub elapsed_ms: u32, +} + +#[derive(Clone, Copy, Debug)] +struct Token { + hash: u64, + start: u32, + end: u32, +} + +struct Tokenized { + file: SourceFile, + language: LanguageId, + text: String, + tokens: Vec, + /// `prefix[i]` = rolling hash of `tokens[..i]`. + prefix: Vec, +} + +impl Tokenized { + fn window(&self, at: usize, len: usize, pow: &Pow) -> u64 { + self.prefix[at + len].wrapping_sub(self.prefix[at].wrapping_mul(pow.get(len))) + } +} + +/// Powers of [`BASE`], computed lazily up to the longest run needed. +struct Pow(Vec); + +impl Pow { + fn new(max: usize) -> Self { + let mut v = Vec::with_capacity(max + 1); + let mut p = 1u64; + for _ in 0..=max { + v.push(p); + p = p.wrapping_mul(BASE); + } + Self(v) + } + fn get(&self, n: usize) -> u64 { + self.0[n] + } +} + +/// Finds duplicated code among `files`. Files of different languages never +/// match each other. +pub fn find_duplicates( + syntax: &Syntax, + files: &[SourceFile], + load: Loader<'_>, + options: &DuplicateOptions, + cancel: &CancellationToken, +) -> DuplicatesReport { + let started = Instant::now(); + let k = options.min_tokens.max(5) as usize; + let docs: Vec = files + .par_iter() + .filter_map(|file| { + if cancel.is_cancelled() { + return None; + } + let (language, text) = load(&file.path)?; + let tokens = tokenize(syntax, &language, &text, options, cancel)?; + (tokens.len() >= k).then(|| { + let mut prefix = Vec::with_capacity(tokens.len() + 1); + let mut h = 0u64; + prefix.push(h); + for t in &tokens { + h = h.wrapping_mul(BASE).wrapping_add(t.hash); + prefix.push(h); + } + Tokenized { file: file.clone(), language, text, tokens, prefix } + }) + }) + .collect(); + let mut report = DuplicatesReport { + files: docs.len() as u32, + tokens: docs.iter().map(|d| d.tokens.len() as u32).sum(), + ..Default::default() + }; + if cancel.is_cancelled() { + report.cancelled = true; + return report; + } + let pow = Pow::new(docs.iter().map(|d| d.tokens.len()).max().unwrap_or(0)); + + // Window hash -> positions, per language (mixed in so languages never collide). + let mut index: HashMap> = HashMap::new(); + for (fi, d) in docs.iter().enumerate() { + let salt = hash_str(d.language.as_str()); + for at in 0..=d.tokens.len() - k { + index.entry(d.window(at, k, &pow) ^ salt).or_default().push((fi as u32, at as u32)); + } + if cancel.is_cancelled() { + report.cancelled = true; + return report; + } + } + + // (length, content hash) -> occurrences. + let mut groups: HashMap<(usize, u64), Vec<(u32, u32)>> = HashMap::new(); + for bucket in index.values().filter(|b| b.len() > 1) { + if cancel.is_cancelled() { + report.cancelled = true; + return report; + } + let bucket = &bucket[..bucket.len().min(MAX_BUCKET)]; + for (i, &(fa, pa)) in bucket.iter().enumerate() { + for &(fb, pb) in &bucket[i + 1..] { + let (a, b) = (&docs[fa as usize], &docs[fb as usize]); + let (pa, pb) = (pa as usize, pb as usize); + if a.language != b.language || !same(a, pa, b, pb, k) { + continue; + } + // Not maximal: the pair also matches one token earlier. + if pa > 0 && pb > 0 && a.tokens[pa - 1].hash == b.tokens[pb - 1].hash { + continue; + } + let mut len = k; + while pa + len < a.tokens.len() + && pb + len < b.tokens.len() + && a.tokens[pa + len].hash == b.tokens[pb + len].hash + { + len += 1; + } + if fa == fb { + // Overlapping copies within one file (repetitive code): keep the disjoint part. + let (lo, hi) = (pa.min(pb), pa.max(pb)); + len = len.min(hi - lo); + if len < k { + continue; + } + } + let key = (len, a.window(pa, len, &pow)); + let occ = groups.entry(key).or_default(); + for o in [(fa, pa as u32), (fb, pb as u32)] { + if !occ.contains(&o) { + occ.push(o); + } + } + } + } + } + + // Largest duplication (length × copies) first; ties in source order, so runs are stable. + let mut ranked: Vec<(usize, Vec<(u32, u32)>)> = groups + .into_iter() + .map(|((len, _), mut occ)| { + occ.sort_unstable(); + (len, occ) + }) + .collect(); + ranked.sort_by(|a, b| (b.0 * b.1.len()).cmp(&(a.0 * a.1.len())).then_with(|| a.1.cmp(&b.1))); + report.truncated = ranked.len() > MAX_GROUPS; + report.groups = ranked + .into_iter() + .take(MAX_GROUPS) + .map(|(len, occ)| { + let fragments: Vec = + occ.iter().map(|&(f, p)| fragment(&docs[f as usize], p as usize, len)).collect(); + let lines = fragments.first().map_or(0, |f| f.range.end.line - f.range.start.line + 1); + DuplicateGroup { tokens: len as u32, lines, language: docs[occ[0].0 as usize].language.clone(), fragments } + }) + .collect(); + report.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX); + report +} + +fn same(a: &Tokenized, pa: usize, b: &Tokenized, pb: usize, k: usize) -> bool { + a.tokens[pa..pa + k].iter().zip(&b.tokens[pb..pb + k]).all(|(x, y)| x.hash == y.hash) +} + +fn fragment(d: &Tokenized, at: usize, len: usize) -> Fragment { + let start = d.tokens[at].start as usize; + let end = d.tokens[at + len - 1].end as usize; + let lines = Lines::new(&d.text); + let from = d.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut to = end.min(from + MAX_FRAGMENT_TEXT); + while !d.text.is_char_boundary(to) { + to -= 1; + } + Fragment { + path: IpcPath(d.file.path.clone()), + rel_path: d.file.rel_path.clone(), + range: lines.range(start, end), + text: d.text[from..to].to_owned(), + } +} + +fn hash_str(s: &str) -> u64 { + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + +fn is_comment(kind: &str) -> bool { + kind.contains("comment") +} + +/// Literal nodes are one token each (their inner structure — quotes, escapes, +/// string fragments — does not matter for duplication). +fn is_literal(kind: &str) -> bool { + !kind.contains("content") + && ["string", "char", "number", "integer", "float", "literal", "decimal", "rune"] + .iter() + .any(|w| kind.contains(w)) +} + +fn is_identifier(kind: &str) -> bool { + kind.contains("identifier") || matches!(kind, "name" | "constant" | "variable_name" | "word") +} + +/// The token stream of `text`, or `None` without a grammar. +fn tokenize( + syntax: &Syntax, + language: &LanguageId, + text: &str, + options: &DuplicateOptions, + cancel: &CancellationToken, +) -> Option> { + let anon_id = hash_str("\0id"); + let anon_lit = hash_str("\0lit"); + syntax + .with_parsed(language, text, &|| cancel.is_cancelled(), |parsed| { + let mut out = Vec::new(); + let mut cursor = parsed.tree.walk(); + 'walk: loop { + let node = cursor.node(); + let kind = node.kind(); + let literal = node.is_named() && is_literal(kind); + if is_comment(kind) { + // Skipped with its children. + } else if literal || node.child_count() == 0 { + let (start, end) = (node.start_byte(), node.end_byte()); + if end > start { + let hash = if literal && options.anonymize_literals { + anon_lit + } else if !literal && options.anonymize_identifiers && is_identifier(kind) { + anon_id + } else { + let mut h = DefaultHasher::new(); + kind.hash(&mut h); + text[start..end].hash(&mut h); + h.finish() + }; + out.push(Token { hash, start: start as u32, end: end as u32 }); + } + } else if cursor.goto_first_child() { + continue; + } + while !cursor.goto_next_sibling() { + if !cursor.goto_parent() { + break 'walk; + } + } + } + out + }) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + const A: &str = r#" +fn total(items: &[Item]) -> u64 { + let mut sum = 0; + for item in items { + if item.enabled && item.price > 10 { + sum += item.price * item.count; + } else { + sum += 1; + } + } + println!("total {}", sum); + sum +} +"#; + + /// The same body with other names and literals. + const B: &str = r#" +// a copy +fn grand_total(entries: &[Entry]) -> u64 { + let mut acc = 0; + for e in entries { + if e.enabled && e.price > 99 { + acc += e.price * e.count; + } else { + acc += 2; + } + } + println!("sum {}", acc); + acc +} +"#; + + const C: &str = "fn other() { let x = vec![1, 2, 3]; x.len() }\n"; + + fn run(files: &[(&str, &str)], options: DuplicateOptions) -> DuplicatesReport { + let syntax = Syntax::new(); + let sources: Vec = + files.iter().map(|(name, _)| SourceFile { path: PathBuf::from(name), rel_path: (*name).into() }).collect(); + let map: HashMap = files.iter().map(|(n, t)| (PathBuf::from(n), (*t).to_owned())).collect(); + let load = |p: &Path| map.get(p).map(|t| (LanguageId::new("rust"), t.clone())); + find_duplicates(&syntax, &sources, &load, &options, &CancellationToken::new()) + } + + #[test] + fn finds_renamed_copies_when_anonymised() { + let r = + run(&[("a.rs", A), ("b.rs", B), ("c.rs", C)], DuplicateOptions { min_tokens: 20, ..Default::default() }); + assert_eq!(r.files, 3); + assert_eq!(r.groups.len(), 1, "{:#?}", r.groups); + let g = &r.groups[0]; + assert_eq!(g.fragments.len(), 2); + assert_eq!(g.fragments[0].rel_path, "a.rs"); + assert_eq!(g.fragments[1].rel_path, "b.rs"); + // The whole function matches, from `fn` to the closing brace. + assert_eq!(g.fragments[0].range.start.line, 1); + assert_eq!(g.fragments[0].range.end.line, 12); + assert_eq!(g.fragments[1].range.start.line, 2, "the comment is not part of the clone"); + assert!(g.fragments[1].text.starts_with("fn grand_total")); + assert_eq!(g.lines, 12); + } + + #[test] + fn exact_mode_needs_equal_names() { + let r = run( + &[("a.rs", A), ("b.rs", B)], + DuplicateOptions { min_tokens: 20, anonymize_identifiers: false, anonymize_literals: false }, + ); + assert!(r.groups.is_empty(), "{:#?}", r.groups); + let r = run( + &[("a.rs", A), ("a2.rs", A)], + DuplicateOptions { min_tokens: 20, anonymize_identifiers: false, anonymize_literals: false }, + ); + assert_eq!(r.groups.len(), 1); + assert_eq!(r.groups[0].fragments.len(), 2); + } + + #[test] + fn three_copies_form_one_group_and_min_size_applies() { + let r = + run(&[("a.rs", A), ("b.rs", B), ("c.rs", A)], DuplicateOptions { min_tokens: 20, ..Default::default() }); + assert_eq!(r.groups.len(), 1); + assert_eq!(r.groups[0].fragments.len(), 3); + let r = run(&[("a.rs", A), ("b.rs", B)], DuplicateOptions { min_tokens: 500, ..Default::default() }); + assert!(r.groups.is_empty()); + } + + #[test] + fn repeats_within_one_file_do_not_overlap() { + let twice = format!("{A}\n{A}"); + let r = run(&[("a.rs", &twice)], DuplicateOptions { min_tokens: 20, ..Default::default() }); + assert_eq!(r.groups.len(), 1); + let f = &r.groups[0].fragments; + assert_eq!(f.len(), 2); + assert!(f[0].range.end.line < f[1].range.start.line); + } + + #[test] + fn cancelled_runs_report_it() { + let syntax = Syntax::new(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let files = vec![SourceFile { path: PathBuf::from("a.rs"), rel_path: "a.rs".into() }]; + let load = |_: &Path| Some((LanguageId::new("rust"), A.to_owned())); + let r = find_duplicates(&syntax, &files, &load, &DuplicateOptions::default(), &cancel); + assert!(r.cancelled); + assert!(r.groups.is_empty()); + } +} diff --git a/crates/ide-analysis/src/lib.rs b/crates/ide-analysis/src/lib.rs new file mode 100644 index 00000000..1cdbc55c --- /dev/null +++ b/crates/ide-analysis/src/lib.rs @@ -0,0 +1,36 @@ +//! Project-wide analyses over syntax trees. +//! +//! * [`duplicates`]: Locate Duplicates — token-based clone detection in the +//! style of PMD's CPD (tree-sitter leaves as tokens, optional anonymisation +//! of identifiers and literals, rolling hashes over token windows). +//! * [`structural`]: Search / Replace Structurally — code patterns with +//! `$VAR$` placeholders matched against syntax trees (ast-grep's matcher on +//! the bundled grammars). +//! +//! Both work on whole files supplied by the caller (unsaved buffers first), +//! run on rayon and honour a [`CancellationToken`]: call them from a blocking +//! thread. Positions are 0-based lines and UTF-16 columns. + +pub mod duplicates; +mod lines; +pub mod structural; + +pub use duplicates::{DuplicateGroup, DuplicateOptions, DuplicatesReport, Fragment, find_duplicates}; +pub use structural::{ + StructuralFile, StructuralMatch, StructuralQuery, StructuralSummary, normalize_placeholders, search_structurally, + validate_structural, +}; + +use ide_core::LanguageId; +use std::path::{Path, PathBuf}; + +/// A file to analyse: its project-relative path and a way to read it. +#[derive(Clone, Debug)] +pub struct SourceFile { + pub path: PathBuf, + pub rel_path: String, +} + +/// Reads a file for analysis: its language and current text (the unsaved +/// buffer when open). `None` skips the file (binary, unreadable, too large). +pub type Loader<'a> = &'a (dyn Fn(&Path) -> Option<(LanguageId, String)> + Sync); diff --git a/crates/ide-analysis/src/lines.rs b/crates/ide-analysis/src/lines.rs new file mode 100644 index 00000000..f19b5917 --- /dev/null +++ b/crates/ide-analysis/src/lines.rs @@ -0,0 +1,57 @@ +//! Byte offset -> 0-based line / UTF-16 column for one text. + +use ide_core::{Pos, Range}; + +pub(crate) struct Lines<'a> { + text: &'a str, + starts: Vec, +} + +impl<'a> Lines<'a> { + pub(crate) fn new(text: &'a str) -> Self { + let mut starts = vec![0]; + starts.extend(text.bytes().enumerate().filter(|&(_, b)| b == b'\n').map(|(i, _)| i + 1)); + Self { text, starts } + } + + pub(crate) fn line_of(&self, byte: usize) -> usize { + self.starts.partition_point(|&s| s <= byte.min(self.text.len())) - 1 + } + + pub(crate) fn pos(&self, byte: usize) -> Pos { + let mut byte = byte.min(self.text.len()); + while !self.text.is_char_boundary(byte) { + byte -= 1; + } + let line = self.line_of(byte); + let col: usize = self.text[self.starts[line]..byte].chars().map(char::len_utf16).sum(); + Pos::new(line as u32, col as u32) + } + + pub(crate) fn range(&self, start: usize, end: usize) -> Range { + Range::new(self.pos(start), self.pos(end)) + } + + /// Text of the line containing `byte`, without the line break. + pub(crate) fn line_text(&self, byte: usize) -> &'a str { + let line = self.line_of(byte); + let start = self.starts[line]; + let end = self.starts.get(line + 1).map_or(self.text.len(), |&s| s - 1); + self.text[start..end].trim_end_matches('\r') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utf16_columns_and_lines() { + let l = Lines::new("ab\nx😀y\r\nz"); + assert_eq!(l.pos(0), Pos::new(0, 0)); + assert_eq!(l.pos(3), Pos::new(1, 0)); + assert_eq!(l.pos(8), Pos::new(1, 3)); + assert_eq!(l.line_text(4), "x😀y"); + assert_eq!(l.pos(100), Pos::new(2, 1)); + } +} diff --git a/crates/ide-analysis/src/structural.rs b/crates/ide-analysis/src/structural.rs new file mode 100644 index 00000000..cf3f3a7b --- /dev/null +++ b/crates/ide-analysis/src/structural.rs @@ -0,0 +1,354 @@ +//! Search / Replace Structurally: a code pattern with placeholders, matched +//! against syntax trees rather than text, so formatting, comments and +//! nesting do not matter. +//! +//! Placeholders: IDEA's `$name$` (one node), ast-grep's `$NAME` (one node), +//! `$$$NAME` (any number of nodes, e.g. arguments) and `$_` (one node, not +//! captured). A placeholder used twice must match equal code. The replacement +//! template refers to the same placeholders. + +use crate::lines::Lines; +use crate::{Loader, SourceFile}; +use ast_grep_core::matcher::PatternBuilder; +use ast_grep_core::tree_sitter::{LanguageExt, StrDoc, TSLanguage}; +use ast_grep_core::{Language, Pattern, PatternError}; +use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath, LanguageId, Range}; +use ide_language::Syntax; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::borrow::Cow; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::Relaxed}; +use std::time::Instant; + +/// Matches reported at most (0 in the query). +const DEFAULT_MAX_MATCHES: u32 = 5_000; +/// Matched text sent per match (the editor shows the rest). +const MAX_MATCH_TEXT: usize = 4 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StructuralQuery { + pub pattern: String, + /// Only files of this language are searched. + pub language: LanguageId, + /// Replacement template; matches then carry their replacement text. + pub replacement: Option, + /// Stop after this many matches (0 = 5 000). + pub max_matches: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StructuralMatch { + pub range: Range, + /// The matched code (capped). + pub text: String, + /// The first line of the match, for result rows. + pub line_text: String, + pub replacement: Option, +} + +/// All matches in one file; streamed as files complete. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StructuralFile { + pub path: IpcPath, + pub rel_path: String, + pub matches: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StructuralSummary { + pub files: u32, + pub matches: u32, + pub limit_hit: bool, + pub cancelled: bool, + pub elapsed_ms: u32, +} + +/// A bundled grammar as an ast-grep language. +#[derive(Clone)] +struct Lang { + grammar: &'static str, + ts: TSLanguage, +} + +impl Lang { + fn new(syntax: &Syntax, language: &LanguageId) -> IdeResult { + let (grammar, ts) = syntax + .ts_language(language) + .ok_or_else(|| IdeError::invalid(format!("no syntax tree support for {language}")))?; + Ok(Self { grammar, ts }) + } +} + +impl Language for Lang { + fn kind_to_id(&self, kind: &str) -> u16 { + self.ts.id_for_node_kind(kind, true) + } + + fn field_to_id(&self, field: &str) -> Option { + self.ts.field_id_for_name(field).map(|f| f.get()) + } + + /// Placeholders are spelled with a character the grammar accepts in + /// identifiers, as ast-grep does for its own languages. + fn expando_char(&self) -> char { + match self.grammar { + "javascript" | "typescript" | "tsx" | "java" | "bash" | "json" | "lua" | "scala" | "yaml" => '$', + "c" | "cpp" => '\u{10000}', + "css" | "nix" => '_', + _ => 'µ', + } + } + + fn pre_process_pattern<'q>(&self, query: &'q str) -> Cow<'q, str> { + let expando = self.expando_char(); + if expando == '$' { + return Cow::Borrowed(query); + } + // `$A`, `$$$A`, `$_` and `$$$` become expando-spelled; other `$` stay. + let mut out = String::with_capacity(query.len()); + let mut dollars = 0; + for c in query.chars() { + if c == '$' { + dollars += 1; + continue; + } + let sigil = if c.is_ascii_uppercase() || c == '_' || dollars == 3 { expando } else { '$' }; + out.extend(std::iter::repeat_n(sigil, dollars)); + dollars = 0; + out.push(c); + } + out.extend(std::iter::repeat_n(if dollars == 3 { expando } else { '$' }, dollars)); + Cow::Owned(out) + } + + fn build_pattern(&self, builder: &PatternBuilder) -> Result { + builder.build(|src| StrDoc::try_new(src, self.clone())) + } +} + +impl LanguageExt for Lang { + fn get_ts_language(&self) -> TSLanguage { + self.ts.clone() + } +} + +/// Rewrites IDEA-style `$name$` placeholders to ast-grep's `$NAME`. +pub fn normalize_placeholders(src: &str) -> String { + let chars: Vec = src.chars().collect(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < chars.len() { + if chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_alphabetic() || *c == '_') { + let mut j = i + 1; + while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { + j += 1; + } + if chars.get(j) == Some(&'$') { + out.push('$'); + out.extend(chars[i + 1..j].iter().map(char::to_ascii_uppercase)); + i = j + 1; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +fn compile(syntax: &Syntax, query: &StructuralQuery) -> IdeResult<(Lang, Pattern)> { + let lang = Lang::new(syntax, &query.language)?; + let source = normalize_placeholders(query.pattern.trim()); + if source.is_empty() { + return Err(IdeError::invalid("the search template is empty")); + } + let pattern = Pattern::try_new(&source, lang.clone()).map_err(|e| IdeError::invalid(pattern_error(&e)))?; + Ok((lang, pattern)) +} + +fn pattern_error(e: &PatternError) -> String { + match e { + PatternError::Parse(_) => "the search template is not valid code in this language".into(), + PatternError::NoContent(_) => "the search template contains no code".into(), + PatternError::MultipleNode(_) => { + "the search template must be a single statement or expression (use $$$ for sequences)".into() + } + other => other.to_string(), + } +} + +/// Checks the template while the user types. +pub fn validate_structural(syntax: &Syntax, query: &StructuralQuery) -> IdeResult<()> { + compile(syntax, query).map(|_| ()) +} + +/// Matches of `query` in one text. +fn search_text(lang: &Lang, pattern: &Pattern, replacement: Option<&str>, text: &str) -> Vec { + let root = lang.ast_grep(text); + let lines = Lines::new(text); + root.root() + .find_all(pattern) + .map(|m| { + let range = m.range(); + let mut cap = range.end.min(range.start + MAX_MATCH_TEXT); + while !text.is_char_boundary(cap) { + cap -= 1; + } + let replacement = replacement.map(|r| { + let edit = m.replace_by(r); + String::from_utf8_lossy(&edit.inserted_text).into_owned() + }); + StructuralMatch { + range: lines.range(range.start, range.end), + text: text[range.start..cap].to_owned(), + line_text: lines.line_text(range.start).to_owned(), + replacement, + } + }) + .collect() +} + +/// Searches `files` (those of the query's language) in parallel, calling +/// `on_file` for each file with matches. +pub fn search_structurally( + syntax: &Syntax, + files: &[SourceFile], + load: Loader<'_>, + query: &StructuralQuery, + cancel: &CancellationToken, + on_file: &(dyn Fn(StructuralFile) + Sync), +) -> IdeResult { + let started = Instant::now(); + let (lang, pattern) = compile(syntax, query)?; + let replacement = query.replacement.as_deref().map(normalize_placeholders); + let max = if query.max_matches == 0 { DEFAULT_MAX_MATCHES } else { query.max_matches }; + let found = AtomicU32::new(0); + let file_count = AtomicU32::new(0); + let limit_hit = AtomicBool::new(false); + files.par_iter().for_each(|file| { + if cancel.is_cancelled() || limit_hit.load(Relaxed) { + return; + } + let Some((language, text)) = load(&file.path) else { return }; + if syntax.grammar_name(&language) != Some(lang.grammar) { + return; + } + let mut matches = search_text(&lang, &pattern, replacement.as_deref(), &text); + if matches.is_empty() { + return; + } + let before = found.fetch_add(matches.len() as u32, Relaxed); + if before >= max { + limit_hit.store(true, Relaxed); + return; + } + if before + matches.len() as u32 > max { + matches.truncate((max - before) as usize); + limit_hit.store(true, Relaxed); + } + file_count.fetch_add(1, Relaxed); + on_file(StructuralFile { path: IpcPath(file.path.clone()), rel_path: file.rel_path.clone(), matches }); + }); + Ok(StructuralSummary { + files: file_count.load(Relaxed), + matches: found.load(Relaxed).min(max), + limit_hit: limit_hit.load(Relaxed), + cancelled: cancel.is_cancelled(), + elapsed_ms: u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + + fn query(lang: &str, pattern: &str, replacement: Option<&str>) -> StructuralQuery { + StructuralQuery { + pattern: pattern.into(), + language: LanguageId::new(lang), + replacement: replacement.map(Into::into), + max_matches: 0, + } + } + + fn matches(lang: &str, text: &str, pattern: &str, replacement: Option<&str>) -> Vec { + let syntax = Syntax::new(); + let (l, p) = compile(&syntax, &query(lang, pattern, replacement)).unwrap(); + let replacement = replacement.map(normalize_placeholders); + search_text(&l, &p, replacement.as_deref(), text) + } + + #[test] + fn idea_placeholders_become_metavariables() { + assert_eq!(normalize_placeholders("$a$.equals($b$)"), "$A.equals($B)"); + assert_eq!(normalize_placeholders("foo($$$ARGS)"), "foo($$$ARGS)"); + assert_eq!(normalize_placeholders("let $x = 1; \"$5\""), "let $x = 1; \"$5\""); + assert_eq!(normalize_placeholders("$Expr$ + $_"), "$EXPR + $_"); + } + + #[test] + fn matches_ignore_formatting_and_respect_repeated_placeholders() { + let ts = "if (a == a) {}\nif (a ==\n b) {}\nconst x = f(1, 2, 3);\n"; + let m = matches("typescript", ts, "$x$ == $x$", None); + assert_eq!(m.len(), 1); + assert_eq!(m[0].text, "a == a"); + assert_eq!(m[0].range.start.line, 0); + let m = matches("typescript", ts, "$a$ == $b$", None); + assert_eq!(m.len(), 2); + assert_eq!(m[1].range.end.line, 2); + assert_eq!(m[1].line_text, "if (a =="); + let m = matches("typescript", ts, "f($$$ARGS)", None); + assert_eq!(m.len(), 1); + } + + #[test] + fn languages_without_dollar_identifiers_work() { + let rust = "fn main() {\n let v = x.unwrap();\n let w = y.unwrap();\n}\n"; + let m = matches("rust", rust, "$e$.unwrap()", Some("$e$.expect(\"todo\")")); + assert_eq!(m.len(), 2); + assert_eq!(m[0].replacement.as_deref(), Some("x.expect(\"todo\")")); + let py = "print(a)\nprint(b, c)\n"; + assert_eq!(matches("python", py, "print($A)", None).len(), 1); + } + + #[test] + fn invalid_templates_and_languages_are_reported() { + let syntax = Syntax::new(); + assert!(validate_structural(&syntax, &query("typescript", "", None)).is_err()); + assert!(validate_structural(&syntax, &query("no-such-language", "a", None)).is_err()); + assert!(validate_structural(&syntax, &query("typescript", "foo($a$)", None)).is_ok()); + } + + #[test] + fn searches_files_of_the_language_with_a_limit() { + let syntax = Syntax::new(); + let files: Vec = ["a.ts", "b.ts", "c.py"] + .iter() + .map(|n| SourceFile { path: PathBuf::from(n), rel_path: (*n).into() }) + .collect(); + let load = |p: &Path| { + Some(match p.to_str().unwrap() { + "c.py" => (LanguageId::new("python"), "log(1)\n".to_owned()), + _ => (LanguageId::new("typescript"), "log(1);\nlog(2);\n".to_owned()), + }) + }; + let out = Mutex::new(Vec::new()); + let q = StructuralQuery { max_matches: 3, ..query("typescript", "log($v$)", None) }; + let s = search_structurally(&syntax, &files, &load, &q, &CancellationToken::new(), &|f| { + out.lock().unwrap().push(f) + }) + .unwrap(); + assert_eq!(s.matches, 3); + assert!(s.limit_hit); + let out = out.into_inner().unwrap(); + assert!(out.iter().all(|f| f.rel_path.ends_with(".ts"))); + assert_eq!(out.iter().map(|f| f.matches.len()).sum::(), 3); + } +} diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml new file mode 100644 index 00000000..eff2f248 --- /dev/null +++ b/crates/ide-build/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ide-build" +description = "Build tool models for the Maven and Gradle tool windows: POM parsing, Gradle task lists, background sync" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +dunce.workspace = true +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tracing.workspace = true +dirs.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-build/examples/model.rs b/crates/ide-build/examples/model.rs new file mode 100644 index 00000000..327238ad --- /dev/null +++ b/crates/ide-build/examples/model.rs @@ -0,0 +1,25 @@ +//! Prints the build model of a folder, optionally after a sync: +//! `cargo run -p ide-build --example model -- [cache-dir] [maven|gradle]`. + +fn main() { + let mut args = std::env::args().skip(1); + let root = std::path::PathBuf::from(args.next().unwrap_or_else(|| ".".into())); + let cache = + args.next().map(std::path::PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("ide-build-model")); + let cancel = ide_core::CancellationToken::new(); + let progress = |p: ide_build::SyncProgress| eprintln!(" {} {:?}", p.message, p.fraction); + let options = ide_build::SyncOptions::default(); + let started = std::time::Instant::now(); + let synced = match args.next().as_deref() { + Some("maven") => Some(ide_build::sync_maven(&root, &cache, &options, &cancel, &progress)), + Some("gradle") => Some(ide_build::sync_gradle(&root, &cache, &options, &cancel, &progress)), + _ => None, + }; + if let Some(r) = synced { + eprintln!("synced in {:?}: {r:?}", started.elapsed()); + } + let started = std::time::Instant::now(); + let model = ide_build::load(&root, &cache); + println!("{}", serde_json::to_string_pretty(&model).unwrap()); + eprintln!("loaded in {:?}", started.elapsed()); +} diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs new file mode 100644 index 00000000..a79466d4 --- /dev/null +++ b/crates/ide-build/src/compile/mod.rs @@ -0,0 +1,839 @@ +//! Build › Build Project / Rebuild / Build Module / Build Artifacts: picks the +//! build system of the project (or of the file's module), runs it in the +//! background with cancellation and parses the compiler output into messages +//! for the Build tool window, the Problems view and the editor. + +mod parse; + +use crate::process::{self, Stream}; +use crate::{BuildModel, maven}; +use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity}; +use parse::{ColUnit, Format, OutputParser, RawMessage}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +/// Diagnostics source of build messages in the Problems view and the editor. +pub const DIAGNOSTICS_SOURCE: &str = "build"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum BuildKind { + /// Incremental compile of sources and tests. + Build, + /// Clean, then build. + Rebuild, + /// Package the outputs (`package`, `assemble`, `cargo build --release`, `npm run build`). + Artifacts, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum BuildSystem { + Maven, + Gradle, + Cargo, + Go, + #[serde(rename = "typescript")] + TypeScript, +} + +/// One command of a build. +#[derive(Clone, Debug, PartialEq)] +pub struct BuildStep { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: BTreeMap, + format: Format, +} + +impl BuildStep { + fn new(program: impl Into, args: &[&str], cwd: &Path, format: Format) -> Self { + BuildStep { + program: program.into(), + args: args.iter().map(|a| (*a).to_owned()).collect(), + cwd: cwd.to_path_buf(), + env: BTreeMap::new(), + format, + } + } + + /// The command line as echoed in the console. + pub fn display(&self) -> String { + let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into()); + std::iter::once(program) + .chain(self.args.iter().map(|a| if a.contains(' ') { format!("\"{a}\"") } else { a.clone() })) + .collect::>() + .join(" ") + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BuildPlan { + pub system: BuildSystem, + /// Tree root title: `demo` / `Build module 'core'`. + pub title: String, + pub steps: Vec, +} + +/// A compiler message in IDE coordinates (0-based line, UTF-16 column). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BuildMessage { + pub severity: Severity, + pub text: String, + /// Absolute path; `None` for messages about the build itself. + pub path: Option, + pub range: Option, + pub code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum BuildEvent { + /// The build starts: its tree root. + Started { + system: BuildSystem, + title: String, + }, + /// A command starts (echoed in the console). + Step { + command: String, + cwd: ide_core::IpcPath, + }, + /// Console output (one or more lines, each ending in `\n`). + Output { + text: String, + stderr: bool, + }, + Message { + message: BuildMessage, + }, + /// Status bar progress; `fraction` 0..1 or `None` when indeterminate. + Progress { + message: String, + fraction: Option, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BuildResult { + pub system: BuildSystem, + pub title: String, + pub success: bool, + pub errors: u32, + pub warnings: u32, + pub duration_ms: f64, + pub messages: Vec, +} + +// ---------------------------------------------------------------- planning + +fn has(dir: &Path, names: &[&str]) -> bool { + names.iter().any(|n| dir.join(n).is_file()) +} + +fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option { + let rel = rel_str(root, dir); + if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) { + Some(BuildSystem::Maven) + } else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) { + Some(BuildSystem::Gradle) + } else if has(dir, &["Cargo.toml"]) { + Some(BuildSystem::Cargo) + } else if has(dir, &["go.mod"]) { + Some(BuildSystem::Go) + } else if has(dir, &["tsconfig.json", "package.json"]) { + Some(BuildSystem::TypeScript) + } else { + None + } +} + +/// `root/rel`, without a trailing separator for the root itself. +fn subdir(root: &Path, rel: &str) -> PathBuf { + if rel.is_empty() { root.to_path_buf() } else { root.join(rel) } +} + +fn rel_str(root: &Path, dir: &Path) -> String { + dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default() +} + +/// Directories from `file`'s folder up to `root` (inclusive). +fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator { + let start = if file.is_dir() { Some(file) } else { file.parent() }; + start.into_iter().flat_map(Path::ancestors).take_while(move |d| d.starts_with(root)) +} + +/// The build of `root`, or of the module containing `file` (Build Module / Recompile). +pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path>) -> IdeResult { + let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned()); + let module_dir = match file { + Some(f) => { + let dir = ancestors_within(root, f) + .find(|d| detect_dir(d, model, root).is_some()) + .ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?; + Some(dir.to_path_buf()) + } + None => None, + }; + let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); + let system = detect_dir(&dir, model, root) + .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; + let module = module_dir.as_deref().filter(|d| *d != root); + let title = match module { + Some(d) => format!( + "{}: build module '{}'", + project_name, + d.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned()) + ), + None => project_name, + }; + let steps = match system { + BuildSystem::Maven => maven_steps(root, model, kind, module_dir.as_deref())?, + BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?, + BuildSystem::Cargo => cargo_steps(&dir, kind), + // Go builds the file's package (a module holds many). + BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind), + BuildSystem::TypeScript => ts_steps(&dir, kind), + }; + Ok(BuildPlan { system, title, steps }) +} + +fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult> { + let fallback; + let m = match &model.maven { + Some(m) => m, + None => { + fallback = maven::load(root); + &fallback + } + }; + let goals: &[&str] = match kind { + BuildKind::Build => &["test-compile"], + BuildKind::Rebuild => &["clean", "test-compile"], + BuildKind::Artifacts => &["package", "-DskipTests"], + }; + let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect(); + if tops.is_empty() { + return Err(IdeError::not_found("no Maven project found")); + } + 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(extra.iter().cloned()); + s.args.extend(goals.iter().map(|g| (*g).to_owned())); + s.env = p.env.clone(); + s + }; + let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) }; + // The reactor root containing the module builds it with what it depends on (`-pl … -am`). + let rel = rel_str(root, module); + let top = tops + .iter() + .filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir))) + .max_by_key(|t| t.dir.len()) + .copied() + .unwrap_or(tops[0]); + let top_dir = subdir(root, &top.dir); + if module == top_dir { + return Ok(vec![step(top, &[])]); + } + let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or(rel); + Ok(vec![step(top, &["-pl".into(), pl, "-am".into()])]) +} + +fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult> { + let fallback; + let g = match &model.gradle { + Some(g) => g, + None => { + fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?; + &fallback + } + }; + let prefix = module + .and_then(|m| { + let rel = rel_str(root, m); + g.projects.iter().filter(|p| p.dir == rel).map(|p| p.path.clone()).next() + }) + .filter(|p| p != ":") + .map(|p| format!("{p}:")) + .unwrap_or_default(); + let tasks: Vec = match kind { + BuildKind::Build => vec![format!("{prefix}classes"), format!("{prefix}testClasses")], + BuildKind::Rebuild => { + vec![format!("{prefix}clean"), format!("{prefix}classes"), format!("{prefix}testClasses")] + } + BuildKind::Artifacts => vec![format!("{prefix}assemble")], + }; + let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle); + s.args.extend(tasks); + s.env = g.env.clone(); + Ok(vec![s]) +} + +fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec { + let build = |release: bool| { + let mut args = vec!["build", "--all-targets", "--message-format=json"]; + if release { + args = vec!["build", "--release", "--message-format=json"]; + } + args.extend(["--manifest-path", "Cargo.toml"]); + BuildStep::new("cargo", &args, dir, Format::Cargo) + }; + match kind { + BuildKind::Build => vec![build(false)], + BuildKind::Rebuild => { + vec![BuildStep::new("cargo", &["clean", "--manifest-path", "Cargo.toml"], dir, Format::Cargo), build(false)] + } + BuildKind::Artifacts => vec![build(true)], + } +} + +fn go_steps(dir: &Path, kind: BuildKind) -> Vec { + let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir); + let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) }; + let mut args = vec!["build"]; + if kind == BuildKind::Rebuild { + args.push("-a"); + } + let mut s = BuildStep::new("go", &args, module_root, Format::Go); + s.args.push(target); + vec![s] +} + +/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it. +fn package_manager(dir: &Path) -> &'static str { + for d in dir.ancestors() { + if d.join("pnpm-lock.yaml").is_file() { + return "pnpm"; + } else if d.join("yarn.lock").is_file() { + return "yarn"; + } else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() { + return "bun"; + } else if d.join("package-lock.json").is_file() { + return "npm"; + } + } + "npm" +} + +fn has_build_script(dir: &Path) -> bool { + std::fs::read(dir.join("package.json")) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string)) +} + +fn ts_steps(dir: &Path, kind: BuildKind) -> Vec { + let script = has_build_script(dir); + if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) { + return vec![BuildStep::new(package_manager(dir), &["run", "build"], dir, Format::Tsc)]; + } + let local = dir.ancestors().map(|d| d.join("node_modules/.bin/tsc")).find(|p| p.is_file()); + let program = local.map_or_else(|| "tsc".to_owned(), |p| p.to_string_lossy().into_owned()); + vec![BuildStep::new(program, &["--noEmit", "--pretty", "false", "-p", "."], dir, Format::Tsc)] +} + +// ---------------------------------------------------------------- running + +/// Project paths as the UI knows them: compilers may print the canonical path +/// (`/private/tmp/…` for `/tmp/…`). +struct PathMap { + root: PathBuf, + canonical: Option, +} + +impl PathMap { + fn new(root: &Path) -> Self { + let canonical = dunce::canonicalize(root).ok().filter(|c| c != root); + PathMap { root: root.to_path_buf(), canonical } + } + + fn resolve(&self, printed: &str, cwd: &Path, base: Option<&str>) -> PathBuf { + let p = Path::new(printed); + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + let in_base = base.map(|b| Path::new(b).join(p)).filter(|c| c.exists()); + in_base.unwrap_or_else(|| cwd.join(p)) + }; + let abs = normalize(&abs); + match &self.canonical { + Some(c) => abs.strip_prefix(c).map(|r| self.root.join(r)).unwrap_or(abs), + None => abs, + } + } +} + +/// Removes `.` and resolvable `..` components without touching the file system. +fn normalize(p: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for c in p.components() { + match c { + Component::CurDir => {} + Component::ParentDir if out.file_name().is_some() => { + out.pop(); + } + other => out.push(other), + } + } + out +} + +/// Line texts of files, for converting printed columns to UTF-16. +#[derive(Default)] +struct Lines { + files: HashMap>>, +} + +impl Lines { + fn line(&mut self, path: &Path, line: u32) -> Option<&str> { + let lines = self.files.entry(path.to_path_buf()).or_insert_with(|| { + std::fs::read(path).ok().map(|b| String::from_utf8_lossy(&b).lines().map(str::to_owned).collect()) + }); + lines.as_ref()?.get(line as usize).map(String::as_str) + } +} + +/// 1-based column in `unit` → 0-based UTF-16 column of `text`. +fn utf16_col(text: Option<&str>, col: u32, unit: ColUnit) -> u32 { + let n = col.saturating_sub(1); + let Some(text) = text else { return n }; + match unit { + ColUnit::Utf16 => n, + ColUnit::Char => { + text.chars().take(n as usize).map(|c| c.len_utf16() as u32).sum::() + + n.saturating_sub(text.chars().count() as u32) + } + ColUnit::Byte => { + let mut b = n as usize; + while b > 0 && b < text.len() && !text.is_char_boundary(b) { + b -= 1; + } + let b = b.min(text.len()); + text[..b].encode_utf16().count() as u32 + (n as usize).saturating_sub(text.len()) as u32 + } + } +} + +/// UTF-16 length of the identifier starting at UTF-16 column `col` of `text`. +fn identifier_len(text: &str, col: u32) -> u32 { + let mut at = 0; + let mut len = 0; + for c in text.chars() { + if at >= col { + if !(c.is_alphanumeric() || c == '_' || c == '$') { + break; + } + len += c.len_utf16() as u32; + } + at += c.len_utf16() as u32; + } + len +} + +fn resolve(raw: RawMessage, cwd: &Path, paths: &PathMap, lines: &mut Lines) -> BuildMessage { + let path = raw.path.as_deref().map(|p| paths.resolve(p, cwd, raw.base.as_deref())); + let range = match (&path, raw.line) { + (Some(p), Some(line)) => { + let l = line - 1; + let start_col = raw.col.map_or(0, |c| utf16_col(lines.line(p, l), c, raw.unit)); + let end = match (raw.end_line, raw.end_col) { + (Some(el), Some(ec)) if el >= line => { + let el = el - 1; + Pos::new(el, utf16_col(lines.line(p, el), ec, raw.unit)) + } + // A point: underline the identifier there, like the compilers' carets. + _ => { + let word = lines.line(p, l).map_or(0, |t| identifier_len(t, start_col)); + Pos::new(l, start_col + word) + } + }; + Some(Range::new(Pos::new(l, start_col), end)) + } + _ => None, + }; + BuildMessage { severity: raw.severity, text: raw.text, path: path.map(Into::into), range, code: raw.code } +} + +fn progress_of(line: &str) -> Option<(String, Option)> { + let line = line.trim(); + if let Some((name, i, total)) = crate::sync::maven_building(line) { + return Some((format!("Building {name}"), Some((i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0)))); + } + if let Some(task) = line.strip_prefix("> Task ") { + return Some((format!("Running {}", task.split_whitespace().next().unwrap_or(task)), None)); + } + if let Some(krate) = line.strip_prefix("Compiling ") { + return Some((format!("Compiling {}", krate.split_whitespace().next().unwrap_or(krate)), None)); + } + None +} + +/// Runs `plan`, streaming console output, messages and progress to `on_event`. +/// Returns `cancelled` when `cancel` fires (the processes are killed). +pub fn run( + root: &Path, + plan: &BuildPlan, + cancel: &CancellationToken, + on_event: &(dyn Fn(BuildEvent) + Sync), +) -> IdeResult { + let started = Instant::now(); + on_event(BuildEvent::Started { system: plan.system, title: plan.title.clone() }); + let paths = PathMap::new(root); + let mut lines = Lines::default(); + let mut messages: Vec = Vec::new(); + let mut seen: HashSet<(Option, Option, String)> = HashSet::new(); + let mut success = true; + let mut last_error_line = String::new(); + for step in &plan.steps { + let command = step.display(); + on_event(BuildEvent::Step { command: command.clone(), cwd: step.cwd.clone().into() }); + on_event(BuildEvent::Progress { message: command.clone(), fraction: None }); + let mut parser = OutputParser::new(step.format); + let mut emit = |raws: Vec, messages: &mut Vec| { + for raw in raws { + let m = resolve(raw, &step.cwd, &paths, &mut lines); + if seen.insert((m.path.clone().map(Into::into), m.range, m.text.clone())) { + on_event(BuildEvent::Message { message: m.clone() }); + messages.push(m); + } + } + }; + let out = process::run(&step.program, &step.args, &step.cwd, &step.env, cancel, |stream, line| { + let parsed = parser.feed(line); + match parsed.console { + Some(text) if text.is_empty() => {} + Some(text) => on_event(BuildEvent::Output { text, stderr: stream == Stream::Stderr }), + None => { + on_event(BuildEvent::Output { text: format!("{line}\n"), stderr: stream == Stream::Stderr }); + if let Some((message, fraction)) = progress_of(line) { + on_event(BuildEvent::Progress { message, fraction }); + } + } + } + let stripped = parse::strip_ansi(line); + if !stripped.trim().is_empty() && !stripped.starts_with('{') { + last_error_line = stripped; + } + emit(parsed.messages, &mut messages); + }); + let out = match out { + Ok(out) => out, + Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e), + Err(e) => { + let m = BuildMessage { + severity: Severity::Error, + text: e.message.clone(), + path: None, + range: None, + code: None, + }; + on_event(BuildEvent::Output { text: format!("{}\n", e.message), stderr: true }); + on_event(BuildEvent::Message { message: m.clone() }); + messages.push(m); + success = false; + break; + } + }; + emit(parser.finish(), &mut messages); + let code = if out.success { "0" } else { "non-zero" }; + on_event(BuildEvent::Output { text: format!("\nProcess finished with exit code {code}\n"), stderr: false }); + if !out.success { + success = false; + if !messages.iter().any(|m| m.severity == Severity::Error) { + let summary = crate::sync::error_summary(&out.text); + let text = if summary.is_empty() { last_error_line.clone() } else { summary }; + let m = BuildMessage { severity: Severity::Error, text, path: None, range: None, code: None }; + on_event(BuildEvent::Message { message: m.clone() }); + messages.push(m); + } + break; + } + } + let count = |s: Severity| messages.iter().filter(|m| m.severity == s).count() as u32; + Ok(BuildResult { + system: plan.system, + title: plan.title.clone(), + success, + errors: count(Severity::Error), + warnings: count(Severity::Warning), + duration_ms: started.elapsed().as_secs_f64() * 1000.0, + messages, + }) +} + +/// Per-file editor / Problems diagnostics of the messages that have a position. +pub fn diagnostics(messages: &[BuildMessage]) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for m in messages { + let (Some(path), Some(range)) = (&m.path, m.range) else { continue }; + out.entry(path.to_path_buf()).or_default().push(Diagnostic { + range, + severity: m.severity, + message: m.text.clone(), + source: Some(DIAGNOSTICS_SOURCE.to_owned()), + code: m.code.clone(), + unnecessary: false, + deprecated: false, + }); + } + out +} + +// ---------------------------------------------------------------- JVM launches of non-Java modules + +/// Whether the JVM module at `module` has Kotlin, Groovy or Scala sources, which jdtls does not compile. +pub fn has_non_java_sources(module: &Path) -> bool { + fn walk(dir: &Path, depth: u32) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { return false }; + entries.flatten().any(|e| { + let p = e.path(); + if p.is_dir() { + depth > 0 && walk(&p, depth - 1) + } else { + p.extension().is_some_and(|x| x == "kt" || x == "groovy" || x == "scala") + } + }) + } + walk(&module.join("src"), 12) +} + +/// Compiles the module at `module` with its build system before a JVM launch and +/// returns the class directories it wrote, to put ahead of the jdtls classpath. +pub fn build_for_launch( + root: &Path, + model: &BuildModel, + module: &Path, + test: bool, + cancel: &CancellationToken, +) -> IdeResult> { + let plan = plan(root, model, BuildKind::Build, Some(module))?; + let result = run(root, &plan, cancel, &|_| {})?; + if !result.success { + let errors: Vec = result + .messages + .iter() + .filter(|m| m.severity == Severity::Error) + .take(5) + .map(|m| match (&m.path, m.range) { + (Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text), + _ => m.text.clone(), + }) + .collect(); + return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n")))); + } + Ok(launch_class_dirs(plan.system, module, test)) +} + +fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec { + let sets: &[&str] = if test { &["test", "main"] } else { &["main"] }; + let dirs: Vec = match system { + BuildSystem::Gradle => sets + .iter() + .flat_map(|s| { + ["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"] + .map(|d| module.join("build").join(d).join(s)) + }) + .collect(), + BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")], + BuildSystem::Maven => vec![module.join("target/classes")], + _ => Vec::new(), + }; + dirs.into_iter().filter(|d| d.is_dir()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, text: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + + #[test] + fn plans_per_build_system() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("demo"); + write( + &root, + "pom.xml", + "democore", + ); + write(&root, "core/pom.xml", "core"); + write(&root, "core/src/main/java/A.java", "class A {}"); + let model = crate::load(&root, &tmp.path().join("cache")); + let p = plan(&root, &model, BuildKind::Build, None).unwrap(); + assert_eq!(p.system, BuildSystem::Maven); + assert_eq!(p.title, "demo"); + assert_eq!(p.steps.len(), 1); + assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]); + let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap(); + assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]); + assert_eq!(p.steps[0].cwd, root); + assert_eq!(p.title, "demo: build module 'core'"); + + 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 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"]); + assert_eq!( + plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args, + ["--console=plain", "assemble"] + ); + + let c = tmp.path().join("c"); + write(&c, "Cargo.toml", "[package]\nname='c'"); + let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap(); + assert_eq!(p.system, BuildSystem::Cargo); + assert_eq!(p.steps.len(), 2); + assert_eq!(p.steps[0].args[0], "clean"); + + let go = tmp.path().join("go"); + write(&go, "go.mod", "module x"); + let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap(); + assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]); + assert_eq!(p.steps[0].cwd, go); + + let ts = tmp.path().join("ts"); + write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#); + write(&ts, "tsconfig.json", "{}"); + write(&ts, "pnpm-lock.yaml", ""); + let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap(); + assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]); + let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap(); + assert_eq!( + (p.steps[0].program.as_str(), p.steps[0].args.as_slice()), + ("pnpm", &["run".to_owned(), "build".to_owned()][..]) + ); + + let none = tmp.path().join("none"); + std::fs::create_dir_all(&none).unwrap(); + assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); + } + + #[test] + fn columns_become_utf16() { + assert_eq!(utf16_col(Some("a😀b"), 3, ColUnit::Char), 3); + assert_eq!(utf16_col(Some("a😀b"), 6, ColUnit::Byte), 3); + assert_eq!(utf16_col(Some("a😀b"), 4, ColUnit::Utf16), 3); + assert_eq!(utf16_col(None, 5, ColUnit::Char), 4); + assert_eq!(utf16_col(Some("ab"), 5, ColUnit::Char), 4); + assert_eq!(identifier_len("x 😀ab_1 c", 2), 0); + assert_eq!(identifier_len("x é_1 c", 2), 3); + } + + #[test] + fn resolves_paths_against_cwd_and_canonical_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + write(&root, "src/a.ts", "let é = 1;\nlet x: number = 'a';\n"); + let map = PathMap { root: root.clone(), canonical: Some(PathBuf::from("/canon/p")) }; + assert_eq!(map.resolve("/canon/p/src/a.ts", &root, None), root.join("src/a.ts")); + assert_eq!(map.resolve("./src/../src/a.ts", &root, None), root.join("src/a.ts")); + let mut lines = Lines::default(); + let raw = RawMessage { + severity: Severity::Error, + text: "bad".into(), + path: Some("src/a.ts".into()), + line: Some(2), + col: Some(5), + end_line: None, + end_col: None, + unit: ColUnit::Utf16, + code: Some("TS2322".into()), + base: None, + }; + let m = resolve(raw, &root, &map, &mut lines); + assert_eq!(m.path.as_deref(), Some(root.join("src/a.ts").as_path())); + assert_eq!(m.range, Some(Range::new(Pos::new(1, 4), Pos::new(1, 5))), "the identifier at the column"); + let d = diagnostics(&[m]); + assert_eq!(d[&root.join("src/a.ts")][0].source.as_deref(), Some("build")); + } + + #[test] + fn runs_a_failing_build_and_reports_messages() { + if cfg!(windows) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + write(&root, "main.go", "package main\n"); + let script = "echo '# demo'; echo './main.go:1:9: undefined: x' >&2; exit 1"; + let mut step = BuildStep::new("sh", &["-c", script], &root, Format::Go); + step.env.insert("A".into(), "1".into()); + let plan = BuildPlan { system: BuildSystem::Go, title: "p".into(), steps: vec![step] }; + let events = parking_lot_free::Events::default(); + let result = run(&root, &plan, &CancellationToken::new(), &|e| events.push(e)).unwrap(); + assert!(!result.success); + assert_eq!((result.errors, result.warnings), (1, 0)); + let m = &result.messages[0]; + assert_eq!(m.path.as_deref(), Some(root.join("main.go").as_path())); + assert_eq!(m.range, Some(Range::new(Pos::new(0, 8), Pos::new(0, 12)))); + let kinds = events.kinds(); + assert_eq!(kinds.first().map(String::as_str), Some("started")); + assert!(kinds.iter().any(|k| k == "message") && kinds.iter().any(|k| k == "output")); + + let cancel = CancellationToken::new(); + cancel.cancel(); + let slow = BuildPlan { + system: BuildSystem::Go, + title: "p".into(), + steps: vec![BuildStep::new("sh", &["-c", "sleep 5"], &root, Format::Go)], + }; + assert_eq!(run(&root, &slow, &cancel, &|_| {}).err().unwrap().kind, ide_core::error::ErrorKind::Cancelled); + } + + mod parking_lot_free { + use super::super::BuildEvent; + use std::sync::Mutex; + + #[derive(Default)] + pub struct Events(Mutex>); + + impl Events { + pub fn push(&self, e: BuildEvent) { + let kind = serde_json::to_value(&e).unwrap()["kind"].as_str().unwrap().to_owned(); + self.0.lock().unwrap().push(kind); + } + pub fn kinds(&self) -> Vec { + self.0.lock().unwrap().clone() + } + } + } + + #[test] + fn non_java_launch_class_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let m = tmp.path(); + assert!(!has_non_java_sources(m)); + write(m, "src/main/java/A.java", "class A {}"); + assert!(!has_non_java_sources(m)); + write(m, "src/main/kotlin/a/App.kt", "fun main() {}"); + assert!(has_non_java_sources(m)); + let g = tmp.path().join("g"); + write(&g, "src/test/groovy/ASpec.groovy", "class ASpec {}"); + assert!(has_non_java_sources(&g)); + for d in ["build/classes/kotlin/main", "build/classes/kotlin/test", "build/resources/main", "target/classes"] { + std::fs::create_dir_all(m.join(d)).unwrap(); + } + assert_eq!( + launch_class_dirs(BuildSystem::Gradle, m, false), + [m.join("build/classes/kotlin/main"), m.join("build/resources/main")] + ); + assert_eq!(launch_class_dirs(BuildSystem::Gradle, m, true)[0], m.join("build/classes/kotlin/test")); + assert_eq!(launch_class_dirs(BuildSystem::Maven, m, true), [m.join("target/classes")]); + } +} diff --git a/crates/ide-build/src/compile/parse.rs b/crates/ide-build/src/compile/parse.rs new file mode 100644 index 00000000..01144020 --- /dev/null +++ b/crates/ide-build/src/compile/parse.rs @@ -0,0 +1,594 @@ +//! Compiler output → messages. One [`OutputParser`] per build step consumes the +//! output line by line; multi-line messages (javac's source / caret lines, +//! `symbol:` / `location:` details, tsc message chains) are completed by the +//! following lines, so callers must call [`OutputParser::finish`] at the end. +//! +//! Positions are kept as printed (1-based, in the unit of the producing tool); +//! [`super::resolve`] turns them into IDE coordinates. + +use ide_core::Severity; +use serde_json::Value; + +/// Output dialect of a build step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Format { + /// Maven: `[ERROR] /p/A.java:[12,5] msg`, plus Kotlin and javac forms. + Maven, + /// Gradle: javac's `A.java:12: error: msg` and Kotlin's `e: file:///p/A.kt:12:5 msg`. + Gradle, + /// `cargo --message-format=json`. + Cargo, + /// `tsc`: `a.ts(12,5): error TS2322: msg` or the pretty `a.ts:12:5 - error TS2322: msg`. + Tsc, + /// `go build`: `./a.go:12:5: msg`. + Go, +} + +/// What a printed column counts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ColUnit { + /// Unicode scalar values (javac, kotlinc, rustc). + Char, + /// UTF-16 code units (tsc). + Utf16, + /// Bytes (go). + Byte, +} + +/// A message as printed: path as written, 1-based line / column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RawMessage { + pub severity: Severity, + pub text: String, + pub path: Option, + pub line: Option, + pub col: Option, + pub end_line: Option, + pub end_col: Option, + pub unit: ColUnit, + pub code: Option, + /// Directory relative paths resolve against first (cargo: the package of the message). + pub base: Option, +} + +impl RawMessage { + fn new(severity: Severity, text: impl Into, unit: ColUnit) -> Self { + RawMessage { + severity, + text: text.into(), + path: None, + line: None, + col: None, + end_line: None, + end_col: None, + unit, + code: None, + base: None, + } + } + + fn at(mut self, path: &str, line: u32, col: Option) -> Self { + self.path = Some(path.to_owned()); + self.line = Some(line); + self.col = col; + self + } +} + +/// What a line contributes: messages completed by it and the console text to +/// show instead of the raw line (cargo's JSON becomes the rendered diagnostic). +#[derive(Debug, Default)] +pub(crate) struct Parsed { + pub messages: Vec, + /// `None` = show the line as is; `Some("")` = hide it. + pub console: Option, +} + +/// A message waiting for its detail lines. +struct Pending { + msg: RawMessage, + /// javac (Gradle) prints the source line and a caret line after the header: lines still expected. + source_lines: u8, +} + +pub(crate) struct OutputParser { + format: Format, + pending: Option, +} + +/// Removes ANSI escape sequences (colour, cursor movement). +pub(crate) fn strip_ansi(s: &str) -> String { + if !s.contains('\u{1b}') { + return s.to_owned(); + } + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + if chars.peek() == Some(&'[') { + chars.next(); + for c in chars.by_ref() { + if c.is_ascii_alphabetic() || c == '~' { + break; + } + } + } else { + chars.next(); + } + } + out +} + +fn num(s: &str) -> Option { + s.trim().parse().ok().filter(|n| *n > 0) +} + +/// `path:12:5` / `path:12` at the start of `s` (path may contain `:` on Windows) → (path, line, col, rest). +fn path_line_col(s: &str) -> Option<(&str, u32, Option, &str)> { + // Scan `:` positions; the first one followed by digits and `:` / end is the line. + let bytes = s.as_bytes(); + let mut i = 0; + while let Some(off) = s[i..].find(':') { + let colon = i + off; + let digits = s[colon + 1..].bytes().take_while(u8::is_ascii_digit).count(); + let after = colon + 1 + digits; + if colon > 0 && digits > 0 && (after == s.len() || bytes[after] == b':' || bytes[after] == b' ') { + let path = &s[..colon]; + let line = num(&s[colon + 1..after])?; + if after < s.len() && bytes[after] == b':' { + let cdigits = s[after + 1..].bytes().take_while(u8::is_ascii_digit).count(); + if cdigits > 0 { + let end = after + 1 + cdigits; + return Some((path, line, num(&s[after + 1..end]), &s[end..])); + } + } + return Some((path, line, None, &s[after..])); + } + i = colon + 1; + } + None +} + +fn looks_like_source_path(p: &str) -> bool { + !p.is_empty() && !p.contains(' ') && p.rsplit(['/', '\\']).next().is_some_and(|name| name.contains('.')) +} + +/// `[12,5] msg` (Maven) → (12, 5, msg). +fn bracket_pos(s: &str) -> Option<(u32, Option, &str)> { + let inner = s.strip_prefix('[')?; + let (pos, rest) = inner.split_once(']')?; + let (line, col) = match pos.split_once(',') { + Some((l, c)) => (num(l)?, num(c)), + None => (num(pos)?, None), + }; + Some((line, col, rest.trim_start())) +} + +/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded). +fn from_file_uri(s: &str) -> String { + let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() }; + let hex = |b: u8| (b as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let (Some(h), Some(l)) = (bytes.get(i + 1).and_then(|b| hex(*b)), bytes.get(i + 2).and_then(|b| hex(*b))) + { + out.push(h * 16 + l); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + let path = String::from_utf8_lossy(&out).into_owned(); + // `file:///C:/x` on Windows. + if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path } +} + +/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix. +fn kotlin_location(s: &str, severity: Severity) -> Option { + if let Some((path, rest)) = s.split_once(": (") { + let (pos, msg) = rest.split_once(')')?; + let (l, c) = pos.split_once(',')?; + let msg = msg.trim_start_matches(':').trim(); + return Some(RawMessage::new(severity, msg, ColUnit::Char).at(&from_file_uri(path), num(l)?, num(c))); + } + let (path, line, col, rest) = path_line_col(s)?; + let msg = rest.trim_start_matches(':').trim(); + if !looks_like_source_path(path) || msg.is_empty() { + return None; + } + Some(RawMessage::new(severity, msg, ColUnit::Char).at(&from_file_uri(path), line, col)) +} + +fn kotlin_line(line: &str) -> Option { + let (severity, rest) = if let Some(r) = line.strip_prefix("e: ") { + (Severity::Error, r) + } else if let Some(r) = line.strip_prefix("w: ") { + (Severity::Warning, r) + } else { + return None; + }; + kotlin_location(rest, severity) +} + +/// javac: `A.java:12: error: msg` / `A.java:12: warning: [deprecation] msg`. +fn javac_header(line: &str) -> Option { + let (path, ln, col, rest) = path_line_col(line)?; + if !looks_like_source_path(path) { + return None; + } + let rest = rest.trim_start_matches(':').trim_start(); + let (severity, msg) = if let Some(m) = rest.strip_prefix("error:") { + (Severity::Error, m) + } else if let Some(m) = rest.strip_prefix("warning:") { + (Severity::Warning, m) + } else { + return None; + }; + Some(RawMessage::new(severity, msg.trim(), ColUnit::Char).at(path, ln, col)) +} + +/// Maven: `[ERROR] /p/A.java:[12,5] msg`, `[WARNING] …`, and Kotlin / javac forms after the level. +fn maven_line(line: &str) -> Option { + let (severity, rest) = if let Some(r) = line.strip_prefix("[ERROR] ") { + (Severity::Error, r) + } else if let Some(r) = line.strip_prefix("[WARNING] ") { + (Severity::Warning, r) + } else { + return None; + }; + let rest = rest.trim_start(); + if let Some((path, pos)) = rest.split_once(":[") + && looks_like_source_path(path) + && let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}")) + { + return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col)); + } + if let Some(m) = kotlin_line(rest) { + return Some(m); + } + if rest.starts_with("file://") || rest.contains(": (") { + return kotlin_location(rest, severity); + } + None +} + +/// tsc: `a.ts(12,5): error TS2322: msg` or `a.ts:12:5 - error TS2322: msg`. +fn tsc_line(line: &str) -> Option { + let (path, ln, col, rest) = if let Some(open) = line.find('(') + && let Some(close) = line[open..].find("):") + { + let (l, c) = line[open + 1..open + close].split_once(',')?; + (&line[..open], num(l)?, num(c), &line[open + close + 2..]) + } else { + let (path, ln, col, rest) = path_line_col(line)?; + (path, ln, col, rest.strip_prefix(" -")?) + }; + if !looks_like_source_path(path) { + return None; + } + let rest = rest.trim_start(); + let (severity, rest) = if let Some(r) = rest.strip_prefix("error") { + (Severity::Error, r) + } else if let Some(r) = rest.strip_prefix("warning") { + (Severity::Warning, r) + } else { + return None; + }; + let (code, msg) = match rest.trim_start().split_once(':') { + Some((code, msg)) if code.starts_with("TS") => (Some(code.to_owned()), msg.trim()), + _ => (None, rest.trim_start_matches(':').trim()), + }; + let mut m = RawMessage::new(severity, msg, ColUnit::Utf16).at(path.trim(), ln, col); + m.code = code; + Some(m) +} + +/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too). +fn go_line(line: &str) -> Option { + let (path, ln, col, rest) = path_line_col(line)?; + if !path.ends_with(".go") && !path.ends_with(".s") { + return None; + } + let msg = rest.strip_prefix(':')?.trim(); + (!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col)) +} + +/// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text. +fn cargo_json(v: &Value) -> Option<(Option, String)> { + if v.get("reason")?.as_str()? != "compiler-message" { + return Some((None, String::new())); + } + let msg = v.get("message")?; + let rendered = msg.get("rendered").and_then(Value::as_str).unwrap_or_default().trim_end().to_owned(); + let severity = match msg.get("level")?.as_str()? { + "error" | "error: internal compiler error" => Severity::Error, + "warning" => Severity::Warning, + "note" | "help" => Severity::Info, + _ => return Some((None, rendered)), + }; + let text = msg.get("message")?.as_str()?.to_owned(); + let span = msg + .get("spans") + .and_then(Value::as_array) + .and_then(|s| s.iter().find(|s| s.get("is_primary").and_then(Value::as_bool) == Some(true))); + let Some(span) = span else { + // Span-less summaries ("2 warnings emitted", "aborting due to…") repeat the real messages. + let summary = text.starts_with("aborting due to") + || text.ends_with("emitted") + || text.contains("generated") && text.contains("warning"); + let keep = severity == Severity::Error && !summary; + return Some((keep.then(|| RawMessage::new(severity, text, ColUnit::Char)), rendered)); + }; + let u = |k: &str| span.get(k).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()); + let mut m = RawMessage::new(severity, text, ColUnit::Char).at( + span.get("file_name")?.as_str()?, + u("line_start")?, + u("column_start"), + ); + m.end_line = u("line_end"); + m.end_col = u("column_end"); + m.code = msg.get("code").and_then(|c| c.get("code")).and_then(Value::as_str).map(str::to_owned); + m.base = v + .get("manifest_path") + .and_then(Value::as_str) + .and_then(|p| std::path::Path::new(p).parent()) + .map(|p| p.to_string_lossy().into_owned()); + Some((Some(m), rendered)) +} + +impl OutputParser { + pub(crate) fn new(format: Format) -> Self { + OutputParser { format, pending: None } + } + + fn flush(&mut self, out: &mut Vec) { + if let Some(p) = self.pending.take() { + out.push(p.msg); + } + } + + /// Source, caret and detail lines that belong to the pending message; `true` when consumed. + fn continue_pending(&mut self, line: &str) -> bool { + let format = self.format; + let Some(p) = &mut self.pending else { return false }; + if p.source_lines > 0 { + let t = line.trim_end(); + if t.trim_start() == "^" { + if p.msg.col.is_none() { + p.msg.col = u32::try_from(t.chars().count()).ok(); + } + p.source_lines = 0; + return true; + } + if p.source_lines == 2 { + p.source_lines = 1; + return true; + } + p.source_lines = 0; + } + // Maven repeats compiler messages in its failure summary with every line prefixed. + let body = match format { + Format::Maven => line + .strip_prefix("[ERROR]") + .or_else(|| line.strip_prefix("[WARNING]")) + .map_or(line, |r| r.strip_prefix(' ').unwrap_or(r)), + _ => line, + }; + if matches!(format, Format::Maven | Format::Gradle | Format::Tsc) + && body.starts_with(" ") + && !body.trim().is_empty() + { + p.msg.text.push('\n'); + p.msg.text.push_str(body.trim()); + return true; + } + false + } + + pub(crate) fn feed(&mut self, raw: &str) -> Parsed { + let line = strip_ansi(raw); + let mut out = Parsed::default(); + if self.format == Format::Cargo { + if line.starts_with('{') + && let Ok(v) = serde_json::from_str::(&line) + { + let (msg, rendered) = cargo_json(&v).unwrap_or_default(); + out.messages.extend(msg); + out.console = Some(if rendered.is_empty() { String::new() } else { format!("{rendered}\n") }); + } + return out; + } + if self.continue_pending(&line) { + return out; + } + self.flush(&mut out.messages); + let parsed = match self.format { + Format::Maven => maven_line(&line), + Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)), + Format::Tsc => tsc_line(&line), + Format::Go => go_line(&line), + Format::Cargo => None, + }; + if let Some(msg) = parsed { + let javac = self.format == Format::Gradle + && msg.unit == ColUnit::Char + && !line.starts_with("e: ") + && !line.starts_with("w: "); + self.pending = Some(Pending { msg, source_lines: if javac { 2 } else { 0 } }); + } + out + } + + pub(crate) fn finish(&mut self) -> Vec { + let mut out = Vec::new(); + self.flush(&mut out); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(format: Format, text: &str) -> Vec { + let mut p = OutputParser::new(format); + let mut out = Vec::new(); + for line in text.lines() { + out.extend(p.feed(line).messages); + } + out.extend(p.finish()); + out + } + + fn pos(m: &RawMessage) -> (Severity, &str, u32, Option) { + (m.severity, m.path.as_deref().unwrap(), m.line.unwrap(), m.col) + } + + #[test] + fn maven_javac() { + let out = parse( + Format::Maven, + "[INFO] --- compiler:3.13.0:compile (default-compile) @ demo ---\n\ + [ERROR] COMPILATION ERROR : \n\ + [ERROR] /p/src/main/java/a/A.java:[12,5] cannot find symbol\n\ + \x20 symbol: class Foo\n\ + \x20 location: class a.A\n\ + [WARNING] /p/src/main/java/a/B.java:[3,8] [deprecation] old() in a.C has been deprecated\n\ + [INFO] 1 error\n\ + [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile\n", + ); + assert_eq!(out.len(), 2, "{out:?}"); + assert_eq!(pos(&out[0]), (Severity::Error, "/p/src/main/java/a/A.java", 12, Some(5))); + assert_eq!(out[0].text, "cannot find symbol\nsymbol: class Foo\nlocation: class a.A"); + assert_eq!(pos(&out[1]), (Severity::Warning, "/p/src/main/java/a/B.java", 3, Some(8))); + assert_eq!(out[1].unit, ColUnit::Char); + } + + #[test] + fn maven_detail_lines_with_level_prefix_and_kotlin() { + let out = parse( + Format::Maven, + "[ERROR] /p/A.java:[7,20] incompatible types: String cannot be converted to int\n\ + [ERROR] -> [Help 1]\n\ + [ERROR] file:///p/src/K%20t.kt:4:9 Unresolved reference 'x'.\n\ + [WARNING] /p/src/K.kt: (2, 3): Variable 'y' is never used\n", + ); + assert_eq!(out.len(), 3, "{out:?}"); + assert_eq!(out[0].text, "incompatible types: String cannot be converted to int"); + assert_eq!(pos(&out[1]), (Severity::Error, "/p/src/K t.kt", 4, Some(9))); + assert_eq!(pos(&out[2]), (Severity::Warning, "/p/src/K.kt", 2, Some(3))); + assert_eq!(out[2].text, "Variable 'y' is never used"); + } + + #[test] + fn gradle_javac_with_caret_column() { + let out = parse( + Format::Gradle, + "> Task :compileJava\n\ + /p/src/main/java/a/A.java:12: error: cannot find symbol\n\ + \x20 Foo foo = new Foo();\n\ + \x20 ^\n\ + \x20 symbol: class Foo\n\ + \x20 location: class A\n\ + /p/src/main/java/a/A.java:20: warning: [removal] finalize() has been deprecated\n\ + \x20 protected void finalize() {}\n\ + \x20 ^\n\ + 1 error\n\ + 1 warning\n\ + > Task :compileJava FAILED\n", + ); + assert_eq!(out.len(), 2, "{out:?}"); + assert_eq!(pos(&out[0]), (Severity::Error, "/p/src/main/java/a/A.java", 12, Some(9))); + assert_eq!(out[0].text, "cannot find symbol\nsymbol: class Foo\nlocation: class A"); + assert_eq!(pos(&out[1]), (Severity::Warning, "/p/src/main/java/a/A.java", 20, Some(20))); + assert_eq!(out[1].text, "[removal] finalize() has been deprecated"); + } + + #[test] + fn gradle_kotlin() { + let out = parse( + Format::Gradle, + "> Task :app:compileKotlin FAILED\n\ + e: file:///p/app/src/main/kotlin/Main.kt:12:5 Unresolved reference: foo\n\ + w: file:///p/app/src/main/kotlin/Main.kt:3:9 Parameter 'args' is never used\n\ + e: Compilation error. See log for more details\n", + ); + assert_eq!(out.len(), 2, "{out:?}"); + assert_eq!(pos(&out[0]), (Severity::Error, "/p/app/src/main/kotlin/Main.kt", 12, Some(5))); + assert_eq!(out[0].text, "Unresolved reference: foo"); + assert_eq!(pos(&out[1]), (Severity::Warning, "/p/app/src/main/kotlin/Main.kt", 3, Some(9))); + } + + #[test] + fn cargo_json_messages() { + let err = r#"{"reason":"compiler-message","package_id":"p 0.1.0","manifest_path":"/w/crates/p/Cargo.toml","target":{},"message":{"rendered":"error[E0425]: cannot find value `x`\n --> src/main.rs:2:5\n","$message_type":"diagnostic","children":[],"code":{"code":"E0425","explanation":null},"level":"error","message":"cannot find value `x` in this scope","spans":[{"byte_end":20,"byte_start":19,"column_end":6,"column_start":5,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":null,"line_end":2,"line_start":2,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#; + let summary = r#"{"reason":"compiler-message","manifest_path":"/w/Cargo.toml","message":{"rendered":"error: aborting due to 1 previous error\n","children":[],"code":null,"level":"error","message":"aborting due to 1 previous error","spans":[]}}"#; + let artifact = r#"{"reason":"compiler-artifact","package_id":"p"}"#; + let mut p = OutputParser::new(Format::Cargo); + let a = p.feed(err); + assert_eq!(a.console.as_deref(), Some("error[E0425]: cannot find value `x`\n --> src/main.rs:2:5\n")); + let m = &a.messages[0]; + assert_eq!(pos(m), (Severity::Error, "src/main.rs", 2, Some(5))); + assert_eq!((m.end_line, m.end_col, m.code.as_deref()), (Some(2), Some(6), Some("E0425"))); + assert_eq!(m.base.as_deref(), Some("/w/crates/p")); + let s = p.feed(summary); + assert!(s.messages.is_empty()); + assert_eq!(s.console.as_deref(), Some("error: aborting due to 1 previous error\n")); + let a = p.feed(artifact); + assert!(a.messages.is_empty() && a.console.as_deref() == Some("")); + let plain = p.feed(" Compiling p v0.1.0 (/w/crates/p)"); + assert!(plain.messages.is_empty() && plain.console.is_none()); + } + + #[test] + fn tsc_plain_and_pretty() { + let out = parse( + Format::Tsc, + "src/a.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.\n\ + src/b.ts(3,1): error TS2345: Argument of type 'A' is not assignable to parameter of type 'B'.\n\ + \x20 Property 'x' is missing in type 'A'.\n\ + \x1b[96msrc/c.tsx\x1b[0m:\x1b[93m7\x1b[0m:\x1b[93m10\x1b[0m - \x1b[91merror\x1b[0m\x1b[90m TS2304: \x1b[0mCannot find name 'foo'.\n\ + \n\ + Found 3 errors in 3 files.\n", + ); + assert_eq!(out.len(), 3, "{out:?}"); + assert_eq!(pos(&out[0]), (Severity::Error, "src/a.ts", 12, Some(5))); + assert_eq!(out[0].code.as_deref(), Some("TS2322")); + assert_eq!(out[0].unit, ColUnit::Utf16); + assert!(out[1].text.ends_with("\nProperty 'x' is missing in type 'A'.")); + assert_eq!(pos(&out[2]), (Severity::Error, "src/c.tsx", 7, Some(10))); + assert_eq!(out[2].text, "Cannot find name 'foo'."); + } + + #[test] + fn go_errors() { + let out = parse( + Format::Go, + "# example.com/demo\n\ + ./main.go:12:5: undefined: foo\n\ + pkg/util.go:3:2: \"fmt\" imported and not used\n\ + note: module requires Go 1.22\n", + ); + assert_eq!(out.len(), 2, "{out:?}"); + assert_eq!(pos(&out[0]), (Severity::Error, "./main.go", 12, Some(5))); + assert_eq!(out[0].text, "undefined: foo"); + assert_eq!(out[1].unit, ColUnit::Byte); + assert_eq!(pos(&out[1]), (Severity::Error, "pkg/util.go", 3, Some(2))); + } + + #[test] + fn helpers() { + assert_eq!(strip_ansi("\x1b[1;31merror\x1b[0m: x"), "error: x"); + assert_eq!(from_file_uri("file:///a/b%20c.kt"), "/a/b c.kt"); + assert_eq!(from_file_uri("file:///C:/a.kt"), "C:/a.kt"); + assert_eq!(path_line_col("C:\\a\\b.go:3:4: x"), Some(("C:\\a\\b.go", 3, Some(4), ": x"))); + assert_eq!(path_line_col("no position here"), None); + } +} diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs new file mode 100644 index 00000000..be4d8eb5 --- /dev/null +++ b/crates/ide-build/src/gradle.rs @@ -0,0 +1,435 @@ +//! Gradle projects for the Gradle tool window: the project structure from +//! `settings.gradle(.kts)` (read statically) and the task list from +//! `gradle tasks --all` (after a sync; until then the usual tasks of the +//! applied plugins are shown). + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub const SETTINGS_FILES: &[&str] = &["settings.gradle.kts", "settings.gradle"]; +pub const BUILD_FILES: &[&str] = &["build.gradle.kts", "build.gradle"]; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GradleModel { + /// Project-relative directory of the build (where `settings.gradle` is); `""` = project root. + pub dir: String, + /// The root project first, then subprojects in `include` order. + pub projects: Vec, + pub java_version: Option, + /// `gradlew` of the build (absolute), else `gradle`. + pub command: String, + /// Environment for runs (`JAVA_HOME`); filled in by the shell. + pub env: BTreeMap, + /// When the task list was last read from Gradle (Unix ms); `None` = static defaults. + pub synced_at: Option, + pub stale: bool, + pub sync_error: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GradleProject { + /// Gradle path: `:` for the root project, `:app`, `:libs:core`. + pub path: String, + pub name: String, + /// Project-relative directory. + pub dir: String, + /// Project-relative build script, if any. + pub build_file: Option, + pub tasks: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GradleTask { + pub name: String, + /// Lower-case group (`build`, `build setup`, `other`). + pub group: String, + pub description: Option, +} + +fn task(group: &str, name: &str) -> GradleTask { + GradleTask { name: name.to_owned(), group: group.to_owned(), description: None } +} + +/// A Groovy / Kotlin script with `//` and `/* */` comments removed. +fn strip_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + let mut quote: Option = None; + while let Some(c) = chars.next() { + if let Some(q) = quote { + out.push(c); + if c == '\\' { + if let Some(n) = chars.next() { + out.push(n); + } + } else if c == q { + quote = None; + } + continue; + } + match c { + '"' | '\'' => { + quote = Some(c); + out.push(c); + } + '/' if chars.peek() == Some(&'/') => { + for n in chars.by_ref() { + if n == '\n' { + out.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut prev = ' '; + for n in chars.by_ref() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + } + _ => out.push(c), + } + } + out +} + +/// Quoted string literals in `s`. +fn literals(s: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '"' || c == '\'' { + out.push(chars.by_ref().take_while(|&n| n != c).collect()); + } + } + out +} + +/// Arguments of `include(...)` (Kotlin) or `include 'a', 'b'` (Groovy, continued +/// over lines that end with a comma). +fn include_args(after: &str) -> Vec { + if let Some(inner) = after.trim_start().strip_prefix('(') { + return literals(&inner[..inner.find(')').unwrap_or(inner.len())]); + } + let mut out = Vec::new(); + for line in after.lines() { + out.extend(literals(line)); + if !line.trim_end().ends_with(',') { + break; + } + } + out +} + +/// `(root project name, included project paths)` from a settings script. +pub fn parse_settings(text: &str) -> (Option, Vec) { + let text = strip_comments(text); + let mut name = None; + let mut includes: Vec = Vec::new(); + for (i, _) in text.match_indices("rootProject.name") { + let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default(); + if let Some(n) = literals(line).into_iter().next() { + name = Some(n); + } + } + let mut from = 0; + while let Some(i) = text[from..].find("include") { + let at = from + i; + from = at + "include".len(); + let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.'); + let after = &text[from..]; + let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace()); + if !before_ok || !after_ok { + continue; + } + for p in include_args(after) { + let path = if p.starts_with(':') { p } else { format!(":{p}") }; + if path.len() > 1 && !includes.contains(&path) { + includes.push(path); + } + } + } + (name, includes) +} + +/// The directory of the Gradle build under `root`: the root itself, or a direct +/// subfolder, holding a settings or build script. +pub fn build_dir(root: &Path) -> Option { + let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file()); + if has(root) { + return Some(root.to_path_buf()); + } + let mut dirs: Vec = std::fs::read_dir(root) + .ok()? + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.')) + .map(|e| e.path()) + .collect(); + dirs.sort(); + dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file())) +} + +fn wrapper(dir: &Path) -> String { + let name = if cfg!(windows) { "gradlew.bat" } else { "gradlew" }; + if dir.join(name).is_file() { + return dir.join(name).to_string_lossy().into_owned(); + } + if cfg!(windows) { "gradle.bat".into() } else { "gradle".into() } +} + +fn rel(root: &Path, p: &Path) -> String { + p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/") +} + +/// The tasks Gradle's own plugins add, for display before the first sync. +pub fn default_tasks(is_root: bool, script: &str) -> Vec { + let mut out = Vec::new(); + if is_root { + out.extend([task("build setup", "init"), task("build setup", "wrapper")]); + } + for t in [ + "buildEnvironment", + "dependencies", + "dependencyInsight", + "help", + "javaToolchains", + "projects", + "properties", + "tasks", + ] { + out.push(task("help", t)); + } + let jvm = [ + "java", + "application", + "kotlin(", + "org.jetbrains.kotlin", + "groovy", + "scala", + "org.springframework.boot", + "war", + ] + .iter() + .any(|k| script.contains(k)); + if jvm { + for t in ["assemble", "build", "buildDependents", "buildNeeded", "classes", "clean", "jar", "testClasses"] { + out.push(task("build", t)); + } + out.extend([task("verification", "check"), task("verification", "test"), task("documentation", "javadoc")]); + } + if script.contains("application") { + out.push(task("application", "run")); + } + if script.contains("org.springframework.boot") { + out.extend([task("application", "bootRun"), task("build", "bootJar")]); + } + out +} + +/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none. +pub fn load(root: &Path) -> Option { + let dir = build_dir(root)?; + let read = |d: &Path, names: &[&str]| { + names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t))) + }; + let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default(); + let root_name = + name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + let mut projects = Vec::new(); + let mut add = |path: String, name: String, pdir: PathBuf| { + let script = read(&pdir, BUILD_FILES); + let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default()); + projects.push(GradleProject { + build_file: script.map(|(n, _)| rel(root, &pdir.join(n))), + dir: rel(root, &pdir), + path, + name, + tasks, + }); + }; + add(":".into(), root_name, dir.clone()); + for path in includes { + let pdir = dir.join(path.trim_start_matches(':').replace(':', "/")); + let name = path.rsplit(':').next().unwrap_or_default().to_owned(); + add(path, name, pdir); + } + let java_version = projects + .iter() + .filter_map(|p| p.build_file.as_ref()) + .filter_map(|f| std::fs::read_to_string(root.join(f)).ok()) + .find_map(|t| script_java_version(&strip_comments(&t))); + Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() }) +} + +/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script. +pub fn script_java_version(text: &str) -> Option { + let number = |s: &str| -> Option { + let s = s.trim_start_matches([' ', '=', '\t', '(', '"', '\'']); + let s = s.strip_prefix("JavaVersion.VERSION_").unwrap_or(s); + let s = s.strip_prefix("1_").or_else(|| s.strip_prefix("1.")).unwrap_or(s); + s.chars().take_while(char::is_ascii_digit).collect::().parse().ok() + }; + for key in ["JavaLanguageVersion.of(", "sourceCompatibility", "jvmToolchain("] { + if let Some(v) = text.find(key).and_then(|i| number(&text[i + key.len()..])) { + return Some(v); + } + } + None +} + +/// Every settings and build script of the model (for change detection). +pub fn build_files(root: &Path, model: &GradleModel) -> Vec { + let dir = root.join(&model.dir); + let mut out: Vec = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect(); + out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f)))); + out +} + +/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as +/// `app:run` belong to `:app`, unqualified ones to the root project `:`. +pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> { + let lines: Vec<&str> = output.lines().collect(); + let mut out = Vec::new(); + let mut group: Option = None; + let mut i = 0; + while i < lines.len() { + let line = lines[i].trim_end(); + let underline = lines.get(i + 1).map(|l| l.trim_end()).unwrap_or_default(); + if !line.is_empty() && underline.len() >= 3 && underline.chars().all(|c| c == '-') && !line.starts_with('-') { + group = line.strip_suffix(" tasks").map(str::to_lowercase); + i += 2; + continue; + } + if line.is_empty() { + group = None; + i += 1; + continue; + } + if let Some(g) = &group { + let (name, description) = match line.split_once(" - ") { + Some((n, d)) => (n.trim(), Some(d.trim().to_owned())), + None => (line.trim(), None), + }; + if !name.is_empty() && !name.contains(char::is_whitespace) { + let (project, task_name) = match name.rsplit_once(':') { + Some((p, t)) => (format!(":{}", p.trim_start_matches(':')), t), + None => (":".to_owned(), name), + }; + out.push((project, GradleTask { name: task_name.to_owned(), group: g.clone(), description })); + } + } + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_scripts() { + let kts = r#" + // include("commented") + rootProject.name = "gradle-demo" + include("app", "lib") + include(":libs:core") + /* include("block") */ + includeBuild("plugins") + "#; + let (name, includes) = parse_settings(kts); + assert_eq!(name.as_deref(), Some("gradle-demo")); + assert_eq!(includes, [":app", ":lib", ":libs:core"]); + + let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n"; + let (name, includes) = parse_settings(groovy); + assert_eq!(name.as_deref(), Some("demo")); + assert_eq!(includes, [":a", ":b", ":c"]); + } + + #[test] + fn task_list_output() { + let out = "\ +------------------------------------------------------------ +Tasks runnable from root project 'gradle-demo' +------------------------------------------------------------ + +Application tasks +----------------- +app:run - Runs this project as a JVM application + +Build Setup tasks +----------------- +init - Initializes a new Gradle build. +wrapper - Generates Gradle wrapper files. + +Other tasks +----------- +app:compileJava - Compiles main Java source. +prepareKotlinBuildScriptModel +libs:core:jar + +Rules +----- +Pattern: clean: Cleans the output files of a task. +"; + let tasks = parse_tasks(out); + let flat: Vec<(&str, &str, &str, Option<&str>)> = tasks + .iter() + .map(|(p, t)| (p.as_str(), t.group.as_str(), t.name.as_str(), t.description.as_deref())) + .collect(); + assert_eq!( + flat, + [ + (":app", "application", "run", Some("Runs this project as a JVM application")), + (":", "build setup", "init", Some("Initializes a new Gradle build.")), + (":", "build setup", "wrapper", Some("Generates Gradle wrapper files.")), + (":app", "other", "compileJava", Some("Compiles main Java source.")), + (":", "other", "prepareKotlinBuildScriptModel", None), + (":libs:core", "other", "jar", None), + ] + ); + } + + #[test] + fn loads_projects_and_default_tasks() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"demo\"\ninclude(\"app\")").unwrap(); + std::fs::create_dir_all(root.join("app")).unwrap(); + std::fs::write(root.join("app/build.gradle.kts"), "plugins { application }").unwrap(); + std::fs::write(root.join("gradlew"), "#!/bin/sh").unwrap(); + let m = load(root).unwrap(); + assert_eq!(m.dir, ""); + let ps: Vec<(&str, &str, &str, Option<&str>)> = m + .projects + .iter() + .map(|p| (p.path.as_str(), p.name.as_str(), p.dir.as_str(), p.build_file.as_deref())) + .collect(); + assert_eq!(ps, [(":", "demo", "", None), (":app", "app", "app", Some("app/build.gradle.kts"))]); + assert!(m.projects[0].tasks.iter().any(|t| t.name == "wrapper")); + assert!(!m.projects[0].tasks.iter().any(|t| t.name == "build")); + assert!(m.projects[1].tasks.iter().any(|t| t.name == "run" && t.group == "application")); + assert!(m.command.ends_with("gradlew")); + assert_eq!(build_files(root, &m).len(), 2); + assert_eq!(m.java_version, None); + assert_eq!( + script_java_version("java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }"), + Some(21) + ); + assert_eq!(script_java_version("sourceCompatibility = JavaVersion.VERSION_1_8"), Some(8)); + assert_eq!(script_java_version("kotlin { jvmToolchain(17) }"), Some(17)); + assert_eq!(script_java_version("sourceCompatibility = '11'"), Some(11)); + + let none = tempfile::tempdir().unwrap(); + assert!(load(none.path()).is_none()); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs new file mode 100644 index 00000000..5f4ec5ba --- /dev/null +++ b/crates/ide-build/src/lib.rs @@ -0,0 +1,80 @@ +//! Build tool models for the Maven and Gradle tool windows, and Build › Build +//! Project ([`compile`]) for Maven, Gradle, Cargo, Go and TypeScript projects. +//! +//! [`load`] reads the project's build files (instant, no build tool involved) +//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run +//! Maven / Gradle in the background) cached for the project. + +pub mod compile; +pub mod gradle; +pub mod maven; +mod process; +mod sync; +mod xml; + +pub use gradle::{GradleModel, GradleProject, GradleTask}; +pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject}; +pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven}; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum BuildTool { + Maven, + Gradle, +} + +/// The build systems of a project; `None` when the project does not use one. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BuildModel { + pub maven: Option, + pub gradle: Option, +} + +/// 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 { + let maven = maven::is_maven_project(root).then(|| { + let mut m = maven::load(root); + sync::apply_maven_cache(root, cache_dir, &mut m); + m + }); + let gradle = gradle::load(root).map(|mut g| { + sync::apply_gradle_cache(root, cache_dir, &mut g); + g + }); + BuildModel { maven, gradle } +} + +/// 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 }; + name == "pom.xml" + || gradle::SETTINGS_FILES.contains(&name) + || gradle::BUILD_FILES.contains(&name) + || matches!(name, "mvnw" | "mvnw.cmd" | "gradlew" | "gradlew.bat") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_build_systems_and_build_files() { + let tmp = tempfile::tempdir().unwrap(); + 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()); + std::fs::write(root.join("svc/pom.xml"), "svc").unwrap(); + std::fs::write(root.join("build.gradle"), "").unwrap(); + let m = load(&root, &cache); + 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"))); + } +} diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs new file mode 100644 index 00000000..88eed0e1 --- /dev/null +++ b/crates/ide-build/src/maven.rs @@ -0,0 +1,1326 @@ +//! Maven projects for the Maven tool window, read straight from `pom.xml`: +//! coordinates, `` (aggregators), profiles, plugins (declared plus +//! the default lifecycle bindings of the packaging) and direct dependencies. +//! +//! `${properties}` are resolved through the parent chain, which is followed +//! into the local repository (`~/.m2/repository`) when the parent is not in the +//! project, together with imported BOMs. Nothing here runs Maven: [`crate::sync`] +//! later replaces plugins and dependencies with the effective build. + +use crate::xml::{self, Element}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Phases shown under "Lifecycle" (IDEA's default list). +pub const LIFECYCLE: &[&str] = + &["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"]; + +/// Every phase of the clean, default and site lifecycles (Execute Maven Goal completion). +pub const ALL_PHASES: &[&str] = &[ + "pre-clean", + "clean", + "post-clean", + "validate", + "initialize", + "generate-sources", + "process-sources", + "generate-resources", + "process-resources", + "compile", + "process-classes", + "generate-test-sources", + "process-test-sources", + "generate-test-resources", + "process-test-resources", + "test-compile", + "process-test-classes", + "test", + "prepare-package", + "package", + "pre-integration-test", + "integration-test", + "post-integration-test", + "verify", + "install", + "deploy", + "pre-site", + "site", + "post-site", + "site-deploy", +]; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MavenModel { + /// Pre-order: every aggregator comes before its modules. + pub projects: Vec, + /// Profiles declared by any project, first declaration wins. + pub profiles: Vec, + /// When the effective build was last read (Unix ms); `None` = only the static POM parse. + pub synced_at: Option, + /// Build files changed since that sync. + pub stale: bool, + pub sync_error: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MavenProject { + /// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`). + pub pom: String, + /// Project-relative directory; `""` = the project root. + pub dir: String, + pub group_id: String, + pub artifact_id: String, + pub version: String, + pub packaging: String, + /// `` (resolved), else the artifactId. + pub display_name: String, + /// `groupId:artifactId:version` of the parent POM. + pub parent: Option, + /// `pom` paths of this aggregator's modules. + pub modules: Vec, + /// Listed as a module of another project here. + pub aggregated: bool, + pub plugins: Vec, + pub dependencies: Vec, + /// Profile ids declared in this POM. + pub profiles: Vec, + /// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). + pub java_version: Option, + /// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. + pub command: String, + /// Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell. + pub env: BTreeMap, +} + +impl MavenProject { + pub fn key(&self) -> String { + format!("{}:{}", self.group_id, self.artifact_id) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MavenPlugin { + pub group_id: String, + pub artifact_id: String, + pub version: Option, + /// Goal prefix (`clean`, `spring-boot`): goals run as `:`. + pub prefix: String, + /// Sorted goal names. + pub goals: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MavenDependency { + pub group_id: String, + pub artifact_id: String, + pub version: Option, + /// `jar`, `pom`, `test-jar`… + #[serde(rename = "type")] + pub kind: String, + pub classifier: Option, + pub scope: String, + pub optional: bool, + /// Transitive dependencies (only after a sync). + pub children: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MavenProfile { + pub id: String, + pub active_by_default: bool, +} + +// ---------------------------------------------------------------- raw POM + +#[derive(Clone, Debug, Default)] +struct ParentRef { + group_id: String, + artifact_id: String, + version: String, + relative_path: Option, +} + +#[derive(Clone, Debug, Default)] +struct RawDep { + group_id: String, + artifact_id: String, + version: Option, + kind: Option, + classifier: Option, + scope: Option, + optional: bool, +} + +impl RawDep { + fn key(&self) -> String { + format!( + "{}:{}:{}:{}", + self.group_id, + self.artifact_id, + self.kind.as_deref().unwrap_or("jar"), + self.classifier.as_deref().unwrap_or("") + ) + } +} + +#[derive(Clone, Debug, Default)] +struct RawPlugin { + group_id: String, + artifact_id: String, + version: Option, + /// Goals bound by ``. + goals: Vec, +} + +impl RawPlugin { + fn key(&self) -> String { + format!("{}:{}", self.group_id, self.artifact_id) + } +} + +#[derive(Clone, Debug, Default)] +struct RawProfile { + id: String, + active_by_default: bool, + modules: Vec, + properties: Vec<(String, String)>, + deps: Vec, + plugins: Vec, +} + +#[derive(Clone, Debug, Default)] +struct RawPom { + group_id: Option, + artifact_id: String, + version: Option, + packaging: Option, + name: Option, + parent: Option, + properties: Vec<(String, String)>, + modules: Vec, + deps: Vec, + managed: Vec, + plugins: Vec, + managed_plugins: Vec, + profiles: Vec, + /// `` `maven-compiler-plugin` `` / ``. + compiler_release: Option, +} + +fn read_dep(e: &Element) -> Option { + Some(RawDep { + group_id: e.text_of("groupId")?, + artifact_id: e.text_of("artifactId")?, + version: e.text_of("version"), + kind: e.text_of("type"), + classifier: e.text_of("classifier"), + scope: e.text_of("scope"), + optional: e.text_of("optional").is_some_and(|o| o == "true"), + }) +} + +fn read_plugin(e: &Element) -> Option { + let mut goals: Vec = Vec::new(); + for exec in e.list("executions", "execution") { + for g in exec.list("goals", "goal") { + let g = g.text.trim(); + if !g.is_empty() && !goals.iter().any(|x| x == g) { + goals.push(g.to_owned()); + } + } + } + Some(RawPlugin { + group_id: e.text_of("groupId").unwrap_or_else(|| "org.apache.maven.plugins".into()), + artifact_id: e.text_of("artifactId")?, + version: e.text_of("version"), + goals, + }) +} + +fn read_properties(e: Option<&Element>) -> Vec<(String, String)> { + e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default() +} + +fn texts(e: &Element, path: &str, item: &str) -> Vec { + e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect() +} + +fn read_build(e: &Element, plugins: &mut Vec, managed: &mut Vec) { + plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin)); + managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin)); +} + +fn read_raw(project: &Element) -> RawPom { + let parent = project.child("parent").and_then(|p| { + Some(ParentRef { + group_id: p.text_of("groupId")?, + artifact_id: p.text_of("artifactId")?, + version: p.text_of("version").unwrap_or_default(), + // `` (empty) means "look it up in the repository". + relative_path: p.child("relativePath").map(|r| r.text.trim().to_owned()), + }) + }); + let mut pom = RawPom { + group_id: project.text_of("groupId"), + artifact_id: project.text_of("artifactId").unwrap_or_default(), + version: project.text_of("version"), + packaging: project.text_of("packaging"), + name: project.text_of("name"), + parent, + properties: read_properties(project.child("properties")), + modules: texts(project, "modules", "module"), + deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), + managed: project + .list("dependencyManagement/dependencies", "dependency") + .into_iter() + .filter_map(read_dep) + .collect(), + ..RawPom::default() + }; + read_build(project, &mut pom.plugins, &mut pom.managed_plugins); + if let Some(c) = pom.plugins.iter().position(|p| p.artifact_id == "maven-compiler-plugin") { + let plugin = + project.list("build/plugins", "plugin").into_iter().filter(|p| p.text_of("artifactId").is_some()).nth(c); + if let Some(cfg) = plugin.and_then(|p| p.child("configuration")) { + pom.compiler_release = cfg.text_of("release").or_else(|| cfg.text_of("source")); + } + } + for p in project.list("profiles", "profile") { + let mut profile = RawProfile { + id: p.text_of("id").unwrap_or_else(|| "default".into()), + active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"), + modules: texts(p, "modules", "module"), + properties: read_properties(p.child("properties")), + deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), + ..RawProfile::default() + }; + let mut managed = Vec::new(); + read_build(p, &mut profile.plugins, &mut managed); + pom.profiles.push(profile); + } + pom +} + +fn parse_pom(text: &str) -> Option { + let root = xml::parse(text)?; + (root.name == "project").then(|| read_raw(&root)) +} + +// ---------------------------------------------------------------- properties + +/// Replaces `${key}` with `props[key]` (also `pom.x` → `project.x`, `env.X`), repeatedly +/// for nested references; unknown keys are kept as written. +pub(crate) fn interpolate(s: &str, props: &HashMap) -> String { + let mut out = s.to_owned(); + for _ in 0..10 { + if !out.contains("${") { + break; + } + let mut next = String::with_capacity(out.len()); + let mut rest = out.as_str(); + while let Some(i) = rest.find("${") { + next.push_str(&rest[..i]); + let tail = &rest[i + 2..]; + let Some(end) = tail.find('}') else { + next.push_str(&rest[i..]); + rest = ""; + break; + }; + let key = &tail[..end]; + let alias = key.strip_prefix("pom.").map(|k| format!("project.{k}")); + let value = props + .get(key) + .or_else(|| alias.as_ref().and_then(|a| props.get(a))) + .cloned() + .or_else(|| key.strip_prefix("env.").and_then(|v| std::env::var(v).ok())); + match value { + Some(v) => next.push_str(&v), + None => next.push_str(&rest[i..i + 3 + end]), + } + rest = &tail[end + 1..]; + } + next.push_str(rest); + if next == out { + break; + } + out = next; + } + out +} + +// ---------------------------------------------------------------- inheritance + +/// Everything a POM inherits, with raw (uninterpolated) values: Maven interpolates +/// after inheritance, so `${project.version}` in a parent means the child's version. +#[derive(Clone, Debug, Default)] +struct Inherited { + group_id: Option, + version: Option, + properties: Vec<(String, String)>, + deps: Vec, + managed: Vec, + plugins: Vec, + managed_plugins: Vec, +} + +fn merge_by(base: &mut Vec, own: &[T], key: impl Fn(&T) -> String) { + let mut index: HashMap = base.iter().enumerate().map(|(i, b)| (key(b), i)).collect(); + for item in own { + match index.get(&key(item)) { + Some(&i) => base[i] = item.clone(), + None => { + index.insert(key(item), base.len()); + base.push(item.clone()); + } + } + } +} + +fn merge_plugins(base: &mut Vec, own: &[RawPlugin]) { + for p in own { + match base.iter_mut().find(|b| b.key() == p.key()) { + Some(b) => { + if p.version.is_some() { + b.version = p.version.clone(); + } + for g in &p.goals { + if !b.goals.contains(g) { + b.goals.push(g.clone()); + } + } + } + None => base.push(p.clone()), + } + } +} + +const MAX_PARENT_DEPTH: usize = 10; + +struct Resolver { + local_repo: Option, + poms: HashMap>, + contexts: HashMap, +} + +/// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. +fn default_local_repo() -> Option { + 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") + { + let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + return Some(PathBuf::from(repo.replace("${user.home}", &home))); + } + Some(m2.join("repository")) +} + +impl Resolver { + fn new(local_repo: Option) -> Self { + Self { local_repo, poms: HashMap::new(), contexts: HashMap::new() } + } + + fn pom(&mut self, path: &Path) -> Option { + self.poms + .entry(path.to_path_buf()) + .or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t))) + .clone() + } + + fn repo_pom(&self, group: &str, artifact: &str, version: &str) -> Option { + if version.is_empty() || version.contains("${") { + return None; + } + let mut p = self.local_repo.clone()?; + p.extend(group.split('.')); + p.push(artifact); + p.push(version); + p.push(format!("{artifact}-{version}.pom")); + p.is_file().then_some(p) + } + + /// The parent POM file of the POM in `dir`: in the project via `relativePath` + /// (default `../pom.xml`) when its coordinates match, else in the local repository. + fn parent_path(&mut self, dir: &Path, parent: &ParentRef) -> Option { + let rel = parent.relative_path.clone().unwrap_or_else(|| "../pom.xml".into()); + if !rel.is_empty() { + let mut candidate = dir.join(&rel); + if candidate.is_dir() { + candidate.push("pom.xml"); + } + if let Some(p) = self.pom(&candidate) { + let group = p.group_id.clone().or_else(|| p.parent.as_ref().map(|pp| pp.group_id.clone())); + if p.artifact_id == parent.artifact_id && group.as_deref() == Some(parent.group_id.as_str()) { + return Some(candidate); + } + } + } + self.repo_pom(&parent.group_id, &parent.artifact_id, &parent.version) + } + + /// What the POM at `path` hands down to its children (its parents' plus its own). + fn context(&mut self, path: &Path, depth: usize) -> Inherited { + if let Some(c) = self.contexts.get(path) { + return c.clone(); + } + let Some(pom) = self.pom(path) else { return Inherited::default() }; + let dir = path.parent().unwrap_or(Path::new("")).to_path_buf(); + let mut ctx = match &pom.parent { + Some(parent) if depth < MAX_PARENT_DEPTH => match self.parent_path(&dir, parent) { + Some(pp) => self.context(&pp, depth + 1), + None => Inherited::default(), + }, + _ => Inherited::default(), + }; + let parent_ref = pom.parent.clone().unwrap_or_default(); + ctx.group_id = pom.group_id.clone().or(ctx.group_id).or(Some(parent_ref.group_id.clone())); + ctx.version = pom.version.clone().or(ctx.version).or(Some(parent_ref.version.clone())); + merge_by(&mut ctx.properties, &pom.properties, |(k, _)| k.clone()); + merge_by(&mut ctx.deps, &pom.deps, RawDep::key); + merge_plugins(&mut ctx.plugins, &pom.plugins); + merge_plugins(&mut ctx.managed_plugins, &pom.managed_plugins); + for p in pom.profiles.iter().filter(|p| p.active_by_default) { + merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone()); + merge_by(&mut ctx.deps, &p.deps, RawDep::key); + merge_plugins(&mut ctx.plugins, &p.plugins); + } + // Managed dependencies: own entries win; imported BOMs fill the gaps. + let props = self.properties_for(&pom, &ctx, &dir); + let mut imported: Vec = Vec::new(); + for m in &pom.managed { + if m.scope.as_deref() == Some("import") && depth < MAX_PARENT_DEPTH { + let (g, a) = (interpolate(&m.group_id, &props), interpolate(&m.artifact_id, &props)); + let v = interpolate(m.version.as_deref().unwrap_or_default(), &props); + if let Some(bom) = self.repo_pom(&g, &a, &v) { + imported.extend(self.resolved_managed(&bom, depth + 1)); + } + } + } + let own: Vec = pom.managed.iter().filter(|m| m.scope.as_deref() != Some("import")).cloned().collect(); + merge_by(&mut ctx.managed, &own, RawDep::key); + let mut known: HashSet = ctx.managed.iter().map(RawDep::key).collect(); + for d in imported { + if known.insert(d.key()) { + ctx.managed.push(d); + } + } + self.contexts.insert(path.to_path_buf(), ctx.clone()); + ctx + } + + /// Managed dependencies of a BOM, interpolated with the BOM's own properties. + fn resolved_managed(&mut self, bom: &Path, depth: usize) -> Vec { + let ctx = self.context(bom, depth); + let Some(pom) = self.pom(bom) else { return Vec::new() }; + let dir = bom.parent().unwrap_or(Path::new("")).to_path_buf(); + let props = self.properties_for(&pom, &ctx, &dir); + ctx.managed + .iter() + .map(|d| RawDep { + group_id: interpolate(&d.group_id, &props), + artifact_id: interpolate(&d.artifact_id, &props), + version: d.version.as_deref().map(|v| interpolate(v, &props)), + ..d.clone() + }) + .collect() + } + + /// Properties visible in `pom`: inherited and own `` plus `project.*` built-ins. + fn properties_for(&self, pom: &RawPom, ctx: &Inherited, dir: &Path) -> HashMap { + let mut props: HashMap = ctx.properties.iter().cloned().collect(); + let group = pom.group_id.clone().or(ctx.group_id.clone()).unwrap_or_default(); + let version = pom.version.clone().or(ctx.version.clone()).unwrap_or_default(); + let basedir = dir.to_string_lossy().into_owned(); + let mut set = |k: &str, v: &str| { + props.insert(k.to_owned(), v.to_owned()); + }; + set("project.groupId", &group); + set("project.artifactId", &pom.artifact_id); + set("project.version", &version); + set("project.packaging", pom.packaging.as_deref().unwrap_or("jar")); + set("project.basedir", &basedir); + set("basedir", &basedir); + set("project.build.directory", &format!("{basedir}/target")); + if let Some(name) = &pom.name { + set("project.name", name); + } + if let Some(p) = &pom.parent { + set("project.parent.groupId", &p.group_id); + set("project.parent.artifactId", &p.artifact_id); + set("project.parent.version", &p.version); + set("parent.version", &p.version); + } + props + } +} + +// ---------------------------------------------------------------- plugins + +/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`. +pub fn goal_prefix(artifact_id: &str) -> String { + if let Some(p) = artifact_id.strip_prefix("maven-").and_then(|r| r.strip_suffix("-plugin")) { + return p.to_owned(); + } + if let Some(p) = artifact_id.strip_suffix("-maven-plugin") { + return p.to_owned(); + } + artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned() +} + +/// Goals of well-known plugins (their plugin descriptors are not read). +fn known_goals(artifact_id: &str) -> &'static [&'static str] { + match artifact_id { + "maven-clean-plugin" => &["clean", "help"], + "maven-resources-plugin" => &["copy-resources", "help", "resources", "testResources"], + "maven-compiler-plugin" => &["compile", "help", "testCompile"], + "maven-surefire-plugin" => &["help", "test"], + "maven-failsafe-plugin" => &["help", "integration-test", "verify"], + "maven-jar-plugin" => &["help", "jar", "test-jar"], + "maven-war-plugin" => &["exploded", "help", "inplace", "war"], + "maven-ear-plugin" => &["ear", "generate-application-xml", "help"], + "maven-install-plugin" => &["help", "install", "install-file"], + "maven-deploy-plugin" => &["deploy", "deploy-file", "help"], + "maven-site-plugin" => { + &["attach-descriptor", "deploy", "effective-site", "help", "jar", "run", "site", "stage", "stage-deploy"] + } + "maven-source-plugin" => &["aggregate", "help", "jar", "jar-no-fork", "test-jar", "test-jar-no-fork"], + "maven-javadoc-plugin" => { + &["aggregate", "aggregate-jar", "fix", "help", "jar", "javadoc", "test-jar", "test-javadoc"] + } + "maven-dependency-plugin" => &[ + "analyze", + "analyze-dep-mgt", + "build-classpath", + "copy", + "copy-dependencies", + "get", + "go-offline", + "help", + "list", + "properties", + "purge-local-repository", + "resolve", + "resolve-plugins", + "sources", + "tree", + "unpack", + "unpack-dependencies", + ], + "maven-help-plugin" => &[ + "active-profiles", + "all-profiles", + "describe", + "effective-pom", + "effective-settings", + "evaluate", + "help", + "system", + ], + "maven-enforcer-plugin" => &["display-info", "enforce", "help"], + "maven-shade-plugin" => &["help", "shade"], + "maven-assembly-plugin" => &["help", "single"], + "maven-antrun-plugin" => &["help", "run"], + "maven-release-plugin" => { + &["branch", "clean", "help", "perform", "prepare", "rollback", "stage", "update-versions"] + } + "maven-plugin-plugin" => &["addPluginArtifactMetadata", "descriptor", "help", "helpmojo", "report"], + "maven-gpg-plugin" => &["help", "sign", "sign-and-deploy-file"], + "spring-boot-maven-plugin" => &[ + "build-image", + "build-image-no-fork", + "build-info", + "help", + "process-aot", + "process-test-aot", + "repackage", + "run", + "start", + "stop", + "test-run", + ], + "exec-maven-plugin" => &["exec", "help", "java"], + "jacoco-maven-plugin" => &[ + "check", + "dump", + "help", + "instrument", + "merge", + "prepare-agent", + "prepare-agent-integration", + "report", + "restore-instrumented-classes", + ], + "build-helper-maven-plugin" => &[ + "add-resource", + "add-source", + "add-test-resource", + "add-test-source", + "attach-artifact", + "help", + "parse-version", + "regex-property", + "reserve-network-port", + "timestamp-property", + ], + "versions-maven-plugin" => &[ + "display-dependency-updates", + "display-plugin-updates", + "display-property-updates", + "help", + "set", + "update-parent", + "update-properties", + "use-latest-releases", + ], + "kotlin-maven-plugin" => &["compile", "help", "kapt", "test-compile", "test-kapt"], + "flatten-maven-plugin" => &["clean", "flatten", "help"], + "native-maven-plugin" => { + &["add-reachability-metadata", "compile", "compile-no-fork", "generate-metadata", "help", "test"] + } + _ => &[], + } +} + +/// Plugins Maven binds to the default lifecycle of `packaging` (plus clean and site). +fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] { + match packaging { + "pom" => &["maven-clean-plugin", "maven-install-plugin", "maven-deploy-plugin", "maven-site-plugin"], + "war" => &[ + "maven-clean-plugin", + "maven-resources-plugin", + "maven-compiler-plugin", + "maven-surefire-plugin", + "maven-war-plugin", + "maven-install-plugin", + "maven-deploy-plugin", + "maven-site-plugin", + ], + "maven-plugin" => &[ + "maven-clean-plugin", + "maven-resources-plugin", + "maven-compiler-plugin", + "maven-plugin-plugin", + "maven-surefire-plugin", + "maven-jar-plugin", + "maven-install-plugin", + "maven-deploy-plugin", + "maven-site-plugin", + ], + _ => &[ + "maven-clean-plugin", + "maven-resources-plugin", + "maven-compiler-plugin", + "maven-surefire-plugin", + "maven-jar-plugin", + "maven-install-plugin", + "maven-deploy-plugin", + "maven-site-plugin", + ], + } +} + +/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted. +pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin { + let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect(); + for g in goals { + if !all.contains(g) { + all.push(g.clone()); + } + } + all.sort(); + MavenPlugin { + group_id: group_id.to_owned(), + artifact_id: artifact_id.to_owned(), + version, + prefix: goal_prefix(artifact_id), + goals: all, + } +} + +// ---------------------------------------------------------------- loading + +/// Directories never searched for POMs. +const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"]; +const MAX_PROJECTS: usize = 1000; +const POM_SEARCH_DEPTH: usize = 3; + +fn rel_path(root: &Path, p: &Path) -> String { + p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/") +} + +/// The POMs that are not modules of another one: the root `pom.xml`, else the +/// shallowest `pom.xml` of each branch (a folder holding several builds). +fn top_level_poms(root: &Path) -> Vec { + let top = root.join("pom.xml"); + if top.is_file() { + return vec![top]; + } + let mut out = Vec::new(); + let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + let mut dirs: Vec = Vec::new(); + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str()) + { + dirs.push(e.path()); + } + } + dirs.sort(); + for d in dirs.into_iter().rev() { + if d.join("pom.xml").is_file() { + out.push(d.join("pom.xml")); + } else if depth + 1 < POM_SEARCH_DEPTH { + queue.push((d, depth + 1)); + } + } + } + out.sort(); + out +} + +/// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing). +pub fn is_maven_project(root: &Path) -> bool { + !top_level_poms(root).is_empty() +} + +/// The `mvnw` nearest to `dir`, looking up to `root`. +fn wrapper(root: &Path, dir: &Path) -> String { + let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" }; + let mut cur = Some(dir); + while let Some(d) = cur { + if d.join(name).is_file() { + return d.join(name).to_string_lossy().into_owned(); + } + if d == root { + break; + } + cur = d.parent(); + } + if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() } +} + +fn java_version(v: &str) -> Option { + let v = v.trim(); + let mut parts = v.split('.'); + let first: u32 = parts.next()?.trim().parse().ok()?; + if first == 1 { parts.next()?.trim().parse().ok() } else { Some(first) } +} + +/// Reads every Maven project under `root` from its POMs (no Maven run). +pub fn load(root: &Path) -> MavenModel { + load_with_repo(root, default_local_repo()) +} + +pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenModel { + let mut resolver = Resolver::new(local_repo); + let mut model = MavenModel::default(); + let mut seen: HashSet = HashSet::new(); + // (pom, aggregated) + let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect(); + while let Some((path, aggregated)) = stack.pop() { + if model.projects.len() >= MAX_PROJECTS { + break; + } + let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if !seen.insert(canonical) { + continue; + } + let Some(pom) = resolver.pom(&path) else { continue }; + let dir = path.parent().unwrap_or(root).to_path_buf(); + let ctx = resolver.context(&path, 0); + let props = resolver.properties_for(&pom, &ctx, &dir); + let i = |s: &str| interpolate(s, &props); + + let mut module_names: Vec = pom.modules.clone(); + for p in pom.profiles.iter().filter(|p| p.active_by_default) { + module_names.extend(p.modules.iter().cloned()); + } + let mut modules: Vec = Vec::new(); + for m in module_names { + let mut mp = dir.join(i(&m)); + if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") { + mp.push("pom.xml"); + } + if mp.is_file() && !modules.contains(&mp) { + modules.push(mp); + } + } + + let packaging = i(pom.packaging.as_deref().unwrap_or("jar")); + let managed: HashMap = ctx.managed.iter().map(|m| (m.key(), m)).collect(); + let managed_version = |d: &RawDep| managed.get(&d.key()).and_then(|m| m.version.clone()); + let managed_scope = |d: &RawDep| managed.get(&d.key()).and_then(|m| m.scope.clone()); + let dependencies = ctx + .deps + .iter() + .map(|d| MavenDependency { + group_id: i(&d.group_id), + artifact_id: i(&d.artifact_id), + version: d.version.clone().or_else(|| managed_version(d)).map(|v| i(&v)), + kind: d.kind.clone().unwrap_or_else(|| "jar".into()), + classifier: d.classifier.clone(), + scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()), + optional: d.optional, + children: Vec::new(), + }) + .collect(); + + let mut plugins: Vec = ctx.plugins.clone(); + for a in lifecycle_plugins(&packaging) { + if !plugins.iter().any(|p| p.artifact_id == *a) { + plugins.push(RawPlugin { + group_id: "org.apache.maven.plugins".into(), + artifact_id: (*a).into(), + ..RawPlugin::default() + }); + } + } + let plugins = plugins + .iter() + .map(|p| { + let version = p + .version + .clone() + .or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone())) + .map(|v| i(&v)) + .filter(|v| !v.contains("${")); + plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals) + }) + .collect(); + + let jv = ["maven.compiler.release", "java.version", "maven.compiler.source", "maven.compiler.target"] + .iter() + .find_map(|k| props.get(*k).map(|v| i(v))) + .or_else(|| pom.compiler_release.as_deref().map(i)) + .and_then(|v| java_version(&v)); + + for p in &pom.profiles { + if !model.profiles.iter().any(|x| x.id == p.id) { + model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default }); + } + } + let artifact_id = i(&pom.artifact_id); + model.projects.push(MavenProject { + pom: rel_path(root, &path), + dir: rel_path(root, &dir), + group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()), + version: i(props.get("project.version").map(String::as_str).unwrap_or_default()), + display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()), + artifact_id, + packaging, + parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))), + modules: modules.iter().map(|m| rel_path(root, m)).collect(), + aggregated, + plugins, + dependencies, + profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(), + java_version: jv, + command: wrapper(root, &dir), + env: BTreeMap::new(), + }); + for m in modules.into_iter().rev() { + stack.push((m, true)); + } + } + model +} + +/// Every POM of the model (for change detection). +pub fn build_files(root: &Path, model: &MavenModel) -> Vec { + model.projects.iter().map(|p| root.join(&p.pom)).collect() +} + +/// Projects and their plugins / dependencies from `mvn help:effective-pom` output +/// (`` or ``), keyed `groupId:artifactId`. +pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> { + let Some(root) = xml::parse(text) else { return Vec::new() }; + let projects: Vec<&Element> = + if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] }; + projects + .into_iter() + .map(|p| { + let raw = read_raw(p); + let group = raw.group_id.clone().or(raw.parent.as_ref().map(|pp| pp.group_id.clone())).unwrap_or_default(); + let plugins = raw + .plugins + .iter() + .map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals)) + .collect(); + let deps = raw + .deps + .iter() + .map(|d| MavenDependency { + group_id: d.group_id.clone(), + artifact_id: d.artifact_id.clone(), + version: d.version.clone(), + kind: d.kind.clone().unwrap_or_else(|| "jar".into()), + classifier: d.classifier.clone(), + scope: d.scope.clone().unwrap_or_else(|| "compile".into()), + optional: d.optional, + children: Vec::new(), + }) + .collect(); + (format!("{group}:{}", raw.artifact_id), plugins, deps) + }) + .collect() +} + +/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line. +fn parse_coords(s: &str) -> Option { + let s = s.trim(); + let (coords, extra) = match s.find(" (") { + Some(i) => (&s[..i], &s[i..]), + None => (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), + [g, a, t, v, s] => (g, a, t, None, v, Some(*s)), + [g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)), + _ => return None, + }; + Some(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(), + }) +} + +fn insert_at(list: &mut Vec, depth: usize, dep: MavenDependency) { + if depth == 0 { + list.push(dep); + return; + } + match list.last_mut() { + Some(last) => insert_at(&mut last.children, depth - 1, dep), + None => list.push(dep), + } +} + +/// Dependency trees from `mvn dependency:tree` console output, keyed `groupId:artifactId` +/// of the module; each value is the module's direct dependencies with their transitive ones. +pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + let mut current: Option = None; + let mut expect_root = false; + for line in output.lines() { + let line = line.trim_end(); + let body = line.strip_prefix("[INFO] ").or_else(|| line.strip_prefix("[INFO]")); + let Some(body) = body else { + current = None; + continue; + }; + if body.starts_with("--- ") && body.contains(":tree ") { + expect_root = true; + current = None; + continue; + } + if expect_root { + // The module's own coordinates; Maven may log other lines first ("Loaded … prefixes"). + if let Some(root) = parse_coords(body).filter(|_| !body.contains(' ')) { + expect_root = false; + out.push((format!("{}:{}", root.group_id, root.artifact_id), Vec::new())); + current = Some(out.len() - 1); + } else if body.starts_with("---") || body.starts_with("BUILD") { + expect_root = false; + } + continue; + } + let Some(idx) = current else { continue }; + // Tree lines: depth = 3 characters of `| ` / ` ` per level before `+- ` / `\- `. + let Some(marker) = body.find("+- ").or_else(|| body.find("\\- ")) else { + current = None; + continue; + }; + let prefix = &body[..marker]; + if !prefix.chars().all(|c| c == ' ' || c == '|') { + current = None; + continue; + } + if let Some(dep) = parse_coords(&body[marker + 3..]) { + insert_at(&mut out[idx].1, marker / 3, dep); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, rel: &str, text: &str) { + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + + const PARENT: &str = r#" + demoparent1.0 + pom${project.artifactId} build + coreapp + 5.10.221 + + org.junit.jupiterjunit-jupiter${junit.version}test + + + maven-surefire-plugin3.2.5 + + + devtrue + extraextra + + "#; + + #[test] + fn multi_module_parent_properties_and_managed_versions() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "pom.xml", PARENT); + write( + root, + "core/pom.xml", + r#"demoparent1.0 + core + org.junit.jupiterjunit-jupiter + "#, + ); + write( + root, + "app/pom.xml", + r#"demoparent1.0 + appDemo Appwar + + ${project.groupId}core${project.version} + xopt2trueprovided + + + org.springframework.bootspring-boot-maven-plugin + repackagecustom + + + "#, + ); + write(root, "extra/pom.xml", "extra"); + let model = load_with_repo(root, None); + let names: Vec<(&str, &str, bool)> = + model.projects.iter().map(|p| (p.pom.as_str(), p.display_name.as_str(), p.aggregated)).collect(); + assert_eq!( + names, + [("pom.xml", "parent build", false), ("core/pom.xml", "core", true), ("app/pom.xml", "Demo App", true)] + ); + let parent = &model.projects[0]; + assert_eq!(parent.modules, ["core/pom.xml", "app/pom.xml"]); + assert_eq!(parent.packaging, "pom"); + assert_eq!(parent.dir, ""); + let prefixes: Vec<&str> = parent.plugins.iter().map(|p| p.prefix.as_str()).collect(); + assert_eq!(prefixes, ["clean", "install", "deploy", "site"]); + + let core = &model.projects[1]; + assert_eq!((core.group_id.as_str(), core.version.as_str(), core.dir.as_str()), ("demo", "1.0", "core")); + assert_eq!(core.parent.as_deref(), Some("demo:parent:1.0")); + assert_eq!(core.java_version, Some(21)); + let junit = &core.dependencies[0]; + assert_eq!((junit.version.as_deref(), junit.scope.as_str()), (Some("5.10.2"), "test")); + let surefire = core.plugins.iter().find(|p| p.prefix == "surefire").unwrap(); + assert_eq!(surefire.version.as_deref(), Some("3.2.5")); + assert_eq!(surefire.goals, ["help", "test"]); + assert!(core.plugins.iter().any(|p| p.prefix == "jar")); + + let app = &model.projects[2]; + assert_eq!(app.packaging, "war"); + let deps: Vec<(String, Option, String, bool)> = app + .dependencies + .iter() + .map(|d| (d.artifact_id.clone(), d.version.clone(), d.scope.clone(), d.optional)) + .collect(); + assert_eq!( + deps, + [ + ("core".into(), Some("1.0".into()), "compile".into(), false), + ("opt".into(), Some("2".into()), "provided".into(), true) + ] + ); + assert_eq!(app.dependencies[0].group_id, "demo"); + let boot = &app.plugins[0]; + assert_eq!((boot.prefix.as_str(), boot.group_id.as_str()), ("spring-boot", "org.springframework.boot")); + assert!(boot.goals.contains(&"run".to_owned()) && boot.goals.contains(&"custom".to_owned())); + assert!(boot.goals.windows(2).all(|w| w[0] <= w[1]), "sorted"); + assert!(app.plugins.iter().any(|p| p.prefix == "war") && !app.plugins.iter().any(|p| p.prefix == "jar")); + + let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect(); + assert_eq!(profiles, [("dev", true), ("extra", false)]); + assert_eq!(parent.profiles, ["dev", "extra"]); + assert!(model.projects.iter().all(|p| p.command == "mvn")); + } + + #[test] + fn parent_and_bom_from_the_local_repository_and_wrapper() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let root = tmp.path().join("proj"); + write( + &repo, + "org/boot/boot-parent/3.0/boot-parent-3.0.pom", + r#"org.bootboot-parent3.0 + 179.9 + + org.bootbom${project.parent.version}pomimport + org.bootweb${lib.version} + + org.bootboot-maven-plugin${project.version} + org.bootroot3.0 + "#, + ); + write( + &repo, + "org/boot/root/3.0/root-3.0.pom", + "org.bootroot3.0", + ); + write( + &repo, + "org/boot/bom/3.0/bom-3.0.pom", + r#"org.bootbom3.0 + 2.1 + + org.jsonjson${json.version} + org.bootweb0.1 + + "#, + ); + write( + &root, + "pom.xml", + r#"org.bootboot-parent3.0 + com.exampledemo0.1-SNAPSHOT + + org.bootweb + org.jsonjson + unknownx + + "#, + ); + write(&root, "mvnw", "#!/bin/sh"); + let model = load_with_repo(&root, Some(repo)); + let p = &model.projects[0]; + assert_eq!(p.java_version, Some(17)); + let versions: Vec> = p.dependencies.iter().map(|d| d.version.as_deref()).collect(); + assert_eq!(versions, [Some("9.9"), Some("2.1"), None], "parent's own entry wins over its BOM"); + let boot = p.plugins.iter().find(|pl| pl.artifact_id == "boot-maven-plugin").unwrap(); + assert_eq!(boot.version.as_deref(), Some("0.1-SNAPSHOT"), "interpolated with the child's version"); + assert_eq!(boot.prefix, "boot"); + assert!(p.command.ends_with("mvnw") && Path::new(&p.command).is_absolute()); + } + + #[test] + fn folders_of_separate_builds_and_module_cycles() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "a/pom.xml", + "ga../b", + ); + write( + root, + "b/pom.xml", + "gb../a", + ); + write(root, "c/target/pom.xml", "ignored"); + let model = load_with_repo(root, None); + let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect(); + assert_eq!(names, ["a", "b"]); + assert!(is_maven_project(root)); + assert!(!is_maven_project(&root.join("c"))); + } + + #[test] + fn property_interpolation() { + let props: HashMap = + [("a", "${b}-x"), ("b", "1"), ("project.version", "2"), ("self", "${self}")] + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + assert_eq!(interpolate("v${a}/${pom.version}/${missing}/${", &props), "v1-x/2/${missing}/${"); + assert_eq!(interpolate("${self}", &props), "${self}"); + } + + #[test] + fn prefixes() { + assert_eq!(goal_prefix("maven-surefire-plugin"), "surefire"); + assert_eq!(goal_prefix("spring-boot-maven-plugin"), "spring-boot"); + assert_eq!(goal_prefix("frontend-plugin"), "frontend"); + assert_eq!(goal_prefix("odd"), "odd"); + } + + #[test] + fn effective_pom_projects() { + let text = r#" + demop1 + core + jjunit5test + maven-clean-plugin3.2.0 + default-cleancleanclean + + + demop + "#; + let projects = parse_effective_pom(text); + assert_eq!(projects.len(), 2); + let (key, plugins, deps) = &projects[0]; + assert_eq!(key, "demo:core"); + assert_eq!((plugins[0].prefix.as_str(), plugins[0].version.as_deref()), ("clean", Some("3.2.0"))); + assert_eq!(plugins[0].goals, ["clean", "help"]); + assert_eq!((deps[0].artifact_id.as_str(), deps[0].scope.as_str()), ("junit", "test")); + assert_eq!(projects[1].0, "demo:p"); + } + + #[test] + fn dependency_tree_output() { + let out = "\ +[INFO] --- dependency:3.7.0:tree (default-cli) @ multi-parent --- +[INFO] demo:multi-parent:pom:1.0-SNAPSHOT +[INFO] +[INFO] --- dependency:3.7.0:tree (default-cli) @ core --- +[INFO] demo:core:jar:1.0-SNAPSHOT +[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.10.2:test +[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:5.10.2:test +[INFO] | | \\- org.opentest4j:opentest4j:jar:1.3.0:test +[INFO] | \\- org.junit.jupiter:junit-jupiter-engine:jar:5.10.2:test +[INFO] \\- com.x:native:jar:linux:1.0:compile (optional) +[INFO] ------------------------------------------------------------------------ +[INFO] --- dependency:3.10.0:tree (default-cli) @ app --- +[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt) +[INFO] demo:app:jar:1.0-SNAPSHOT +[INFO] \\- demo:core:jar:1.0-SNAPSHOT:compile +"; + let trees = parse_dependency_tree(out); + let keys: Vec<&str> = trees.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, ["demo:multi-parent", "demo:core", "demo:app"]); + assert!(trees[0].1.is_empty()); + let core = &trees[1].1; + assert_eq!(core.len(), 2); + assert_eq!(core[0].artifact_id, "junit-jupiter"); + let kids: Vec<&str> = core[0].children.iter().map(|d| d.artifact_id.as_str()).collect(); + assert_eq!(kids, ["junit-jupiter-api", "junit-jupiter-engine"]); + assert_eq!(core[0].children[0].children[0].artifact_id, "opentest4j"); + let native = &core[1]; + assert_eq!( + (native.classifier.as_deref(), native.version.as_deref(), native.optional), + (Some("linux"), Some("1.0"), true) + ); + assert_eq!(trees[2].1[0].scope, "compile"); + } +} diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs new file mode 100644 index 00000000..c1e1eb41 --- /dev/null +++ b/crates/ide-build/src/process.rs @@ -0,0 +1,160 @@ +//! Line-oriented child processes for syncs and builds: output is fed line by +//! line to a callback, and cancellation kills the whole process group (the +//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child). + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Read}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Stream { + Stdout, + Stderr, +} + +pub(crate) struct Output { + pub success: bool, + /// Every line, stdout and stderr interleaved as received. + pub text: String, +} + +fn kill_tree(child: &mut Child) { + #[cfg(unix)] + if let Ok(pid) = i32::try_from(child.id()) { + // SAFETY: plain syscall; the group was created by `process_group(0)` at spawn. + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills +/// the process group and returns `cancelled` when `cancel` fires. +pub(crate) fn run( + program: &str, + args: &[String], + cwd: &Path, + env: &BTreeMap, + cancel: &CancellationToken, + mut on_line: impl FnMut(Stream, &str), +) -> IdeResult { + tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display()); + let mut command = Command::new(program); + command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + 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); + } + 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, kind: Stream, tx: mpsc::Sender<(Stream, String)>| { + std::thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut buf = Vec::new(); + loop { + buf.clear(); + match reader.read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => { + let line = String::from_utf8_lossy(&buf); + let line = line.trim_end_matches(['\n', '\r']).to_owned(); + if tx.send((kind, line)).is_err() { + break; + } + } + } + } + }) + }; + let readers = [ + child.stdout.take().map(|s| pump(Box::new(s), Stream::Stdout, tx.clone())), + child.stderr.take().map(|s| pump(Box::new(s), Stream::Stderr, tx.clone())), + ]; + drop(tx); + let mut text = String::new(); + loop { + if cancel.is_cancelled() { + kill_tree(&mut child); + return Err(IdeError::cancelled()); + } + match rx.recv_timeout(Duration::from_millis(100)) { + Ok((stream, line)) => { + on_line(stream, &line); + text.push_str(&line); + text.push('\n'); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + for r in readers.into_iter().flatten() { + let _ = r.join(); + } + let status = child.wait()?; + Ok(Output { success: status.success(), text }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_and_cancels_processes() { + if cfg!(windows) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let cancel = CancellationToken::new(); + let mut lines = Vec::new(); + let out = run( + "sh", + &["-c".into(), "echo one; echo two >&2; exit 3".into()], + tmp.path(), + &BTreeMap::new(), + &cancel, + |s, l| lines.push((s, l.to_owned())), + ) + .unwrap(); + assert!(!out.success); + lines.sort_by(|a, b| a.1.cmp(&b.1)); + assert_eq!(lines, [(Stream::Stdout, "one".to_owned()), (Stream::Stderr, "two".to_owned())]); + cancel.cancel(); + let err = run("sh", &["-c".into(), "sleep 5".into()], tmp.path(), &BTreeMap::new(), &cancel, |_, _| {}) + .err() + .unwrap(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + } + + #[test] + fn cancel_kills_grandchildren() { + if cfg!(windows) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let cancel = CancellationToken::new(); + let marker = tmp.path().join("survived"); + let script = format!("(sleep 1; touch '{}') & wait", marker.display()); + let c = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + c.cancel(); + }); + let err = run("sh", &["-c".into(), script], tmp.path(), &BTreeMap::new(), &cancel, |_, _| {}).err().unwrap(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + std::thread::sleep(Duration::from_millis(1300)); + assert!(!marker.exists(), "the background child must be killed with the group"); + } +} diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs new file mode 100644 index 00000000..fe984efc --- /dev/null +++ b/crates/ide-build/src/sync.rs @@ -0,0 +1,335 @@ +//! Reading the effective build from Maven / Gradle itself ("Reload" in the tool +//! windows): `mvn help:effective-pom dependency:tree` 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. + +use crate::gradle::{self, GradleModel, GradleTask}; +use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin}; +use crate::process::{self, Output}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncProgress { + pub message: String, + /// 0..1, or `None` when indeterminate. + pub fraction: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct SyncOptions { + /// `-o` / `--offline`. + pub offline: bool, + /// Maven profiles (`-P`), `!id` deactivates. + pub profiles: Vec, + /// Extra environment (`JAVA_HOME`). + pub env: BTreeMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct CachedMavenProject { + key: String, + plugins: Vec, + /// Effective direct dependencies, with transitive ones when `dependency:tree` ran. + dependencies: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct MavenCache { + hash: String, + at_ms: f64, + error: Option, + projects: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct GradleCache { + hash: String, + at_ms: f64, + error: Option, + tasks: Vec<(String, GradleTask)>, +} + +const MAVEN_CACHE: &str = "maven.json"; +const GRADLE_CACHE: &str = "gradle.json"; + +fn read_cache(dir: &Path, name: &str) -> Option { + serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok() +} + +fn write_cache(dir: &Path, name: &str, value: &T) -> IdeResult<()> { + std::fs::create_dir_all(dir)?; + let json = serde_json::to_vec(value).map_err(|e| IdeError::internal(e.to_string()))?; + let tmp = dir.join(format!("{name}.tmp")); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, dir.join(name))?; + Ok(()) +} + +/// FNV-1a over the paths and contents of `files` (missing files count as empty). +fn hash_files(files: &[PathBuf]) -> String { + let mut h: u64 = 0xcbf29ce484222325; + let mut feed = |bytes: &[u8]| { + for b in bytes { + h ^= u64::from(*b); + h = h.wrapping_mul(0x100000001b3); + } + }; + for f in files { + feed(f.to_string_lossy().as_bytes()); + feed(&[0]); + feed(&std::fs::read(f).unwrap_or_default()); + feed(&[0]); + } + format!("{h:016x}") +} + +fn now_ms() -> f64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64) +} + +/// Merges the cached effective build into `model`. +pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) { + let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return }; + for p in &mut model.projects { + if let Some(c) = cache.projects.iter().find(|c| c.key == p.key()) { + if !c.plugins.is_empty() { + p.plugins = c.plugins.clone(); + } + p.dependencies = c.dependencies.clone(); + } + } + model.synced_at = Some(cache.at_ms); + model.sync_error = cache.error; + model.stale = cache.hash != hash_files(&maven::build_files(root, model)); +} + +/// Merges the cached task list into `model`. +pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) { + let Some(cache) = read_cache::(cache_dir, GRADLE_CACHE) else { return }; + if !cache.tasks.is_empty() { + for p in &mut model.projects { + let tasks: Vec = + cache.tasks.iter().filter(|(path, _)| *path == p.path).map(|(_, t)| t.clone()).collect(); + if !tasks.is_empty() { + p.tasks = tasks; + } + } + } + model.synced_at = Some(cache.at_ms); + model.sync_error = cache.error; + model.stale = cache.hash != hash_files(&gradle::build_files(root, model)); +} + +// ---------------------------------------------------------------- processes + +fn run( + program: &str, + args: &[String], + cwd: &Path, + env: &BTreeMap, + cancel: &CancellationToken, + mut on_line: impl FnMut(&str), +) -> IdeResult { + process::run(program, args, cwd, env, cancel, |_, line| on_line(line)) +} + +/// The first error lines of a failed build, for the tool window. +pub(crate) fn error_summary(text: &str) -> String { + let lines: Vec<&str> = text + .lines() + .filter(|l| { + l.starts_with("[ERROR]") + || l.starts_with("FAILURE:") + || l.starts_with("* What went wrong") + || l.starts_with("> ") + }) + .map(|l| l.trim_start_matches("[ERROR]").trim()) + .filter(|l| !l.is_empty() && !l.starts_with("Re-run Maven") && !l.starts_with("To see the full stack trace")) + .take(4) + .collect(); + if lines.is_empty() { + text.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("the build failed").trim().to_owned() + } else { + lines.join("\n") + } +} + +/// `[INFO] Building core 1.0-SNAPSHOT [2/3]` → ("core 1.0-SNAPSHOT", 2, 3). +pub(crate) fn maven_building(line: &str) -> Option<(String, u32, u32)> { + let body = line.strip_prefix("[INFO] Building ")?; + let open = body.rfind('[')?; + let (n, m) = body[open + 1..].trim_end_matches(']').split_once('/')?; + Some((body[..open].trim().to_owned(), n.trim().parse().ok()?, m.trim().parse().ok()?)) +} + +/// Runs Maven on every top-level project and caches effective plugins and dependency trees. +pub fn sync_maven( + root: &Path, + cache_dir: &Path, + options: &SyncOptions, + cancel: &CancellationToken, + progress: &dyn Fn(SyncProgress), +) -> 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 errors: Vec = 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 = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()]; + 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()); + progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None }); + let out = run(&top.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) }); + } else if let Some(url) = + line.strip_prefix("Downloading from ").and_then(|l| l.split_once(": ")).map(|(_, u)| u) + { + progress(SyncProgress { + message: format!("Downloading {}", url.rsplit('/').next().unwrap_or(url)), + fraction: None, + }); + } + })?; + let effective_projects = + std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default(); + let trees = maven::parse_dependency_tree(&out.text); + for (key, plugins, deps) in effective_projects { + let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps); + cache.projects.push(CachedMavenProject { key, plugins, dependencies }); + } + // Modules whose effective POM could not be written still get their tree. + for (key, tree) in trees { + if !cache.projects.iter().any(|c| c.key == key) { + cache.projects.push(CachedMavenProject { key, plugins: Vec::new(), dependencies: tree }); + } + } + if !out.success { + errors.push(error_summary(&out.text)); + } + } + cache.at_ms = now_ms(); + cache.error = (!errors.is_empty()).then(|| errors.join("\n")); + write_cache(cache_dir, MAVEN_CACHE, &cache) +} + +/// Runs `gradle tasks --all` and caches the task list. +pub fn sync_gradle( + root: &Path, + cache_dir: &Path, + options: &SyncOptions, + cancel: &CancellationToken, + progress: &dyn Fn(SyncProgress), +) -> IdeResult<()> { + let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; + let mut args: Vec = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()]; + if options.offline { + args.push("--offline".into()); + } + progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None }); + let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?; + let cache = GradleCache { + hash: hash_files(&gradle::build_files(root, &model)), + at_ms: now_ms(), + error: (!out.success).then(|| error_summary(&out.text)), + tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() }, + }; + write_cache(cache_dir, GRADLE_CACHE, &cache) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caches_merge_and_go_stale() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("pom.xml"), "ga") + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + assert!(model.synced_at.is_none()); + let cache = MavenCache { + hash: hash_files(&maven::build_files(&root, &model)), + at_ms: 5.0, + error: None, + projects: vec![CachedMavenProject { + key: "g:a".into(), + plugins: vec![maven::plugin( + "org.apache.maven.plugins", + "maven-clean-plugin", + Some("3.2.0".into()), + &[], + )], + dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }], + }], + }; + write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap(); + apply_maven_cache(&root, &cache_dir, &mut model); + assert_eq!(model.synced_at, Some(5.0)); + assert!(!model.stale); + assert_eq!(model.projects[0].plugins.len(), 1); + assert_eq!(model.projects[0].dependencies[0].artifact_id, "dep"); + + std::fs::write( + root.join("pom.xml"), + "ga2", + ) + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + apply_maven_cache(&root, &cache_dir, &mut model); + assert!(model.stale); + } + + #[test] + fn gradle_cache_replaces_tasks_per_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(root.join("app")).unwrap(); + std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap(); + std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap(); + let mut model = gradle::load(&root).unwrap(); + let cache = GradleCache { + hash: hash_files(&gradle::build_files(&root, &model)), + at_ms: 1.0, + error: None, + tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })], + }; + write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap(); + apply_gradle_cache(&root, &cache_dir, &mut model); + assert_eq!(model.projects[1].tasks.len(), 1); + assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults"); + assert!(!model.stale); + } + + #[test] + fn progress_and_error_lines() { + assert_eq!( + maven_building("[INFO] Building core 1.0-SNAPSHOT [2/3]"), + Some(("core 1.0-SNAPSHOT".into(), 2, 3)) + ); + assert_eq!(maven_building("[INFO] Building jar: x.jar"), None); + let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n"; + assert_eq!(error_summary(text), "Failed to execute goal"); + assert_eq!(error_summary("one\nlast line\n\n"), "last line"); + } +} diff --git a/crates/ide-build/src/xml.rs b/crates/ide-build/src/xml.rs new file mode 100644 index 00000000..38f96ed1 --- /dev/null +++ b/crates/ide-build/src/xml.rs @@ -0,0 +1,178 @@ +//! 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 (`` → `project`). + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Element { + pub name: String, + pub text: String, + pub children: Vec, +} + +impl Element { + pub fn child(&self, name: &str) -> Option<&Element> { + self.children.iter().find(|c| c.name == name) + } + + pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children.iter().filter(move |c| c.name == name) + } + + /// Trimmed text of the child `name`, if present and non-empty. + pub fn text_of(&self, name: &str) -> Option { + self.child(name).map(|c| c.text.trim().to_owned()).filter(|t| !t.is_empty()) + } + + /// Element at a `/`-separated path of child names. + pub fn at(&self, path: &str) -> Option<&Element> { + path.split('/').try_fold(self, |e, name| e.child(name)) + } + + /// Children of the element at `path` (empty when missing). + pub fn list<'a>(&'a self, path: &str, item: &'a str) -> Vec<&'a Element> { + self.at(path).map(|e| e.children_named(item).collect()).unwrap_or_default() + } +} + +fn decode_entities(raw: &str, out: &mut String) { + let mut rest = raw; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let tail = &rest[i..]; + let Some(end) = tail.find(';').filter(|&e| e <= 12) else { + out.push('&'); + rest = &tail[1..]; + continue; + }; + let entity = &tail[1..end]; + let ch = match entity { + "lt" => Some('<'), + "gt" => Some('>'), + "amp" => Some('&'), + "quot" => Some('"'), + "apos" => Some('\''), + e if e.starts_with("#x") || e.starts_with("#X") => { + u32::from_str_radix(&e[2..], 16).ok().and_then(char::from_u32) + } + e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), + _ => None, + }; + match ch { + Some(c) => out.push(c), + None => out.push_str(&tail[..=end]), + } + rest = &tail[end + 1..]; + } + out.push_str(rest); +} + +fn local(name: &str) -> String { + name.rsplit(':').next().unwrap_or(name).to_owned() +} + +/// Parses `text` and returns its root element, or `None` when there is none. +/// Unbalanced end tags close the innermost matching element; unclosed elements +/// are closed at the end of input. +pub fn parse(text: &str) -> Option { + let mut stack: Vec = vec![Element::default()]; + let mut rest = text; + while !rest.is_empty() { + let Some(lt) = rest.find('<') else { + decode_entities(rest, &mut stack.last_mut()?.text); + break; + }; + if lt > 0 { + decode_entities(&rest[..lt], &mut stack.last_mut()?.text); + } + let tag = &rest[lt..]; + if let Some(body) = tag.strip_prefix("").map_or("", |e| &body[e + 3..]); + } else if let Some(body) = tag.strip_prefix("").unwrap_or(body.len()); + stack.last_mut()?.text.push_str(&body[..end]); + rest = body.get(end + 3..).unwrap_or(""); + } else if tag.starts_with("").map_or("", |e| &tag[e + 2..]); + } else if tag.starts_with("').map_or("", |e| &tag[e + 1..]); + } else if let Some(body) = tag.strip_prefix("').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()?; + 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(); + let mut i = 1; + let mut quote: Option = None; + while i < bytes.len() { + match (quote, bytes[i]) { + (Some(q), b) if b == q => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(bytes[i]), + (None, b'>') => break, + _ => {} + } + i += 1; + } + let inner = &tag[1..i.min(tag.len())]; + 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() }; + 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()?; + stack.last_mut()?.children.push(done); + } + stack.pop()?.children.into_iter().next() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elements_text_and_markup_noise() { + let doc = r#" + + + + A & B AB + + ]]> + + 12 +"#; + let root = parse(doc).unwrap(); + assert_eq!(root.name, "project"); + assert_eq!(root.text_of("name").as_deref(), Some("A & B AB")); + assert!(root.child("empty").is_some()); + assert_eq!(root.text_of("empty"), None); + assert_eq!(root.text_of("desc").as_deref(), Some("")); + let items: Vec = root.list("list", "item").iter().map(|e| e.text.clone()).collect(); + assert_eq!(items, ["1", "2"]); + assert_eq!(root.children_named("name").count(), 1); + } + + #[test] + fn tolerates_broken_documents() { + let root = parse("xy").unwrap(); + assert_eq!(root.at("b").unwrap().text, "x"); + assert_eq!(root.at("b/d").unwrap().text, "y"); + assert!(parse("").is_none()); + assert!(parse("just text").is_none()); + } +} diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml new file mode 100644 index 00000000..14cf7ab3 --- /dev/null +++ b/crates/ide-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ide-core" +description = "Shared vocabulary of OpenIDEA: ids, text coordinates, events, errors, settings" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +dunce.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +thiserror.workspace = true +tokio-util.workspace = true diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs new file mode 100644 index 00000000..43547f96 --- /dev/null +++ b/crates/ide-core/src/editor_settings.rs @@ -0,0 +1,370 @@ +//! Settings › Editor sub-structures (IDEA's Editor group: General and its +//! sub-pages, Code Editing, Color Scheme, Code Style, File Encodings). The +//! top-level `EditorSettings` lives in `settings.rs` and embeds these. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; + +/// Editor › General (mouse, scrolling, soft wraps, virtual space, saving). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct EditorGeneralSettings { + /// Change font size with ⌘ + mouse wheel. + pub wheel_zoom: bool, + /// Drag-and-drop of selected text inside the editor. + pub drag_and_drop: bool, + /// When the caret moves out of view: `minimize` scrolling | `center` the caret line. + pub caret_scroll: String, + /// Glob patterns (`;`-separated) of files soft-wrapped when `editor.softWrap` is on. + pub soft_wrap_files: String, + /// Wrapped parts keep the original line's indent. + pub soft_wrap_indent: bool, + /// Show virtual space at the file bottom. + pub scroll_past_end: bool, + /// Remove trailing spaces on save from: `modified` lines | `all` lines. + pub strip_trailing_scope: String, + /// Keep trailing spaces on the caret line when stripping. + pub keep_trailing_on_caret_line: bool, + pub remove_trailing_blank_lines: bool, + /// Copy as rich text (HTML with syntax colours) by default. + pub rich_text_copy: bool, +} + +impl Default for EditorGeneralSettings { + fn default() -> Self { + Self { + wheel_zoom: false, + drag_and_drop: true, + caret_scroll: "minimize".into(), + soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(), + soft_wrap_indent: true, + scroll_past_end: true, + strip_trailing_scope: "modified".into(), + keep_trailing_on_caret_line: true, + remove_trailing_blank_lines: false, + rich_text_copy: true, + } + } +} + +/// Editor › General › Appearance. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct EditorAppearanceSettings { + pub caret_blinking: bool, + /// Blink period in ms. + pub caret_blink_period: u32, + pub block_caret: bool, + pub line_numbers: bool, + pub method_separators: bool, + /// Sub-options of `editor.showWhitespace`. + pub whitespace_leading: bool, + pub whitespace_inner: bool, + pub whitespace_trailing: bool, + pub indent_guides: bool, + /// Right margin and visual guides from Code Style. + pub hard_wrap_guides: bool, + pub intention_bulb: bool, + /// Distraction Free Mode: width of the centred text column, in characters. + pub distraction_free_width: u32, +} + +impl Default for EditorAppearanceSettings { + fn default() -> Self { + Self { + caret_blinking: true, + caret_blink_period: 500, + block_caret: false, + line_numbers: true, + method_separators: false, + whitespace_leading: true, + whitespace_inner: true, + whitespace_trailing: true, + indent_guides: true, + hard_wrap_guides: true, + intention_bulb: true, + distraction_free_width: 80, + } + } +} + +/// Editor › General › Breadcrumbs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct BreadcrumbsSettings { + pub show: bool, + /// `top` | `bottom` of the editor. + pub placement: String, + /// Language ids breadcrumbs are turned off for. + pub disabled_languages: Vec, +} + +impl Default for BreadcrumbsSettings { + fn default() -> Self { + Self { show: false, placement: "bottom".into(), disabled_languages: Vec::new() } + } +} + +/// Editor › General › Code Completion. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CompletionSettings { + pub match_case: bool, + /// `first` letter only | `all` letters. + pub case_sensitivity: String, + /// Show suggestions as you type. + pub auto_popup: bool, + /// Insert the selected suggestion by pressing space, dot, or other context-dependent keys. + pub insert_on_special_keys: bool, + pub show_documentation: bool, + pub documentation_delay: u32, + pub parameter_info: bool, + pub parameter_info_delay: u32, +} + +impl Default for CompletionSettings { + fn default() -> Self { + Self { + match_case: true, + case_sensitivity: "first".into(), + auto_popup: true, + insert_on_special_keys: false, + show_documentation: true, + documentation_delay: 500, + parameter_info: true, + parameter_info_delay: 300, + } + } +} + +/// Editor › General › Auto Import (LSP code actions). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct AutoImportSettings { + pub add_unambiguous: bool, + pub optimize_on_the_fly: bool, +} + +/// Editor › General › Code Folding. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct FoldingSettings { + pub show_outline: bool, + pub file_header: bool, + pub imports: bool, + pub doc_comments: bool, +} + +impl Default for FoldingSettings { + fn default() -> Self { + Self { show_outline: true, file_header: true, imports: true, doc_comments: false } + } +} + +/// Editor › General › Editor Tabs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct EditorTabsSettings { + /// `top` | `none`. + pub placement: String, + pub multiple_rows: bool, + pub show_extension: bool, + /// Mark modified tabs with an asterisk. + pub mark_modified: bool, + pub show_close_button: bool, + pub preview_tab: bool, + pub open_at_end: bool, + pub tab_limit: u32, + /// When the limit is reached close `unchanged` (non-modified) tabs first, or the `lru` one. + pub closing_policy: String, + /// When the current tab is closed activate the `left` | `right` | `mru` tab. + pub activate_on_close: String, +} + +impl Default for EditorTabsSettings { + fn default() -> Self { + Self { + placement: "top".into(), + multiple_rows: false, + show_extension: true, + mark_modified: false, + show_close_button: true, + preview_tab: true, + open_at_end: false, + tab_limit: 10, + closing_policy: "unchanged".into(), + activate_on_close: "mru".into(), + } + } +} + +/// Editor › General › Smart Keys. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SmartKeysSettings { + /// Home moves caret to first non-whitespace character. + pub smart_home: bool, + /// Use "CamelHumps" words. + pub camel_humps: bool, + pub pair_brackets: bool, + pub pair_quotes: bool, + pub surround_selection: bool, + pub tab_out_of_pair: bool, + /// Reformat on paste: `none` | `indentBlock` | `indentEachLine`. + pub reformat_on_paste: String, + pub smart_indent: bool, + /// Backspace › Unindent: `nearest` indent position | `none`. + pub backspace_unindent: String, +} + +impl Default for SmartKeysSettings { + fn default() -> Self { + Self { + smart_home: true, + camel_humps: false, + pair_brackets: true, + pair_quotes: true, + surround_selection: true, + tab_out_of_pair: false, + reformat_on_paste: "indentBlock".into(), + smart_indent: true, + backspace_unindent: "nearest".into(), + } + } +} + +/// Editor › Code Editing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CodeEditingSettings { + /// Highlight usages of element at caret. + pub highlight_usages: bool, + pub highlight_braces: bool, + /// Highlight other occurrences of the selected text. + pub highlight_selection: bool, + pub quick_doc_on_hover: bool, + pub quick_doc_delay: u32, + /// Next / previous error goes to errors only (skips warnings). + pub next_error_errors_only: bool, + pub sticky_lines_max: u32, +} + +impl Default for CodeEditingSettings { + fn default() -> Self { + Self { + highlight_usages: true, + highlight_braces: true, + highlight_selection: true, + quick_doc_on_hover: true, + quick_doc_delay: 500, + next_error_errors_only: true, + sticky_lines_max: 5, + } + } +} + +/// Editor › Inlay Hints. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct InlayHintsSettings { + pub types: bool, + pub parameters: bool, + /// Hints of other kinds (chaining, closing labels…). + pub other: bool, + pub disabled_languages: Vec, + /// Code vision: usages above declarations. + pub code_vision_usages: bool, +} + +impl Default for InlayHintsSettings { + fn default() -> Self { + Self { types: true, parameters: true, other: true, disabled_languages: Vec::new(), code_vision_usages: true } + } +} + +/// Editor › Color Scheme override of one text attribute. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct TextAttributesOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub foreground: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub italic: Option, + /// `underline` | `wave` | `strikeout` | `none`. + #[serde(skip_serializing_if = "Option::is_none")] + pub effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effect_color: Option, +} + +/// Editor › Code Style › › Tabs and Indents (unset fields inherit). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct LanguageCodeStyle { + #[serde(skip_serializing_if = "Option::is_none")] + pub use_tab: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tab_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub indent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hard_wrap_at: Option, +} + +/// Editor › Code Style (General tab + per-language overrides). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CodeStyleSettings { + /// Line separator for new files: `system` | `lf` | `crlf` | `cr`. + pub line_separator: String, + pub hard_wrap_at: u32, + pub visual_guides: Vec, + /// Detect and use existing file indents for editing. + pub detect_indents: bool, + /// Enable EditorConfig support. + pub editorconfig: bool, + pub languages: BTreeMap, +} + +impl Default for CodeStyleSettings { + fn default() -> Self { + Self { + line_separator: "system".into(), + hard_wrap_at: 120, + visual_guides: Vec::new(), + detect_indents: true, + editorconfig: true, + languages: BTreeMap::new(), + } + } +} + +/// Editor › File Encodings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct EncodingSettings { + /// Fallback for files outside the project that are not valid UTF-8 (WHATWG label). + pub global: String, + /// Encoding of new files and fallback for project files that are not valid UTF-8. + pub project: String, + /// Fallback for `.properties` files. + pub properties: String, + /// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows). + pub utf8_bom: String, +} + +impl Default for EncodingSettings { + fn default() -> Self { + Self { + global: "utf-8".into(), + project: "utf-8".into(), + properties: "iso-8859-1".into(), + utf8_bom: "never".into(), + } + } +} diff --git a/crates/ide-core/src/error.rs b/crates/ide-core/src/error.rs new file mode 100644 index 00000000..7df81c9a --- /dev/null +++ b/crates/ide-core/src/error.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +pub type IdeResult = Result; + +/// The single error type that crosses the IPC boundary. +/// +/// Crates keep rich internal errors and convert at their public edge; the UI +/// switches on `kind` and shows `message`. +#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize, Type)] +#[error("{kind:?}: {message}")] +pub struct IdeError { + pub kind: ErrorKind, + pub message: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ErrorKind { + NotFound, + AlreadyExists, + PermissionDenied, + InvalidInput, + /// Document version mismatch; the UI must resync the full text. + OutOfSync, + Cancelled, + /// A required external tool (language server, git, debugger) is unavailable. + Unavailable, + Io, + Protocol, + Internal, +} + +impl IdeError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { kind, message: message.into() } + } + pub fn not_found(m: impl Into) -> Self { + Self::new(ErrorKind::NotFound, m) + } + pub fn invalid(m: impl Into) -> Self { + Self::new(ErrorKind::InvalidInput, m) + } + pub fn internal(m: impl Into) -> Self { + Self::new(ErrorKind::Internal, m) + } + pub fn unavailable(m: impl Into) -> Self { + Self::new(ErrorKind::Unavailable, m) + } + pub fn cancelled() -> Self { + Self::new(ErrorKind::Cancelled, "cancelled") + } +} + +impl From for IdeError { + fn from(e: std::io::Error) -> Self { + use std::io::ErrorKind as K; + let kind = match e.kind() { + K::NotFound => ErrorKind::NotFound, + K::AlreadyExists => ErrorKind::AlreadyExists, + K::PermissionDenied => ErrorKind::PermissionDenied, + K::InvalidInput | K::InvalidData => ErrorKind::InvalidInput, + _ => ErrorKind::Io, + }; + Self::new(kind, e.to_string()) + } +} diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs new file mode 100644 index 00000000..3cfabb35 --- /dev/null +++ b/crates/ide-core/src/event.rs @@ -0,0 +1,189 @@ +//! The backend → UI event bus. +//! +//! Feature crates emit through an [`EventSink`]; `src-tauri` implements it by +//! forwarding to the webview as the single `ide://event` Tauri event. High +//! volume streams (terminal bytes, search hits, process output) do *not* use +//! this bus; they use per-request `tauri::ipc::Channel`s. + +use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum IdeEvent { + /// Files or directories changed on disk (after debouncing, ignore rules applied). + FsChanged { + created: Vec, + modified: Vec, + removed: Vec, + }, + /// The backend changed a document (reload from disk, formatting, refactoring). + /// The UI applies `changes` on top of `base_version`, yielding `version`. + DocPatched { + doc: DocId, + base_version: u32, + version: u32, + changes: Vec, + }, + /// A document's dirty flag changed (saved / modified externally). + DocDirty { + doc: DocId, + dirty: bool, + }, + /// Full replacement of diagnostics for one file from one producer. + Diagnostics { + path: crate::IpcPath, + source: String, + items: Vec, + }, + /// Progress of a background task; `fraction` is `None` when indeterminate. + Task { + id: TaskId, + title: String, + message: Option, + fraction: Option, + done: bool, + }, + /// Project indexing state changed (dumb mode on/off). + IndexState { + indexing: bool, + files: u32, + symbols: u32, + }, + /// Git status / branch information should be refreshed. + VcsChanged { + root: crate::IpcPath, + }, + LanguageServer { + server: ServerId, + name: String, + status: ServerStatus, + message: Option, + }, + /// A language server asked clients to re-request one kind of data for its + /// open documents (`workspace/inlayHint/refresh`, …), e.g. after indexing. + LspRefresh { + server: ServerId, + kind: LspRefreshKind, + }, + TerminalExited { + terminal: TerminalId, + code: Option, + }, + RunExited { + run: RunId, + code: Option, + }, + Debug { + session: DebugSessionId, + event: DebugEvent, + }, + Notify { + level: NotifyLevel, + title: String, + body: Option, + }, + /// A setting was written (any window); the windows it concerns (all for a + /// global value, the project's for a project value) re-read their effective settings. + SettingsChanged { + key: String, + }, + /// The project's source / test / resource / excluded roots changed (build files + /// changed or folders were marked); the UI re-reads them with `project_roots`. + ProjectRootsChanged { + root: crate::IpcPath, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ServerStatus { + Starting, + Ready, + Busy, + Crashed, + Stopped, + Missing, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum LspRefreshKind { + InlayHints, + SemanticTokens, + CodeLens, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum NotifyLevel { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DebugEvent { + /// First event of every session (only build output of a root session's + /// `cargo` step can precede it). `parent` is set for adapter-started child sessions. + Started { + name: String, + adapter: String, + parent: Option, + }, + Initialized, + Stopped { + thread_id: i64, + reason: String, + description: Option, + all_threads_stopped: bool, + }, + Continued { + thread_id: i64, + all_threads_continued: bool, + }, + Output { + category: String, + output: String, + }, + Thread { + thread_id: i64, + started: bool, + }, + BreakpointsChanged { + path: crate::IpcPath, + }, + Terminated, + Exited { + code: i32, + }, + /// java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes + /// changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`. + HotCodeReplace { + change_type: String, + message: String, + }, +} + +/// Where backend crates publish [`IdeEvent`]s. Cheap to clone via `Arc`. +pub trait EventSink: Send + Sync + 'static { + fn emit(&self, event: IdeEvent); +} + +impl EventSink for F { + fn emit(&self, event: IdeEvent) { + self(event) + } +} + +pub type SharedSink = Arc; + +/// Discards events; for tests and headless tools. +pub struct NullSink; + +impl EventSink for NullSink { + fn emit(&self, _: IdeEvent) {} +} diff --git a/crates/ide-core/src/ids.rs b/crates/ide-core/src/ids.rs new file mode 100644 index 00000000..a2d25a70 --- /dev/null +++ b/crates/ide-core/src/ids.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::atomic::{AtomicU32, Ordering}; + +macro_rules! id_type { + ($($(#[$doc:meta])* $name:ident),* $(,)?) => {$( + $(#[$doc])* + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Type)] + #[serde(transparent)] + pub struct $name(pub u32); + + impl $name { + /// Allocates a process-unique id. + pub fn next() -> Self { + static NEXT: AtomicU32 = AtomicU32::new(1); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}#{}", stringify!($name), self.0) + } + } + )*}; +} + +id_type!( + /// An open document buffer. + DocId, + /// A cancellable, streaming backend request issued by the UI. + RequestId, + /// An integrated terminal session. + TerminalId, + /// A process started from a run configuration. + RunId, + /// A debug adapter session. + DebugSessionId, + /// A running language server instance. + ServerId, + /// A long-running background task shown in the status bar. + TaskId, +); + +/// Language identifier, e.g. `rust`, `typescript`, `kotlin`. Matches LSP `languageId`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Type)] +#[serde(transparent)] +pub struct LanguageId(pub String); + +impl LanguageId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } + pub fn plain_text() -> Self { + Self::new("plaintext") + } +} + +impl std::fmt::Display for LanguageId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} diff --git a/crates/ide-core/src/ipc_path.rs b/crates/ide-core/src/ipc_path.rs new file mode 100644 index 00000000..994db022 --- /dev/null +++ b/crates/ide-core/src/ipc_path.rs @@ -0,0 +1,148 @@ +//! Paths on the IPC boundary are `/`-separated on every OS, so the UI can split, +//! join and compare them without caring about Windows. Path fields of IPC types +//! are [`IpcPath`]: a `PathBuf` (derefs to `Path`) that serializes `C:\a\b` (and `\\?\C:\a\b`) as +//! `C:/a/b` and is a plain `string` in the generated bindings. +//! +//! Incoming paths need nothing: Windows accepts `C:/a/b`, and `Path` equality +//! and hashing are component-wise. + +use std::borrow::Borrow; +use std::ops::Deref; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`. +pub fn to_ipc(p: &Path) -> String { + #[cfg(windows)] + { + dunce::simplified(p).to_string_lossy().replace('\\', "/") + } + #[cfg(not(windows))] + { + p.to_string_lossy().into_owned() + } +} + +/// A path field of an IPC type. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, specta::Type)] +#[specta(transparent)] +pub struct IpcPath(#[specta(type = String)] pub PathBuf); + +impl IpcPath { + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +impl Serialize for IpcPath { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&to_ipc(&self.0)) + } +} + +impl<'de> Deserialize<'de> for IpcPath { + fn deserialize>(d: D) -> Result { + PathBuf::deserialize(d).map(Self) + } +} + +impl Deref for IpcPath { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl AsRef for IpcPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Borrow for IpcPath { + fn borrow(&self) -> &Path { + &self.0 + } +} + +impl From for IpcPath { + fn from(p: PathBuf) -> Self { + Self(p) + } +} + +impl From<&Path> for IpcPath { + fn from(p: &Path) -> Self { + Self(p.to_path_buf()) + } +} + +impl From<&PathBuf> for IpcPath { + fn from(p: &PathBuf) -> Self { + Self(p.clone()) + } +} + +impl From for IpcPath { + fn from(p: String) -> Self { + Self(p.into()) + } +} + +impl From<&str> for IpcPath { + fn from(p: &str) -> Self { + Self(p.into()) + } +} + +impl From for PathBuf { + fn from(p: IpcPath) -> Self { + p.0 + } +} + +impl PartialEq for IpcPath { + fn eq(&self, other: &PathBuf) -> bool { + self.0 == *other + } +} + +impl PartialEq for IpcPath { + fn eq(&self, other: &Path) -> bool { + self.0 == other + } +} + +impl PartialEq<&Path> for IpcPath { + fn eq(&self, other: &&Path) -> bool { + self.0 == *other + } +} + +impl PartialEq for PathBuf { + fn eq(&self, other: &IpcPath) -> bool { + *self == other.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_json() { + let p = IpcPath::from("a/b"); + assert_eq!(serde_json::to_string(&p).unwrap(), r#""a/b""#); + assert_eq!(serde_json::from_str::(r#""a/b""#).unwrap(), p); + } + + #[cfg(windows)] + #[test] + fn windows_paths_use_forward_slashes() { + assert_eq!(to_ipc(Path::new(r"C:\a\b")), "C:/a/b"); + assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b"); + assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b"); + assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#); + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs new file mode 100644 index 00000000..ac3e2d60 --- /dev/null +++ b/crates/ide-core/src/lib.rs @@ -0,0 +1,22 @@ +//! Shared vocabulary for every OpenIDEA crate. +//! +//! Anything that crosses a crate boundary or the IPC boundary lives here, so +//! feature crates depend on `ide-core` and never on each other's internals. +//! All IPC-facing types derive `specta::Type`, which generates `src/bindings.ts`. + +pub mod editor_settings; +pub mod error; +pub mod event; +pub mod ids; +pub mod ipc_path; +pub mod project_dir; +pub mod settings; +pub mod text; +pub mod workflow_settings; + +pub use error::{IdeError, IdeResult}; +pub use event::{EventSink, IdeEvent, NullSink, SharedSink}; +pub use ids::*; +pub use ipc_path::IpcPath; +pub use text::*; +pub use tokio_util::sync::CancellationToken; diff --git a/crates/ide-core/src/project_dir.rs b/crates/ide-core/src/project_dir.rs new file mode 100644 index 00000000..dcd7d449 --- /dev/null +++ b/crates/ide-core/src/project_dir.rs @@ -0,0 +1,11 @@ +//! The per-project config folder (`/.openidea/`). + +use std::path::{Path, PathBuf}; + +/// Folder name under the project root. +pub const DIR: &str = ".openidea"; + +/// Where `rel` (e.g. `settings.toml`) is read from and written to. +pub fn path(root: &Path, rel: impl AsRef) -> PathBuf { + root.join(DIR).join(rel) +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs new file mode 100644 index 00000000..d67d2d51 --- /dev/null +++ b/crates/ide-core/src/settings.rs @@ -0,0 +1,465 @@ +//! User-facing settings. Layered: defaults → global `settings.toml` → +//! project `.openidea/settings.toml`. Loading/merging lives in `ide-workspace`. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; + +pub use crate::editor_settings::*; +pub use crate::workflow_settings::*; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub general: GeneralSettings, + pub appearance: AppearanceSettings, + pub editor: EditorSettings, + pub keymap: String, + pub files: FileSettings, + /// Per-action keybinding overrides on top of the `keymap` preset: action id → + /// bindings (`Meta+Shift+O`, chords space-separated). An empty list unbinds. + pub keymap_overrides: BTreeMap>, + /// User keymaps (Keymap › Duplicate…): id → name, parent preset and the + /// keymap's own overrides. `keymap` may name one of these ids. + pub custom_keymaps: BTreeMap, + pub notifications: NotificationSettings, + /// Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). + pub languages: LanguagesSettings, + /// Project Structure: source / test / resource / excluded folders (project-relative). + pub project_structure: ProjectStructureSettings, + /// Version Control (Git, Commit, Confirmation, Issue Navigation). + pub vcs: VcsSettings, + /// Build, Execution, Deployment › Console. + pub console: ConsoleSettings, + pub debugger: DebuggerSettings, + pub run: RunSettings, + /// Tools › Terminal. + pub terminal: TerminalSettings, + pub actions_on_save: ActionsOnSaveSettings, + pub external_tools: Vec, +} + +impl Default for Settings { + fn default() -> Self { + Self { + general: GeneralSettings::default(), + appearance: AppearanceSettings::default(), + editor: EditorSettings::default(), + keymap: default_keymap().into(), + files: FileSettings::default(), + keymap_overrides: BTreeMap::new(), + custom_keymaps: BTreeMap::new(), + notifications: NotificationSettings::default(), + languages: LanguagesSettings::default(), + project_structure: ProjectStructureSettings::default(), + vcs: VcsSettings::default(), + console: ConsoleSettings::default(), + debugger: DebuggerSettings::default(), + run: RunSettings::default(), + terminal: TerminalSettings::default(), + actions_on_save: ActionsOnSaveSettings::default(), + external_tools: Vec::new(), + } + } +} + +/// IDEA's keymap for the OS (`src/platform/keymaps/`). +fn default_keymap() -> &'static str { + if cfg!(target_os = "macos") { "intellij-macos" } else { "intellij-windows" } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomKeymap { + pub name: String, + /// Bundled preset the keymap derives from, e.g. `intellij-macos`. + pub parent: String, + /// Action id → bindings on top of `parent`; an empty list unbinds. + pub overrides: BTreeMap>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GeneralSettings { + pub reopen_last_project: bool, + pub confirm_exit: bool, + /// Where Open… puts a project while one is open: `ask` | `new` | `same`. + pub open_project_in: String, + /// Initial folder of the Open / Clone dialogs; empty = the OS default. + pub default_project_directory: String, + /// Save all files after `idle_seconds` without input. + pub save_on_idle: bool, + pub idle_seconds: u32, + pub date_format: DateFormatSettings, +} + +impl Default for GeneralSettings { + fn default() -> Self { + Self { + reopen_last_project: true, + confirm_exit: false, + open_project_in: "ask".into(), + default_project_directory: String::new(), + save_on_idle: false, + idle_seconds: 15, + date_format: DateFormatSettings::default(), + } + } +} + +/// System Settings › Date Formats (Git log, Local History, blame). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DateFormatSettings { + /// Use `pattern` / `use24_hour` instead of the system locale's format. + pub override_system: bool, + /// `dd`, `MM`, `MMM`, `yy`, `yyyy` placeholders. + pub pattern: String, + pub use24_hour: bool, + /// "Today", "Yesterday", "5 min ago". + pub pretty: bool, +} + +impl Default for DateFormatSettings { + fn default() -> Self { + Self { override_system: false, pattern: "dd.MM.yyyy".into(), use24_hour: true, pretty: true } + } +} + +/// Notifications page: global switches and the per-group display type. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct NotificationSettings { + pub balloons: bool, + pub sound: bool, + /// Group id → display; groups not listed use their default. + pub groups: BTreeMap, +} + +impl Default for NotificationSettings { + fn default() -> Self { + Self { balloons: true, sound: false, groups: BTreeMap::new() } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct NotificationGroupSettings { + /// `balloon` | `sticky` | `none` + pub display: String, + /// Also list it in the Notifications tool window. + pub log: bool, +} + +impl Default for NotificationGroupSettings { + fn default() -> Self { + Self { display: "balloon".into(), log: true } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct AppearanceSettings { + /// `dark` | `light` | `system` | `dark-hc` (high-contrast dark) + pub theme: String, + pub accent: String, + pub ui_font_size: f32, + pub locale: String, + /// Follow the OS light/dark mode with the preferred themes below. + pub sync_with_os: bool, + pub preferred_light_theme: String, + pub preferred_dark_theme: String, + /// IDE zoom in percent (50–300). + pub zoom: u32, + /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). + pub use_custom_font: bool, + pub ui_font_family: String, + pub contrast_scrollbars: bool, + /// Adjust colours for red-green vision deficiency. + pub red_green_vision: bool, + pub different_tool_window_background: bool, + pub compact_mode: bool, + pub full_path_in_title: bool, + pub project_colors_in_toolbar: bool, + pub dnd_with_alt_only: bool, + pub menu_icons: bool, + /// `subpixel` | `greyscale` | `none` + pub ide_antialiasing: String, + pub editor_antialiasing: String, + pub show_tool_window_bars: bool, + pub show_tool_window_names: bool, + pub widescreen_tool_windows: bool, + /// 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, +} + +impl Default for AppearanceSettings { + fn default() -> Self { + Self { + theme: "light".into(), + accent: "#3871e1".into(), + ui_font_size: 13.0, + locale: "en".into(), + sync_with_os: false, + preferred_light_theme: "light".into(), + preferred_dark_theme: "dark".into(), + zoom: 100, + use_custom_font: false, + ui_font_family: "Inter".into(), + contrast_scrollbars: false, + red_green_vision: false, + different_tool_window_background: false, + compact_mode: false, + full_path_in_title: false, + project_colors_in_toolbar: true, + dnd_with_alt_only: false, + menu_icons: true, + ide_antialiasing: "greyscale".into(), + editor_antialiasing: "greyscale".into(), + show_tool_window_bars: true, + show_tool_window_names: true, + widescreen_tool_windows: false, + status_bar_widgets: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct EditorSettings { + pub font_family: String, + pub font_size: f32, + pub line_height: f32, + pub tab_size: u32, + pub insert_spaces: bool, + pub soft_wrap: bool, + pub show_whitespace: bool, + pub inlay_hints: bool, + pub sticky_scroll: bool, + pub format_on_save: bool, + /// Editor colour scheme id; `theme-default` follows the IDE theme. + pub color_scheme: String, + /// Color Scheme edits: scheme id → attribute id → override. + pub color_overrides: BTreeMap>, + pub ligatures: bool, + /// Fallback font for glyphs the main font lacks; empty = none. + pub fallback_font: String, + pub general: EditorGeneralSettings, + pub appearance: EditorAppearanceSettings, + pub breadcrumbs: BreadcrumbsSettings, + pub completion: CompletionSettings, + pub auto_import: AutoImportSettings, + pub folding: FoldingSettings, + pub tabs: EditorTabsSettings, + pub smart_keys: SmartKeysSettings, + pub code_editing: CodeEditingSettings, + pub inlay: InlayHintsSettings, + /// Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`). + pub gutter_icons_disabled: Vec, + pub show_gutter_icons: bool, + pub code_style: CodeStyleSettings, + pub encodings: EncodingSettings, +} + +impl Default for EditorSettings { + fn default() -> Self { + Self { + font_family: "JetBrains Mono".into(), + font_size: 13.0, + line_height: 22.0, + tab_size: 4, + insert_spaces: true, + soft_wrap: true, + show_whitespace: false, + inlay_hints: true, + sticky_scroll: true, + format_on_save: false, + color_scheme: "theme-default".into(), + color_overrides: BTreeMap::new(), + ligatures: false, + fallback_font: String::new(), + general: EditorGeneralSettings::default(), + appearance: EditorAppearanceSettings::default(), + breadcrumbs: BreadcrumbsSettings::default(), + completion: CompletionSettings::default(), + auto_import: AutoImportSettings::default(), + folding: FoldingSettings::default(), + tabs: EditorTabsSettings::default(), + smart_keys: SmartKeysSettings::default(), + code_editing: CodeEditingSettings::default(), + inlay: InlayHintsSettings::default(), + gutter_icons_disabled: Vec::new(), + show_gutter_icons: true, + code_style: CodeStyleSettings::default(), + encodings: EncodingSettings::default(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct FileSettings { + /// Save on focus loss / tab switch, like IDEA. + pub auto_save: bool, + pub exclude: Vec, + 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, +} + +impl Default for FileSettings { + fn default() -> Self { + Self { + auto_save: true, + exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()], + trim_trailing_whitespace: true, + ensure_final_newline: true, + local_history_days: 5, + local_history_max_mb: 512, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct LanguagesSettings { + pub python: PythonSettings, + pub node: NodeSettings, + pub rust: RustSettings, + pub go: GoSettings, + pub typescript: TypeScriptSettings, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct PythonSettings { + /// Project interpreter executable; empty = auto-detect (project venv, else `python3`). + pub interpreter: String, + /// Activate the interpreter's virtualenv in new terminal sessions. + pub activate_virtualenv: bool, +} + +impl Default for PythonSettings { + fn default() -> Self { + Self { interpreter: String::new(), activate_virtualenv: true } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct NodeSettings { + /// `node` executable; empty = the one on `PATH`. + pub interpreter: String, + /// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`. + pub package_manager: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RustSettings { + /// Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. + pub toolchain: String, + /// Standard library sources (`RUST_SRC_PATH`); empty = from the toolchain. + pub stdlib: String, + /// Run the external linter on save (rust-analyzer `checkOnSave`). + pub check_on_save: bool, + /// `check` | `clippy` (rust-analyzer `check.command`). + pub check_command: String, + /// Extra arguments for rustfmt (rust-analyzer `rustfmt.extraArgs`), space-separated. + pub rustfmt_args: String, +} + +impl Default for RustSettings { + fn default() -> Self { + Self { + toolchain: String::new(), + stdlib: String::new(), + check_on_save: true, + check_command: "check".into(), + rustfmt_args: String::new(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GoSettings { + /// Empty = the `go` on `PATH`. + pub goroot: String, + /// Empty = `go env GOPATH`. + pub gopath: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct TypeScriptSettings { + /// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`), + /// or a path to a TypeScript `lib` directory. + pub version: String, +} + +impl Default for TypeScriptSettings { + fn default() -> Self { + Self { version: "bundled".into() } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ProjectStructureSettings { + /// Project-relative folders, `/`-separated. + pub source_roots: Vec, + pub test_roots: Vec, + pub resource_roots: Vec, + pub test_resource_roots: Vec, + /// Excluded from the project tree, search and indexing. + pub excluded: Vec, + /// Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked. + pub unmarked: Vec, +} + +impl Settings { + /// `files.exclude` plus the Project Structure excluded folders as globs. + pub fn exclude_globs(&self) -> Vec { + let mut out = self.files.exclude.clone(); + for dir in &self.project_structure.excluded { + let dir = dir.trim().trim_matches('/'); + if !dir.is_empty() { + out.push(format!("/{dir}")); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keymap_overrides_are_camel_case_and_default_empty() { + let s: Settings = + serde_json::from_str(r#"{"keymapOverrides":{"file.save":["Meta+S","Ctrl+S"],"vcs.commit":[]}}"#).unwrap(); + assert_eq!(s.keymap_overrides["file.save"], vec!["Meta+S", "Ctrl+S"]); + assert!(s.keymap_overrides["vcs.commit"].is_empty()); + assert_eq!(s.keymap, default_keymap()); + let json = serde_json::to_value(Settings::default()).unwrap(); + assert_eq!(json["keymapOverrides"], serde_json::json!({})); + } + + #[test] + fn custom_keymaps_round_trip() { + let s: Settings = serde_json::from_str( + r#"{"keymap":"custom.mine","customKeymaps":{"custom.mine":{"name":"Mine","parent":"vscode-macos","overrides":{"file.save":[]}}}}"#, + ) + .unwrap(); + let k = &s.custom_keymaps["custom.mine"]; + assert_eq!((k.name.as_str(), k.parent.as_str()), ("Mine", "vscode-macos")); + assert!(k.overrides["file.save"].is_empty()); + assert_eq!(serde_json::to_value(Settings::default()).unwrap()["customKeymaps"], serde_json::json!({})); + } +} diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs new file mode 100644 index 00000000..45b9caa2 --- /dev/null +++ b/crates/ide-core/src/text.rs @@ -0,0 +1,185 @@ +//! Text coordinates used across the IPC boundary. +//! +//! Convention: **all offsets and columns crossing IPC are UTF-16 code units**, +//! matching JavaScript strings, CodeMirror and the LSP default encoding. +//! Lines are 0-based. Conversion to/from byte offsets lives in `ide-text`. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +/// A 0-based line / UTF-16 column position. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] +pub struct Pos { + pub line: u32, + pub col: u32, +} + +impl Pos { + pub const fn new(line: u32, col: u32) -> Self { + Self { line, col } + } +} + +/// Half-open range `[start, end)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] +pub struct Range { + pub start: Pos, + pub end: Pos, +} + +impl Range { + pub const fn new(start: Pos, end: Pos) -> Self { + Self { start, end } + } + pub const fn point(p: Pos) -> Self { + Self { start: p, end: p } + } + pub fn is_empty(&self) -> bool { + self.start == self.end + } +} + +/// A range inside a specific file, or inside a virtual document (library +/// class contents, decompiled code…) that only a language server can provide. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +pub struct Location { + /// File path; for virtual targets the URI itself (equal to `uri`), which + /// the UI opens as a read-only virtual document. + pub path: crate::IpcPath, + pub range: Range, + /// Set for non-`file:` targets, e.g. `jdt://contents/…`. + #[specta(optional)] + pub uri: Option, +} + +/// Whether `s` is a non-`file:` URI (`scheme:` with a scheme of 2+ chars, so +/// Windows drive letters don't count). +pub fn is_virtual_uri(s: &str) -> bool { + let Some(colon) = s.find(':') else { return false }; + let scheme = &s[..colon]; + scheme.len() >= 2 + && !scheme.eq_ignore_ascii_case("file") + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + +impl Location { + pub fn file(path: PathBuf, range: Range) -> Self { + Self { path: path.into(), range, uri: None } + } +} + +/// Replace the text in `[from, to)` (UTF-16 offsets into the document *before* +/// the change set is applied) with `insert`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct TextChange { + pub from: u32, + pub to: u32, + pub insert: String, +} + +/// Ordered, non-overlapping changes, all expressed in the coordinates of the +/// original document (CodeMirror `ChangeSet` semantics). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct ChangeSet { + pub changes: Vec, +} + +/// An edit expressed in line/column coordinates (LSP `TextEdit`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct TextEdit { + pub range: Range, + pub new_text: String, +} + +/// Edits to many files, applied atomically from the user's point of view. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WorkspaceEdit { + pub files: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FileEdit { + Edit { path: crate::IpcPath, edits: Vec }, + Create { path: crate::IpcPath, overwrite: bool }, + Rename { from: crate::IpcPath, to: crate::IpcPath }, + Delete { path: crate::IpcPath, recursive: bool }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct Diagnostic { + pub range: Range, + pub severity: Severity, + pub message: String, + /// Producer, e.g. `rust-analyzer`, `eslint`. + pub source: Option, + pub code: Option, + #[serde(default)] + pub unnecessary: bool, + #[serde(default)] + pub deprecated: bool, +} + +/// Symbol kinds, a stable subset of LSP `SymbolKind`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SymbolKind { + File, + Module, + Namespace, + Package, + Class, + Interface, + Struct, + Enum, + EnumMember, + Trait, + Function, + Method, + Constructor, + Field, + Property, + Variable, + Constant, + TypeParameter, + TypeAlias, + Macro, + Event, + Operator, + Key, + Unknown, +} + +impl SymbolKind { + /// "Class-like" symbols are what IDEA's *Go to Class* (⌘O) searches. + pub fn is_type_like(self) -> bool { + matches!(self, Self::Class | Self::Interface | Self::Struct | Self::Enum | Self::Trait | Self::TypeAlias) + } +} + +/// A node of a document outline (Structure tool window, breadcrumbs). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct OutlineItem { + pub name: String, + pub detail: Option, + pub kind: SymbolKind, + /// Whole extent of the symbol. + pub range: Range, + /// The identifier itself; where navigation lands. + pub selection_range: Range, + pub children: Vec, +} + +/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched. +pub type MatchIndices = Vec; diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs new file mode 100644 index 00000000..f1996e0c --- /dev/null +++ b/crates/ide-core/src/workflow_settings.rs @@ -0,0 +1,321 @@ +//! Settings of IDEA's Version Control, Build, Execution, Deployment and Tools +//! groups. The fields hang off `Settings` in `settings.rs`. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; + +/// Version Control (main page) and its sub-pages. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct VcsSettings { + /// Colour folders that contain changed files (project tree, tabs). + pub show_dirty_directories: bool, + /// Stop file history (Show History) after `history_limit` commits. + pub limit_history: bool, + pub history_limit: u32, + pub git: GitSettings, + pub commit: CommitSettings, + pub confirmation: VcsConfirmationSettings, + /// Issue Navigation: commit-message patterns turned into links. + pub issue_navigation: Vec, +} + +impl Default for VcsSettings { + fn default() -> Self { + Self { + show_dirty_directories: true, + limit_history: true, + history_limit: 1000, + git: GitSettings::default(), + commit: CommitSettings::default(), + confirmation: VcsConfirmationSettings::default(), + issue_navigation: Vec::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GitSettings { + /// Path to the git executable; empty = auto-detect. + pub executable: String, + /// Default of Update Project: `merge` | `rebase`. + pub update_method: String, + /// Push rejected (non-fast-forward): update the branch and push again. + pub auto_update_on_push_rejected: bool, + pub warn_crlf: bool, + pub warn_detached_head: bool, + /// Background check for incoming commits: `auto` | `always` | `never`. + pub incoming_check: String, + /// Branch name globs, `;`/`,`-separated; force push to them is prohibited. + pub protected_branches: String, + /// Initial state of Commit Options › Sign-off Commit. + pub sign_off: bool, +} + +impl Default for GitSettings { + fn default() -> Self { + Self { + executable: String::new(), + update_method: "merge".into(), + auto_update_on_push_rejected: false, + warn_crlf: true, + warn_detached_head: true, + incoming_check: "auto".into(), + protected_branches: "master; main".into(), + sign_off: false, + } + } +} + +/// Version Control › Commit: commit checks and commit-message inspections. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CommitSettings { + pub analyze_code: bool, + pub check_todo: bool, + pub reformat: bool, + pub optimize_imports: bool, + pub cleanup: bool, + pub blank_line_after_subject: bool, + pub limit_subject: bool, + pub subject_limit: u32, + pub limit_body: bool, + pub body_limit: u32, + pub show_right_margin: bool, + pub wrap_at_margin: bool, +} + +impl Default for CommitSettings { + fn default() -> Self { + Self { + analyze_code: true, + check_todo: true, + reformat: false, + optimize_imports: false, + cleanup: false, + blank_line_after_subject: true, + limit_subject: true, + subject_limit: 72, + limit_body: false, + body_limit: 72, + show_right_margin: true, + wrap_at_margin: false, + } + } +} + +/// Version Control › Confirmation. Values: `ask` | `silently` | `never`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct VcsConfirmationSettings { + pub on_create: String, + pub on_delete: String, +} + +impl Default for VcsConfirmationSettings { + fn default() -> Self { + Self { on_create: "ask".into(), on_delete: "ask".into() } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct IssueLink { + /// Regular expression matched in commit messages. + pub pattern: String, + /// Link with `$0` (whole match) / `$1`… (groups) placeholders. + pub link: String, +} + +/// Build, Execution, Deployment › Console. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ConsoleSettings { + pub soft_wrap: bool, + pub override_buffer: bool, + pub buffer_kb: u32, + /// Encoding of process output (`UTF-8`, `windows-1252`, `GBK`…). + pub encoding: String, + /// Consecutive lines containing any of these are folded. + pub fold_patterns: Vec, +} + +impl Default for ConsoleSettings { + fn default() -> Self { + Self { + soft_wrap: false, + override_buffer: true, + buffer_kb: 1024, + encoding: "UTF-8".into(), + fold_patterns: vec![ + "at java.base/jdk.internal.reflect.".into(), + "at java.base/java.lang.reflect.Method.invoke".into(), + "at org.junit.".into(), + "at node:internal/".into(), + " Self { + Self { + show_on_breakpoint: true, + focus_app_on_breakpoint: false, + hide_on_termination: false, + breakpoints_any_file: false, + inline_values: true, + value_tooltip: true, + value_tooltip_delay: 700, + skip_library_code: true, + python: PythonDebuggerSettings::default(), + } + } +} + +/// Python Debugger (debugpy launch options). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct PythonDebuggerSettings { + pub attach_subprocess: bool, + pub gevent: bool, +} + +impl Default for PythonDebuggerSettings { + fn default() -> Self { + Self { attach_subprocess: true, gevent: false } + } +} + +/// Run/Debug configuration behaviour. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RunSettings { + pub confirm_rerun: bool, + pub show_on_start: bool, + /// Grace period between interrupt and kill when stopping a process. + pub termination_timeout_ms: u32, +} + +impl Default for RunSettings { + fn default() -> Self { + Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 } + } +} + +/// Tools › Terminal. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct TerminalSettings { + /// Empty = project root; relative paths resolve against it. + pub start_directory: String, + pub env: BTreeMap, + /// Shell command line; empty = the login shell. + pub shell_path: String, + pub tab_name: String, + pub audible_bell: bool, + pub close_on_exit: bool, + pub mouse_reporting: bool, + pub copy_on_selection: bool, + pub paste_on_middle_click: bool, + pub override_ide_shortcuts: bool, + pub shell_integration: bool, + pub highlight_hyperlinks: bool, + pub option_as_meta: bool, + /// `block` | `underline` | `bar`. + pub cursor_shape: String, + pub cursor_blink: bool, + pub use_editor_font: bool, + pub font_family: String, + pub font_size: f32, + /// Multiple of the cell height. + pub line_height: f32, +} + +impl Default for TerminalSettings { + fn default() -> Self { + Self { + start_directory: String::new(), + env: BTreeMap::new(), + shell_path: String::new(), + tab_name: "Local".into(), + audible_bell: true, + close_on_exit: true, + mouse_reporting: true, + copy_on_selection: false, + paste_on_middle_click: true, + override_ide_shortcuts: true, + shell_integration: true, + highlight_hyperlinks: true, + option_as_meta: 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, + } + } +} + +/// Tools › Actions on Save (Reformat code is `editor.formatOnSave`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ActionsOnSaveSettings { + pub optimize_imports: bool, + /// Run code cleanup (`source.fixAll`). + pub cleanup: bool, +} + +/// Tools › External Tools entry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalTool { + pub id: String, + pub name: String, + pub description: String, + pub group: String, + pub enabled: bool, + pub program: String, + /// Arguments with macros (`$FilePath$`, `$ProjectFileDir$`…), shell-quoted. + pub arguments: String, + pub working_directory: String, + pub synchronize_after: bool, + pub open_console: bool, +} + +impl Default for ExternalTool { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + group: "External Tools".into(), + enabled: true, + program: String::new(), + arguments: String::new(), + working_directory: "$ProjectFileDir$".into(), + synchronize_after: true, + open_console: true, + } + } +} diff --git a/crates/ide-coverage/Cargo.toml b/crates/ide-coverage/Cargo.toml new file mode 100644 index 00000000..d13af03a --- /dev/null +++ b/crates/ide-coverage/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ide-coverage" +description = "Run with Coverage: coverage-tool launch plans (cargo-llvm-cov, vitest / jest / c8, coverage.py, go test, JaCoCo) and report parsers (lcov, Go cover profiles, JaCoCo XML)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-run.workspace = true +quick-xml = "0.42" +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-coverage/src/goprofile.rs b/crates/ide-coverage/src/goprofile.rs new file mode 100644 index 00000000..842e784a --- /dev/null +++ b/crates/ide-coverage/src/goprofile.rs @@ -0,0 +1,106 @@ +//! Go cover profiles (`go test -coverprofile`): +//! +//! ```text +//! mode: set +//! example.com/m/pkg/calc.go:3.24,5.2 1 1 +//! ``` +//! +//! Each block `file:startLine.startCol,endLine.endCol statements count` +//! covers its lines; a line touched by covered and uncovered blocks is partial. + +use crate::Accumulator; +use std::path::{Path, PathBuf}; + +/// File of an import path: below `module` → `module_dir`, absolute paths as they are. +fn resolve(file: &str, module: &str, module_dir: &Path) -> PathBuf { + let p = Path::new(file); + if p.is_absolute() { + return p.to_path_buf(); + } + if !module.is_empty() { + if file == module { + return module_dir.to_path_buf(); + } + if let Some(rest) = file.strip_prefix(module).and_then(|r| r.strip_prefix('/')) { + return module_dir.join(rest); + } + } + module_dir.join(file) +} + +pub(crate) fn parse(text: &str, module: &str, module_dir: &Path) -> Accumulator { + let mut acc = Accumulator::default(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with("mode:") { + continue; + } + // `name.go:3.24,5.2 1 1` — the file name may contain ':' only on Windows drives, so split from the right. + let mut fields = line.rsplitn(3, ' '); + let (Some(count), Some(_stmts), Some(loc)) = (fields.next(), fields.next(), fields.next()) else { continue }; + let Ok(count) = count.parse::() else { continue }; + let Some((file, range)) = loc.rsplit_once(':') else { continue }; + let Some((start, end)) = range.split_once(',') else { continue }; + let line_of = |s: &str| s.split('.').next().and_then(|l| l.parse::().ok()); + let (Some(first), Some(last)) = (line_of(start), line_of(end)) else { continue }; + if first == 0 || last < first { + continue; + } + let path = resolve(file, module, module_dir); + for n in first..=last { + acc.line(&path, n - 1, count); + acc.branch(&path, n - 1, range.to_owned(), count > 0); + } + } + // Blocks are not branches: keep only the partial marks of lines with mixed blocks. + acc.drop_unanimous_branches(); + acc +} + +impl Accumulator { + /// Removes the branch data of lines whose branches are all taken or all missed. + fn drop_unanimous_branches(&mut self) { + for lines in self.files.values_mut() { + for acc in lines.values_mut() { + let taken = acc.branches.values().filter(|t| **t).count(); + if taken == 0 || taken == acc.branches.len() { + acc.branches.clear(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_import_paths_and_marks_mixed_lines_partial() { + let text = "mode: count\n\ + example.com/m/calc/calc.go:3.24,5.2 1 4\n\ + example.com/m/calc/calc.go:5.2,7.3 2 0\n\ + example.com/m/calc/calc.go:9.1,9.20 1 0\n\ + /abs/other.go:1.1,1.5 1 1\n"; + let suite = parse(text, "example.com/m", Path::new("/proj")).finish("go"); + assert!(!suite.has_branches); + assert_eq!(suite.files.len(), 2); + let other = &suite.files[0]; + assert_eq!(other.path.as_ref() as &Path, Path::new("/abs/other.go")); + let calc = &suite.files[1]; + assert_eq!(calc.path.as_ref() as &Path, Path::new("/proj/calc/calc.go")); + let at = |n: u32| calc.lines.iter().find(|l| l.line == n).unwrap(); + assert_eq!(at(2).hits, 4); + assert!(!at(2).partial()); + assert!(at(4).partial(), "line 5 has a covered and an uncovered block"); + assert_eq!(at(5).hits, 0); + assert_eq!(at(8).hits, 0); + assert_eq!((calc.lines_total, calc.lines_covered), (6, 3)); + } + + #[test] + fn skips_malformed_lines() { + let suite = parse("mode: set\nnot a block\nx.go:a.1,2.1 1 1\n", "", Path::new("/p")).finish("go"); + assert!(suite.files.is_empty()); + } +} diff --git a/crates/ide-coverage/src/jacoco.rs b/crates/ide-coverage/src/jacoco.rs new file mode 100644 index 00000000..1805b3c9 --- /dev/null +++ b/crates/ide-coverage/src/jacoco.rs @@ -0,0 +1,203 @@ +//! JaCoCo XML reports: +//! +//! ```xml +//! +//! +//! +//! +//! ``` +//! +//! Source files are found below the report's module in the conventional +//! source roots (`src//{java,kotlin,groovy,scala}`), falling back to +//! `src//java`. + +use crate::Accumulator; +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use std::path::{Path, PathBuf}; + +const LANGS: &[&str] = &["java", "kotlin", "groovy", "scala"]; +const MAX_DEPTH: usize = 6; + +/// The module a report belongs to: `/target/site/jacoco/jacoco.xml` (Maven) or +/// `/build/reports/jacoco//.xml` (Gradle). +pub(crate) fn module_dir(report: &Path) -> PathBuf { + for dir in report.ancestors().skip(1) { + let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if (name == "target" || name == "build") + && let Some(parent) = dir.parent() + { + return parent.to_path_buf(); + } + } + report.parent().unwrap_or(report).to_path_buf() +} + +/// JaCoCo XML reports below `dir` (skipping VCS / dependency directories). +pub(crate) fn find_reports(dir: &Path) -> Vec { + let mut out = Vec::new(); + walk(dir, 0, &mut out); + out.sort(); + out +} + +fn walk(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let path = e.path(); + let name = e.file_name().to_string_lossy().into_owned(); + let Ok(ft) = e.file_type() else { continue }; + if ft.is_dir() { + if name.starts_with('.') || name == "node_modules" || name == "src" { + continue; + } + // `…/target/site/jacoco/` and `…/build/reports/jacoco//`. + if name == "jacoco" + && path.parent().is_some_and(|p| p.ends_with("target/site") || p.ends_with("build/reports")) + { + collect_xml(&path, 0, out); + continue; + } + if depth < MAX_DEPTH { + walk(&path, depth + 1, out); + } + } + } +} + +fn collect_xml(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let path = e.path(); + if path.is_dir() && depth < 1 { + collect_xml(&path, depth + 1, out); + } else if path.extension().is_some_and(|x| x == "xml") { + out.push(path); + } + } +} + +fn attr(e: &BytesStart, name: &str) -> Option { + e.attributes().flatten().find(|a| a.key.as_ref() == name).map(|a| a.value.into_owned()) +} + +fn num(e: &BytesStart, name: &str) -> u32 { + attr(e, name).and_then(|v| v.parse().ok()).unwrap_or(0) +} + +/// Source file of `package/name` below `module`. +fn locate(module: &Path, package: &str, name: &str) -> PathBuf { + let rel = if package.is_empty() { PathBuf::from(name) } else { Path::new(package).join(name) }; + let src = module.join("src"); + if let Ok(sets) = std::fs::read_dir(&src) { + let mut sets: Vec = sets.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); + // `main` first, so a class compiled from main sources does not resolve into tests. + sets.sort_by_key(|p| (!p.ends_with("main"), p.clone())); + for set in sets { + for lang in LANGS { + let candidate = set.join(lang).join(&rel); + if candidate.is_file() { + return candidate; + } + } + } + } + src.join("main").join("java").join(rel) +} + +/// Parses one report of the module at `module` into `acc`. +pub(crate) fn parse_into(xml: &str, module: &Path, acc: &mut Accumulator) -> Result<(), quick_xml::Error> { + let mut reader = Reader::from_str(xml); + let mut package = String::new(); + let mut file: Option = None; + loop { + match reader.read_event()? { + Event::Start(e) | Event::Empty(e) => match e.name().as_ref() { + "package" => package = attr(&e, "name").unwrap_or_default(), + "sourcefile" => { + let name = attr(&e, "name").unwrap_or_default(); + let path = locate(module, &package, &name); + acc.file(&path); + file = Some(path); + } + "line" => { + let Some(f) = file.as_deref() else { continue }; + let nr = num(&e, "nr"); + if nr == 0 { + continue; + } + let (ci, mb, cb) = (num(&e, "ci"), num(&e, "mb"), num(&e, "cb")); + acc.line(f, nr - 1, u64::from(ci)); + for i in 0..mb + cb { + acc.branch(f, nr - 1, i.to_string(), i < cb); + } + } + _ => {} + }, + Event::End(e) => match e.name().as_ref() { + "sourcefile" => file = None, + "package" => package.clear(), + _ => {} + }, + Event::Eof => break, + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +/// Parses a single report whose sources live below `module`. +pub(crate) fn parse(xml: &str, module: &Path) -> Result { + let mut acc = Accumulator { has_branches: true, ..Default::default() }; + parse_into(xml, module, &mut acc).map_err(|e| e.to_string())?; + Ok(acc.finish("JaCoCo")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = r#""#; + + #[test] + fn parses_lines_and_branches_and_locates_sources() { + let dir = tempfile::tempdir().unwrap(); + let kt = dir.path().join("src/main/kotlin/com/example"); + std::fs::create_dir_all(&kt).unwrap(); + std::fs::write(kt.join("Util.kt"), "").unwrap(); + let suite = parse(REPORT, dir.path()).unwrap(); + assert!(suite.has_branches); + assert_eq!(suite.files.len(), 2); + let calc = &suite.files[0]; + assert_eq!(calc.path.as_ref() as &Path, dir.path().join("src/main/java/com/example/Calc.java")); + assert_eq!((calc.lines_total, calc.lines_covered), (3, 2)); + assert_eq!((calc.branches_total, calc.branches_covered), (2, 1)); + assert!(calc.lines[1].partial()); + assert_eq!(calc.lines[2].hits, 0); + assert_eq!(suite.files[1].path.as_ref() as &Path, kt.join("Util.kt")); + } + + #[test] + fn finds_maven_and_gradle_reports() { + let dir = tempfile::tempdir().unwrap(); + let maven = dir.path().join("core/target/site/jacoco"); + let gradle = dir.path().join("app/build/reports/jacoco/test"); + std::fs::create_dir_all(&maven).unwrap(); + std::fs::create_dir_all(&gradle).unwrap(); + std::fs::write(maven.join("jacoco.xml"), REPORT).unwrap(); + std::fs::write(gradle.join("jacocoTestReport.xml"), REPORT).unwrap(); + std::fs::write(maven.join("index.html"), "").unwrap(); + let found = find_reports(dir.path()); + assert_eq!(found.len(), 2); + assert_eq!(module_dir(&found[0]), dir.path().join("app")); + assert_eq!(module_dir(&found[1]), dir.path().join("core")); + } + + #[test] + fn rejects_broken_xml() { + assert!(parse("", Path::new("/m")).is_err()); + let suite = parse("", Path::new("/m")).unwrap(); + assert!(suite.files.is_empty()); + } +} diff --git a/crates/ide-coverage/src/lcov.rs b/crates/ide-coverage/src/lcov.rs new file mode 100644 index 00000000..249549f8 --- /dev/null +++ b/crates/ide-coverage/src/lcov.rs @@ -0,0 +1,86 @@ +//! lcov tracefiles (`geninfo` format), as written by cargo-llvm-cov, istanbul +//! (vitest / jest / c8) and coverage.py: +//! +//! ```text +//! SF:src/lib.rs +//! DA:3,1 +//! BRDA:4,0,0,1 +//! BRDA:4,0,1,- +//! end_of_record +//! ``` + +use crate::Accumulator; +use std::path::{Path, PathBuf}; + +/// Parses `text`; relative `SF:` paths resolve against `base`. +pub(crate) fn parse(text: &str, base: &Path) -> Accumulator { + let mut acc = Accumulator::default(); + let mut file: Option = None; + for raw in text.lines() { + let line = raw.trim(); + if let Some(sf) = line.strip_prefix("SF:") { + let p = Path::new(sf.trim()); + let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) }; + acc.file(&path); + file = Some(path); + continue; + } + if line == "end_of_record" { + file = None; + continue; + } + let Some(f) = file.as_deref() else { continue }; + if let Some(da) = line.strip_prefix("DA:") { + let mut parts = da.split(','); + let (Some(n), Some(hits)) = (parts.next(), parts.next()) else { continue }; + let (Ok(n), Ok(hits)) = (n.trim().parse::(), hits.trim().parse::()) else { continue }; + if n == 0 { + continue; + } + acc.line(f, n - 1, hits.max(0.0) as u64); + } else if let Some(brda) = line.strip_prefix("BRDA:") { + let parts: Vec<&str> = brda.split(',').map(str::trim).collect(); + let [n, block, branch, taken] = parts[..] else { continue }; + let Ok(n) = n.parse::() else { continue }; + if n == 0 { + continue; + } + let taken = taken != "-" && taken.parse::().is_ok_and(|t| t > 0.0); + acc.has_branches = true; + acc.branch(f, n - 1, format!("{block}:{branch}"), taken); + } + } + acc +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "TN:\nSF:/abs/src/lib.rs\nFN:1,add\nFNDA:3,add\nDA:1,3\nDA:2,3\nDA:5,0\nBRDA:2,0,0,3\nBRDA:2,0,1,-\nLF:3\nLH:2\nend_of_record\nSF:src/util.ts\nDA:10,1\nend_of_record\n"; + + #[test] + fn parses_lines_and_branches() { + let suite = parse(SAMPLE, Path::new("/base")).finish("lcov"); + assert!(suite.has_branches); + assert_eq!(suite.files.len(), 2); + let lib = &suite.files[0]; + assert_eq!(lib.path.as_ref() as &Path, Path::new("/abs/src/lib.rs")); + assert_eq!((lib.lines_total, lib.lines_covered), (3, 2)); + assert_eq!((lib.branches_total, lib.branches_covered), (2, 1)); + assert_eq!(lib.lines[0].line, 0); + assert!(lib.lines[1].partial()); + assert_eq!(lib.lines[2].hits, 0); + assert_eq!(suite.files[1].path.as_ref() as &Path, Path::new("/base/src/util.ts")); + } + + #[test] + fn merges_repeated_records_and_ignores_garbage() { + let text = "SF:/a.py\nDA:1,0\nDA:x,1\nDA:0,4\nend_of_record\nDA:9,9\nSF:/a.py\nDA:1,2\nend_of_record\n"; + let suite = parse(text, Path::new("/")).finish("lcov"); + assert!(!suite.has_branches); + assert_eq!(suite.files.len(), 1); + assert_eq!(suite.files[0].lines.len(), 1); + assert_eq!(suite.files[0].lines[0].hits, 2); + } +} diff --git a/crates/ide-coverage/src/lib.rs b/crates/ide-coverage/src/lib.rs new file mode 100644 index 00000000..48972d60 --- /dev/null +++ b/crates/ide-coverage/src/lib.rs @@ -0,0 +1,258 @@ +//! Run with Coverage. +//! +//! [`prepare`] turns a run configuration into the command that runs it under +//! the runtime's coverage tool (or explains which tool is missing), and +//! [`collect`] reads the report that run left behind into a [`CoverageSuite`]: +//! +//! | runtime | tool | report | +//! |---|---|---| +//! | Rust (`cargo test` / `run` / `nextest`) | `cargo llvm-cov` | lcov | +//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov | +//! | Node.js scripts | `c8` | lcov | +//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov | +//! | Go (`go test`) | `-coverprofile` | Go cover profile | +//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML | +//! +//! Lines in the model are 0-based, like every IPC coordinate. + +mod goprofile; +mod jacoco; +mod lcov; +mod plan; + +pub use plan::prepare; + +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_run::RunConfig; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Coverage of one source line. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LineCoverage { + /// 0-based. + pub line: u32, + /// Execution count (JaCoCo: covered instructions); 0 = not covered. + pub hits: u32, + pub branches_total: u32, + pub branches_covered: u32, +} + +impl LineCoverage { + /// Covered, but not every branch (or block) on the line. + pub fn partial(&self) -> bool { + self.hits > 0 && self.branches_covered < self.branches_total + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileCoverage { + pub path: IpcPath, + /// Sorted by line. + pub lines: Vec, + pub lines_total: u32, + pub lines_covered: u32, + pub branches_total: u32, + pub branches_covered: u32, +} + +/// One coverage run's report. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CoverageSuite { + /// Tool that produced it, e.g. `cargo-llvm-cov`, `JaCoCo`. + pub runner: String, + /// Whether branch counts are meaningful (lcov `BRDA`, JaCoCo); Go profiles only have blocks. + pub has_branches: bool, + /// Sorted by path. + pub files: Vec, +} + +/// Where the report of a prepared run will be, and how to read it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum CoverageReportSpec { + /// An lcov tracefile; relative `SF:` paths resolve against `base`. + Lcov { runner: String, path: IpcPath, base: IpcPath }, + /// `go test -coverprofile`; import paths under `module` map to `module_dir`. + GoProfile { path: IpcPath, module: String, module_dir: IpcPath }, + /// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. + Jacoco { dir: IpcPath }, + /// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o `). + Exported { runner: String, argv: Vec, cwd: IpcPath, env: BTreeMap, lcov: IpcPath }, +} + +/// A command line that installs a missing tool (run in the Run tool window). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct InstallCommand { + pub command: String, + pub args: Vec, + pub cwd: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum CoveragePrepared { + Ready { + runner: String, + /// What to launch instead of the configuration. + launch: Box, + report: CoverageReportSpec, + }, + MissingTool { + tool: String, + message: String, + install: Option, + }, + Unsupported { + reason: String, + }, +} + +/// Line data merged from one or more report records. +#[derive(Default)] +pub(crate) struct Accumulator { + files: BTreeMap>, + pub(crate) has_branches: bool, +} + +#[derive(Default)] +struct LineAcc { + hits: u64, + /// Branch (or block) key → taken. + branches: BTreeMap, +} + +impl Accumulator { + pub(crate) fn line(&mut self, file: &Path, line: u32, hits: u64) { + let acc = self.files.entry(file.to_path_buf()).or_default().entry(line).or_default(); + acc.hits = acc.hits.saturating_add(hits); + } + + /// A branch outcome on `line`; the same `key` from several records counts once (taken if any). + pub(crate) fn branch(&mut self, file: &Path, line: u32, key: String, taken: bool) { + let acc = self.files.entry(file.to_path_buf()).or_default().entry(line).or_default(); + let b = acc.branches.entry(key).or_default(); + *b |= taken; + } + + /// Declares `file` (a report entry without line data still lists the file). + pub(crate) fn file(&mut self, file: &Path) { + self.files.entry(file.to_path_buf()).or_default(); + } + + pub(crate) fn finish(self, runner: &str) -> CoverageSuite { + let files = self + .files + .into_iter() + .map(|(path, lines)| { + let lines: Vec = lines + .into_iter() + .map(|(line, acc)| LineCoverage { + line, + hits: u32::try_from(acc.hits).unwrap_or(u32::MAX), + branches_total: acc.branches.len() as u32, + branches_covered: acc.branches.values().filter(|t| **t).count() as u32, + }) + .collect(); + FileCoverage { + path: path.into(), + lines_total: lines.len() as u32, + lines_covered: lines.iter().filter(|l| l.hits > 0).count() as u32, + branches_total: lines.iter().map(|l| l.branches_total).sum(), + branches_covered: lines.iter().map(|l| l.branches_covered).sum(), + lines, + } + }) + .collect(); + CoverageSuite { runner: runner.to_owned(), has_branches: self.has_branches, files } + } +} + +/// Reads the report described by `spec` (running coverage.py's lcov export first). +/// Blocking: call from a blocking thread. +pub fn collect(spec: &CoverageReportSpec) -> IdeResult { + match spec { + CoverageReportSpec::Lcov { runner, path, base } => { + let text = read_report(path)?; + Ok(lcov::parse(&text, base).finish(runner)) + } + CoverageReportSpec::GoProfile { path, module, module_dir } => { + let text = read_report(path)?; + Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover")) + } + CoverageReportSpec::Jacoco { dir } => { + let reports = jacoco::find_reports(dir); + if reports.is_empty() { + return Err(IdeError::not_found(format!("no JaCoCo report under {}", dir.display()))); + } + let mut acc = Accumulator { has_branches: true, ..Default::default() }; + for report in reports { + let xml = std::fs::read_to_string(&report)?; + let module = jacoco::module_dir(&report); + jacoco::parse_into(&xml, &module, &mut acc) + .map_err(|e| IdeError::invalid(format!("{}: {e}", report.display())))?; + } + Ok(acc.finish("JaCoCo")) + } + 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) + .args(args) + .current_dir(cwd) + .envs(env) + .output() + .map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?; + if !out.status.success() { + let text = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text }; + return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}"))); + } + let text = read_report(lcov)?; + Ok(lcov::parse(&text, cwd).finish(runner)) + } + } +} + +fn read_report(path: &Path) -> IdeResult { + std::fs::read_to_string(path).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + IdeError::not_found(format!("the run produced no coverage report ({})", path.display())) + } + _ => e.into(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accumulator_merges_records_of_the_same_file() { + let mut acc = Accumulator::default(); + let f = Path::new("/p/a.rs"); + acc.line(f, 0, 1); + acc.line(f, 0, 2); + acc.line(f, 3, 0); + acc.branch(f, 0, "0:0".into(), true); + acc.branch(f, 0, "0:1".into(), false); + acc.branch(f, 0, "0:1".into(), false); + acc.file(Path::new("/p/empty.rs")); + let suite = acc.finish("t"); + assert_eq!(suite.files.len(), 2); + let a = &suite.files[0]; + assert_eq!(a.path.as_ref() as &Path, f); + assert_eq!((a.lines_total, a.lines_covered, a.branches_total, a.branches_covered), (2, 1, 2, 1)); + assert_eq!(a.lines[0], LineCoverage { line: 0, hits: 3, branches_total: 2, branches_covered: 1 }); + assert!(a.lines[0].partial()); + assert!(!a.lines[1].partial()); + assert_eq!(suite.files[1].lines_total, 0); + } +} diff --git a/crates/ide-coverage/src/plan.rs b/crates/ide-coverage/src/plan.rs new file mode 100644 index 00000000..5ff6c797 --- /dev/null +++ b/crates/ide-coverage/src/plan.rs @@ -0,0 +1,407 @@ +//! Which coverage tool runs a configuration, and how its command line changes. + +use crate::{CoveragePrepared, CoverageReportSpec, InstallCommand}; +use ide_run::RunConfig; +use ide_run::wrap::{self, program_name}; +use std::path::Path; + +const GRADLE_INIT: &str = r#"// Written by OpenIDEA for Run with Coverage. +allprojects { + plugins.withId('java') { + apply plugin: 'jacoco' + tasks.withType(Test).configureEach { t -> t.finalizedBy(tasks.named('jacocoTestReport')) } + tasks.named('jacocoTestReport') { r -> r.reports { xml.required.set(true) } } + } +} +"#; + +fn s(v: &str) -> String { + v.to_owned() +} + +fn path(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +fn unsupported(reason: impl Into) -> CoveragePrepared { + CoveragePrepared::Unsupported { reason: reason.into() } +} + +fn missing(tool: &str, message: impl Into, install: Option<(Vec, &Path)>) -> CoveragePrepared { + CoveragePrepared::MissingTool { + tool: tool.to_owned(), + message: message.into(), + install: install.and_then(|(argv, cwd)| { + let (command, args) = argv.split_first()?; + Some(InstallCommand { command: command.clone(), args: args.to_vec(), cwd: Some(cwd.to_path_buf().into()) }) + }), + } +} + +fn ready( + runner: &str, + config: &RunConfig, + command: String, + args: Vec, + report: CoverageReportSpec, +) -> CoveragePrepared { + CoveragePrepared::Ready { + runner: runner.to_owned(), + launch: Box::new(RunConfig { command, args, ..config.clone() }), + report, + } +} + +/// How to run `config` with coverage. `out` is an empty directory for the run's +/// data; it must exist (the Gradle init script is written there). Probes tools, +/// so call it from a blocking thread. +pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared { + let dir = wrap::run_dir(root, config); + let program = program_name(&config.command); + let first = config.args.first().map(String::as_str).unwrap_or_default(); + match program.as_str() { + "cargo" => cargo(config, &dir, out), + "go" if first == "test" => go(config, &dir, out), + "go" => unsupported("Go coverage is collected by `go test`; run a test configuration with coverage."), + "mvn" | "mvnw" => maven(config, &dir), + "gradle" | "gradlew" => gradle(config, &dir, out), + "npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out), + "node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out), + "deno" => unsupported("Coverage of Deno tasks is not supported yet."), + "java" => unsupported( + "Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.", + ), + _ => match wrap::python_command(config) { + Some(py) => python(config, &dir, out, py), + None => unsupported(format!("No coverage runner is known for '{}'.", config.name)), + }, + } +} + +fn cargo(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + // `cargo +nightly test …`: the toolchain stays in front. + let toolchain: Vec = config.args.iter().take_while(|a| a.starts_with('+')).cloned().collect(); + let rest = &config.args[toolchain.len()..]; + let Some(sub) = rest.first().filter(|s| matches!(s.as_str(), "test" | "run" | "nextest")) else { + return unsupported("Coverage runs `cargo test`, `cargo run` or `cargo nextest` configurations."); + }; + let probe: Vec = std::iter::once(config.command.clone()) + .chain(toolchain.iter().cloned()) + .chain([s("llvm-cov"), s("--version")]) + .collect(); + if !wrap::probe(&probe, dir, config) { + return missing( + "cargo-llvm-cov", + "Rust coverage needs cargo-llvm-cov (and the llvm-tools-preview rustup component).", + Some((vec![s("cargo"), s("install"), s("cargo-llvm-cov")], dir)), + ); + } + let lcov = out.join("lcov.info"); + let mut args = toolchain; + args.extend([s("llvm-cov"), sub.clone(), s("--lcov"), s("--output-path"), path(&lcov)]); + args.extend(rest[1..].iter().cloned()); + let report = + CoverageReportSpec::Lcov { runner: s("cargo-llvm-cov"), path: lcov.into(), base: dir.to_path_buf().into() }; + ready("cargo-llvm-cov", config, config.command.clone(), args, report) +} + +fn go(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + let profile = out.join("cover.out"); + let mut args = vec![s("test"), format!("-coverprofile={}", path(&profile))]; + args.extend(config.args[1..].iter().cloned()); + let (module, module_dir) = wrap::go_module(dir).unwrap_or_else(|| (String::new(), dir.to_path_buf())); + let report = CoverageReportSpec::GoProfile { path: profile.into(), module, module_dir: module_dir.into() }; + ready("go test -cover", config, config.command.clone(), args, report) +} + +fn runs_tests(args: &[String]) -> bool { + args.iter().any(|a| { + let task = a.rsplit(':').next().unwrap_or(a); + matches!(task, "test" | "verify" | "check" | "integrationTest") || a.starts_with("-Dtest=") || a == "--tests" + }) +} + +fn maven(config: &RunConfig, dir: &Path) -> CoveragePrepared { + if !runs_tests(&config.args) { + return unsupported("Coverage runs Maven configurations that run tests (`test`, `verify`)."); + } + let mut args = vec![s("org.jacoco:jacoco-maven-plugin:prepare-agent")]; + args.extend(config.args.iter().cloned()); + // Report even when tests fail, and from this run's data only. + args.extend([ + s("org.jacoco:jacoco-maven-plugin:report"), + s("-Djacoco.append=false"), + s("-Dmaven.test.failure.ignore=true"), + ]); + ready("JaCoCo", config, config.command.clone(), args, CoverageReportSpec::Jacoco { dir: dir.to_path_buf().into() }) +} + +fn gradle(config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + if !runs_tests(&config.args) { + return unsupported("Coverage runs Gradle configurations that run tests (`test`, `check`)."); + } + let script = out.join("openidea-jacoco.init.gradle"); + if let Err(e) = std::fs::write(&script, GRADLE_INIT) { + return unsupported(format!("Cannot write the Gradle init script: {e}")); + } + let mut args = vec![s("--init-script"), path(&script)]; + args.extend(config.args.iter().cloned()); + ready("JaCoCo", config, config.command.clone(), args, CoverageReportSpec::Jacoco { dir: dir.to_path_buf().into() }) +} + +/// `npm install -D x` / `pnpm add -D x`. +fn add_dev(pm: &str, package: &str) -> Vec { + let verb = if pm == "npm" { "install" } else { "add" }; + vec![s(pm), s(verb), s("-D"), s(package)] +} + +const SHELL_META: &[char] = &['&', '|', ';', '<', '>', '$', '`', '"', '\'', '(', ')']; + +fn npm(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + let pm = program_name(&config.command); + let Some((name, script)) = wrap::npm_script(config, dir) else { + return unsupported("Coverage runs package.json scripts that use vitest or jest."); + }; + let tokens: Vec<&str> = script.split_whitespace().collect(); + let runner_at = tokens.iter().position(|t| matches!(*t, "vitest" | "jest")); + let Some(runner_at) = runner_at else { + return unsupported(format!("The '{name}' script does not run vitest or jest: {script}")); + }; + let runner = tokens[runner_at]; + let lcov = out.join("lcov.info"); + let coverage_args: Vec = if runner == "vitest" { + if !wrap::node_package("@vitest/coverage-v8", dir, root) + && !wrap::node_package("@vitest/coverage-istanbul", dir, root) + { + // The provider must match the installed vitest exactly. + let provider = match wrap::node_package_version("vitest", dir, root) { + Some(v) => format!("@vitest/coverage-v8@{v}"), + None => s("@vitest/coverage-v8"), + }; + return missing( + "@vitest/coverage-v8", + "Vitest coverage needs a coverage provider package.", + Some((add_dev(&pm, &provider), dir)), + ); + } + vec![ + s("--coverage.enabled=true"), + s("--coverage.reporter=lcov"), + format!("--coverage.reportsDirectory={}", path(out)), + ] + } else { + vec![s("--coverage"), s("--coverageReporters=lcov"), format!("--coverageDirectory={}", path(out))] + }; + let report = + CoverageReportSpec::Lcov { runner: runner.to_owned(), path: lcov.into(), base: dir.to_path_buf().into() }; + // A plain `vitest run …` script runs the runner directly, so its arguments need no `--` games. + let simple = + !script.contains(SHELL_META) && tokens[..runner_at].iter().all(|t| matches!(*t, "npx" | "pnpm" | "exec")); + if simple && let Some(bin) = wrap::node_bin(runner, dir, root) { + let mut args: Vec = tokens[runner_at + 1..].iter().map(|t| s(t)).collect(); + args.extend(wrap::passthrough_args(config)); + args.extend(coverage_args); + return ready(runner, config, path(&bin), args, report); + } + let mut args = config.args.clone(); + if !args.iter().any(|a| a == "--") { + args.push(s("--")); + } + args.extend(coverage_args); + ready(runner, config, config.command.clone(), args, report) +} + +fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared { + let Some(c8) = wrap::find_tool("c8", config, dir, root) else { + return missing( + "c8", + "Coverage of Node.js programs needs c8.", + Some((vec![s("npm"), s("install"), s("-g"), s("c8")], dir)), + ); + }; + let mut args = vec![ + s("--reporter=lcov"), + format!("--reports-dir={}", path(out)), + format!("--temp-directory={}", path(&out.join("tmp"))), + config.command.clone(), + ]; + args.extend(config.args.iter().cloned()); + let report = CoverageReportSpec::Lcov { + runner: s("c8"), + path: out.join("lcov.info").into(), + base: dir.to_path_buf().into(), + }; + ready("c8", config, path(&c8), args, report) +} + +fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared { + if py.target.is_empty() { + return unsupported("Coverage needs a Python script or module to run."); + } + let mut config = config.clone(); + let data = out.join(".coverage"); + config.env.insert(s("COVERAGE_FILE"), path(&data)); + let with = + |extra: &[&str]| -> Vec { py.interpreter.iter().cloned().chain(extra.iter().map(|e| s(e))).collect() }; + if !wrap::probe(&with(&["-m", "coverage", "--version"]), dir, &config) { + let install = match py.manager.as_deref() { + Some("uv") => vec![s("uv"), s("add"), s("--dev"), s("coverage")], + Some("poetry") => vec![s("poetry"), s("add"), s("--group"), s("dev"), s("coverage")], + Some("pdm") => vec![s("pdm"), s("add"), s("-d"), s("coverage")], + _ => with(&["-m", "pip", "install", "coverage"]), + }; + return missing( + "coverage", + "Python coverage needs coverage.py in the project's interpreter.", + Some((install, dir)), + ); + } + let lcov = out.join("lcov.info"); + let mut argv = with(&["-m", "coverage", "run", "--branch"]); + argv.extend(py.target.iter().cloned()); + let report = CoverageReportSpec::Exported { + runner: s("coverage.py"), + argv: with(&["-m", "coverage", "lcov", "-o", &path(&lcov)]), + cwd: dir.to_path_buf().into(), + env: config.env.clone(), + lcov: lcov.into(), + }; + let (command, args) = argv.split_first().map(|(c, a)| (c.clone(), a.to_vec())).unwrap_or_default(); + ready("coverage.py", &config, command, args, report) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn cfg(kind: &str, command: &str, args: &[&str]) -> RunConfig { + RunConfig { + id: "t".into(), + name: "t".into(), + kind: kind.into(), + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: None, + env: BTreeMap::new(), + detected: true, + is_test: true, + debug_adapter: None, + } + } + + fn launch(p: &CoveragePrepared) -> (&RunConfig, &CoverageReportSpec) { + match p { + CoveragePrepared::Ready { launch, report, .. } => (launch, report), + other => panic!("not ready: {other:?}"), + } + } + + #[test] + fn go_test_gets_a_cover_profile() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap(); + let out = dir.path().join("out"); + let p = prepare(dir.path(), &cfg("go", "go", &["test", "./...", "-run", "X"]), &out); + let (l, report) = launch(&p); + assert_eq!(l.args[0], "test"); + assert!(l.args[1].starts_with("-coverprofile=") && l.args[1].ends_with("cover.out")); + assert_eq!(&l.args[2..], ["./...", "-run", "X"]); + assert!(matches!(report, CoverageReportSpec::GoProfile { module, .. } if module == "example.com/m")); + assert!(matches!( + prepare(dir.path(), &cfg("go", "go", &["run", "."]), &out), + CoveragePrepared::Unsupported { .. } + )); + } + + #[test] + fn maven_and_gradle_use_jacoco() { + let dir = tempfile::tempdir().unwrap(); + let p = prepare(dir.path(), &cfg("maven", "./mvnw", &["test", "-Dtest=CalcTest"]), dir.path()); + let (l, _) = launch(&p); + assert_eq!(l.args[0], "org.jacoco:jacoco-maven-plugin:prepare-agent"); + assert!(l.args.contains(&"org.jacoco:jacoco-maven-plugin:report".to_owned())); + assert!(matches!( + prepare(dir.path(), &cfg("maven", "mvn", &["package"]), dir.path()), + CoveragePrepared::Unsupported { .. } + )); + + let p = prepare(dir.path(), &cfg("gradle", "./gradlew", &[":app:test", "--tests", "*Calc"]), dir.path()); + let (l, report) = launch(&p); + assert_eq!(l.args[0], "--init-script"); + assert!(std::fs::read_to_string(&l.args[1]).unwrap().contains("jacoco")); + assert!(matches!(report, CoverageReportSpec::Jacoco { .. })); + } + + #[test] + fn vitest_scripts_run_the_runner_with_coverage() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"vitest run","t2":"tsc && jest"}}"#).unwrap(); + let config = cfg("npm", "pnpm", &["run", "test", "--", "a.test.ts"]); + // No provider package: install hint. + match prepare(root, &config, root) { + CoveragePrepared::MissingTool { install: Some(i), .. } => { + assert_eq!(i.args, ["add", "-D", "@vitest/coverage-v8"]) + } + other => panic!("{other:?}"), + } + // ... pinned to the installed vitest. + let vitest = root.join("node_modules/vitest"); + std::fs::create_dir_all(&vitest).unwrap(); + std::fs::write(vitest.join("package.json"), r#"{"version":"2.1.9"}"#).unwrap(); + match prepare(root, &config, root) { + CoveragePrepared::MissingTool { install: Some(i), .. } => { + assert_eq!(i.args, ["add", "-D", "@vitest/coverage-v8@2.1.9"]) + } + other => panic!("{other:?}"), + } + let pkg = root.join("node_modules/@vitest/coverage-v8"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write(pkg.join("package.json"), "{}").unwrap(); + let bin = root.join("node_modules/.bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join(if cfg!(windows) { "vitest.cmd" } else { "vitest" }), "").unwrap(); + let p = prepare(root, &config, root); + let (l, _) = launch(&p); + assert!(l.command.contains("node_modules")); + assert_eq!(&l.args[..2], ["run", "a.test.ts"]); + assert!(l.args.contains(&"--coverage.reporter=lcov".to_owned())); + + // A compound script: arguments go after `--`. + let p = prepare(root, &cfg("npm", "npm", &["run", "t2"]), root); + let (l, _) = launch(&p); + assert_eq!(l.command, "npm"); + assert_eq!(&l.args[..3], ["run", "t2", "--"]); + assert!(l.args.contains(&"--coverage".to_owned())); + } + + #[test] + fn cargo_without_llvm_cov_suggests_installing_it() { + let dir = tempfile::tempdir().unwrap(); + let mut config = cfg("cargo", "cargo", &["test"]); + // A search path without cargo: the probe fails. + config.env.insert("PATH".into(), dir.path().to_string_lossy().into_owned()); + config.command = dir.path().join("cargo").to_string_lossy().into_owned(); + match prepare(dir.path(), &config, dir.path()) { + CoveragePrepared::MissingTool { tool, install: Some(i), .. } => { + assert_eq!(tool, "cargo-llvm-cov"); + assert_eq!(i.args, ["install", "cargo-llvm-cov"]); + } + other => panic!("{other:?}"), + } + assert!(matches!( + prepare(dir.path(), &cfg("cargo", "cargo", &["build"]), dir.path()), + CoveragePrepared::Unsupported { .. } + )); + } + + #[test] + fn unknown_commands_are_unsupported() { + let dir = tempfile::tempdir().unwrap(); + assert!(matches!( + prepare(dir.path(), &cfg("make", "make", &["all"]), dir.path()), + CoveragePrepared::Unsupported { .. } + )); + } +} diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml new file mode 100644 index 00000000..f402a1aa --- /dev/null +++ b/crates/ide-dap/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ide-dap" +description = "Debug adapter protocol client: sessions, breakpoints, threads, frames, variables, evaluation" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-language.workspace = true +ide-jsonrpc.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio = { workspace = true, features = ["net"] } +parking_lot.workspace = true +memchr = "2" +tracing.workspace = true + +[dev-dependencies] +tracing-subscriber.workspace = true +tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] } diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs new file mode 100644 index 00000000..1e75a833 --- /dev/null +++ b/crates/ide-dap/src/adapter.rs @@ -0,0 +1,519 @@ +//! Resolving and starting debug adapters. +//! +//! Adapters come from `languages/*.toml` `[debugger]` tables, with built-in +//! fallbacks for the common ones. `${port}` in the arguments of a `tcp` +//! adapter is replaced by a free local port; the client then connects to it. +//! Adapters that run inside another process (java-debug inside jdtls) are +//! reached through a `debugServer` port in the launch arguments instead. + +use ide_core::{IdeError, IdeResult}; +use ide_language::{DebuggerConfig, LanguageRegistry}; +use std::future::Future; +use std::path::{Path, PathBuf}; +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::process::{Child, Command}; + +pub const PORT_PLACEHOLDER: &str = "${port}"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Transport { + Stdio, + Tcp, +} + +/// A fully resolved adapter command line. +#[derive(Clone, Debug)] +pub struct AdapterSpec { + pub adapter: String, + pub command: String, + pub args: Vec, + pub transport: Transport, + pub install_hint: Option, + pub cwd: PathBuf, + /// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`). + pub env: Vec<(String, String)>, +} + +/// Built-in adapters used when no language definition names `adapter`. +fn builtin(adapter: &str) -> Option { + let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig { + adapter: adapter.to_owned(), + command: command.to_owned(), + args: args.iter().map(|a| (*a).to_owned()).collect(), + transport: transport.to_owned(), + install_hint: Some(hint.to_owned()), + }; + Some(match adapter { + "codelldb" => cfg( + "codelldb", + &["--port", PORT_PLACEHOLDER], + "tcp", + "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH", + ), + "debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"), + "js-debug" => cfg( + "js-debug-adapter", + // Without a host it listens on `localhost`, which may resolve to ::1 only. + &[PORT_PLACEHOLDER, "127.0.0.1"], + "tcp", + "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`", + ), + "delve" => cfg( + "dlv", + &["dap", "--listen", "127.0.0.1:${port}"], + "tcp", + "go install github.com/go-delve/delve/cmd/dlv@latest", + ), + "netcoredbg" => cfg( + "netcoredbg", + &["--interpreter=vscode"], + "stdio", + "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)", + ), + "lldb-dap" => { + cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)") + } + // A jdtls bundle: the session is started through jdtls and reached via `debugServer`. + "java-debug" => cfg( + "com.microsoft.java.debug.plugin.jar", + &[], + "tcp", + "Install Microsoft java-debug (Settings › Languages & Frameworks › Language Servers)", + ), + _ => return None, + }) +} + +/// The adapter definition for `adapter`: a language's `[debugger]` first, then built-ins. +pub fn find_config(languages: &LanguageRegistry, adapter: &str) -> Option { + languages.all().into_iter().filter_map(|l| l.debugger).find(|d| d.adapter == adapter).or_else(|| builtin(adapter)) +} + +/// Default DAP `type` attribute per adapter (some adapters dispatch on it). +pub fn default_type(adapter: &str) -> Option<&'static str> { + Some(match adapter { + "codelldb" => "lldb", + "debugpy" => "debugpy", + "js-debug" => "pwa-node", + "delve" => "go", + "netcoredbg" => "coreclr", + "lldb-dap" => "lldb-dap", + "java-debug" => "java", + _ => return None, + }) +} + +pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { + AdapterSpec { + adapter: config.adapter.clone(), + command: config.command.clone(), + args: config.args.clone(), + transport: if config.transport.eq_ignore_ascii_case("tcp") { Transport::Tcp } else { Transport::Stdio }, + install_hint: config.install_hint.clone(), + cwd, + env: Vec::new(), + } +} + +/// Replaces every `${port}` occurrence (also inside larger arguments like `127.0.0.1:${port}`). +pub fn substitute_port(args: &[String], port: u16) -> Vec { + args.iter().map(|a| a.replace(PORT_PLACEHOLDER, &port.to_string())).collect() +} + +/// A port that was free a moment ago on the loopback interface. +pub fn free_port() -> std::io::Result { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; + Ok(listener.local_addr()?.port()) +} + +static TOOLS_ROOT: parking_lot::RwLock> = parking_lot::RwLock::new(None); + +/// The IDE's managed tools directory (ide-tools); adapters installed there win over `PATH`. +pub fn set_tools_root(root: PathBuf) { + *TOOLS_ROOT.write() = Some(root); +} + +pub fn tools_root() -> Option { + TOOLS_ROOT.read().clone() +} + +/// Executable directories of managed installs, read at lookup time so tools installed +/// after startup are found without a restart: `/bin`, `/venv/bin`, npm's `.bin`. +fn managed_dirs(root: &Path) -> Vec { + let mut dirs = vec![root.join("node_modules").join(".bin")]; + if let Ok(entries) = std::fs::read_dir(root) { + let mut tools: Vec = entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); + tools.sort(); + for t in tools { + dirs.push(t.join("bin")); + dirs.push(t.join("venv").join(if cfg!(windows) { "Scripts" } else { "bin" })); + } + } + dirs +} + +/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots. +fn search_dirs() -> Vec { + let mut dirs: Vec = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default(); + dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::>()).unwrap_or_default()); + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"] + { + dirs.push(home.join(rel)); + } + } + for d in ["/opt/homebrew/bin", "/opt/homebrew/opt/llvm/bin", "/usr/local/bin", "/usr/bin", "/bin"] { + dirs.push(PathBuf::from(d)); + } + dirs +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +/// Resolves `command` to an executable: paths are taken as-is (relative to `cwd`), bare names are looked up on PATH. +pub fn find_binary(command: &str, cwd: &Path) -> Option { + let p = Path::new(command); + if p.components().count() > 1 || p.is_absolute() { + let full = if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) }; + return is_executable(&full).then_some(full); + } + let exts: &[&str] = if cfg!(windows) { &["", ".exe", ".cmd", ".bat"] } else { &[""] }; + search_dirs() + .into_iter() + .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) + .find(|c| is_executable(c)) +} + +/// [`find_binary`], plus Xcode's toolchain on macOS (`xcrun -f lldb-dap`): Xcode ships +/// lldb-dap without putting it on `PATH`. +pub fn resolve_binary(command: &str, cwd: &Path) -> Option { + find_binary(command, cwd).or_else(|| xcrun_tool(command)) +} + +#[cfg(target_os = "macos")] +fn xcrun_tool(command: &str) -> Option { + if command != "lldb-dap" { + return None; + } + let out = std::process::Command::new("/usr/bin/xcrun").args(["-f", command]).stderr(Stdio::null()).output().ok()?; + let path = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + (out.status.success() && is_executable(&path)).then_some(path) +} + +#[cfg(not(target_os = "macos"))] +fn xcrun_tool(_command: &str) -> Option { + None +} + +/// Name of the managed debugpy install (ide-tools `PipTarget`, unpacked into `/debugpy/lib`). +pub const DEBUGPY_TOOL: &str = "debugpy"; + +/// Message prefix of the "debugpy missing" error; the UI offers the one-click install on it. +pub const DEBUGPY_MISSING: &str = "debugpy is not installed"; + +/// Makes `spec` (` -m debugpy.adapter`) runnable like PyCharm's bundled debugger: +/// the interpreter's own debugpy when it has one (venvs), else OpenIDEA's private copy on +/// `PYTHONPATH`. Neither: an `unavailable` error naming the interpreter. +pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> { + let (command, cwd) = (spec.command.clone(), spec.cwd.clone()); + 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) + .args(["-c", "import debugpy.adapter"]) + .current_dir(&spec.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status(); + if let Ok(Ok(status)) = tokio::time::timeout(Duration::from_secs(15), probe).await + && status.success() + { + return Ok(()); + } + let lib = tools_root().map(|r| r.join(DEBUGPY_TOOL).join("lib")); + if let Some(lib) = lib.filter(|l| l.join("debugpy").join("__init__.py").is_file()) { + let mut paths = vec![lib]; + paths.extend( + std::env::var_os("PYTHONPATH").map(|p| std::env::split_paths(&p).collect::>()).unwrap_or_default(), + ); + let joined = std::env::join_paths(paths).map_err(|e| IdeError::internal(e.to_string()))?; + spec.env.push(("PYTHONPATH".into(), joined.to_string_lossy().into_owned())); + return Ok(()); + } + Err(IdeError::unavailable(format!( + "{DEBUGPY_MISSING} for {}. Install OpenIDEA's copy (works with every interpreter) or run `{} -m pip install debugpy`", + python.display(), + python.display() + ))) +} + +pub fn missing(spec: &AdapterSpec) -> IdeError { + let mut msg = format!( + "debug adapter `{}` not found (`{}` is neither installed by OpenIDEA nor on PATH)", + spec.adapter, spec.command + ); + if let Some(hint) = &spec.install_hint { + msg.push_str(". "); + msg.push_str(hint); + } + IdeError::unavailable(msg) +} + +pub type BoxReader = Box; +pub type BoxWriter = Box; + +/// A connected adapter: byte streams plus the process (if we spawned one). +pub struct AdapterIo { + pub reader: BoxReader, + pub writer: BoxWriter, + pub child: Option, + /// TCP port of a server-mode adapter; child sessions (`startDebugging`) connect to it again. + pub port: Option, + /// Recent stderr lines of the adapter process, for error messages. + pub stderr: Arc>>, +} + +pub type LaunchFuture = Pin> + Send>>; + +/// Starts adapters. Tests substitute in-memory adapters. +pub trait AdapterLauncher: Send + Sync + 'static { + /// Starts `spec`, or, when `reuse_port` is set, opens another connection to + /// an already running server-mode adapter. + fn launch(&self, spec: AdapterSpec, reuse_port: Option) -> LaunchFuture; +} + +pub struct ProcessLauncher; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +impl AdapterLauncher for ProcessLauncher { + fn launch(&self, mut spec: AdapterSpec, reuse_port: Option) -> 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?; + 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?; + } + let (command, cwd) = (spec.command.clone(), spec.cwd.clone()); + let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd)) + .await + .ok() + .flatten() + .ok_or_else(|| missing(&spec))?; + let port = match spec.transport { + Transport::Tcp => Some(free_port()?), + Transport::Stdio => None, + }; + let args = match port { + Some(port) => substitute_port(&spec.args, port), + None => spec.args.clone(), + }; + let mut cmd = Command::new(&binary); + cmd.args(&args) + .envs(spec.env.iter().map(|(k, v)| (k, v))) + .current_dir(&spec.cwd) + .stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = + cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?; + if let Some(err) = child.stderr.take() { + pump_lines(err, spec.adapter.clone(), stderr.clone()); + } + match port { + None => { + let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?; + let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?; + Ok(AdapterIo { + reader: Box::new(stdout), + writer: Box::new(stdin), + child: Some(child), + port: None, + stderr, + }) + } + Some(port) => { + 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 { + Ok(s) => s, + Err(e) => { + let _ = child.start_kill(); + let tail = stderr.lock().join("\n"); + return Err(if tail.is_empty() { + e + } else { + IdeError::unavailable(format!("{}\n{tail}", e.message)) + }); + } + }; + let (r, w) = stream.into_split(); + Ok(AdapterIo { + reader: Box::new(r), + writer: Box::new(w), + child: Some(child), + port: Some(port), + stderr, + }) + } + } + }) + } +} + +fn pump_lines( + stream: R, + name: String, + sink: Arc>>, +) { + 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); + } + }); +} + +async fn connect_with_retries( + port: u16, + mut child: Option<&mut Child>, + timeout: Duration, +) -> IdeResult { + let deadline = Instant::now() + timeout; + loop { + match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Ok(s) => { + let _ = s.set_nodelay(true); + return Ok(s); + } + Err(e) => { + if let Some(child) = child.as_deref_mut() + && let Ok(Some(status)) = child.try_wait() + { + return Err(IdeError::unavailable(format!( + "debug adapter exited before accepting connections ({status})" + ))); + } + if Instant::now() >= deadline { + return Err(IdeError::unavailable(format!( + "could not connect to debug adapter on port {port}: {e}" + ))); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Managed tools win over `PATH`, and debugpy falls back to the private copy. + #[cfg(unix)] + #[tokio::test] + async fn managed_tools_and_private_debugpy() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!("openidea-dap-tools-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let exe = |p: &Path, body: &str| { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + let adapter = root.join("js-debug").join("bin").join("openidea-test-js-debug-adapter"); + exe(&adapter, "#!/bin/sh\n"); + // An interpreter without debugpy. + let python = root.join("fake-python"); + exe(&python, "#!/bin/sh\nexit 1\n"); + set_tools_root(root.clone()); + assert_eq!(find_binary("openidea-test-js-debug-adapter", &root), Some(adapter)); + + let mut spec = resolve(&builtin("debugpy").unwrap(), root.clone()); + spec.command = python.to_string_lossy().into_owned(); + let err = prepare_debugpy(&mut spec).await.unwrap_err(); + assert!(err.message.starts_with(DEBUGPY_MISSING), "{}", err.message); + + let lib = root.join(DEBUGPY_TOOL).join("lib"); + std::fs::create_dir_all(lib.join("debugpy")).unwrap(); + std::fs::write(lib.join("debugpy").join("__init__.py"), "").unwrap(); + prepare_debugpy(&mut spec).await.unwrap(); + let (k, v) = &spec.env[0]; + assert_eq!(k, "PYTHONPATH"); + assert!(v.starts_with(&*lib.to_string_lossy()), "{v}"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn substitutes_port_everywhere() { + let args: Vec = ["dap", "--listen", "127.0.0.1:${port}", "${port}"].map(String::from).into(); + assert_eq!(substitute_port(&args, 4711), ["dap", "--listen", "127.0.0.1:4711", "4711"]); + assert_eq!(substitute_port(&["--x".into()], 1), ["--x"]); + } + + #[test] + fn builtins_cover_common_adapters() { + for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] { + let cfg = builtin(name).unwrap(); + assert!(cfg.install_hint.is_some()); + } + let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/")); + assert_eq!(delve.transport, Transport::Tcp); + assert!(builtin("nope").is_none()); + } + + #[test] + fn free_port_is_nonzero() { + assert_ne!(free_port().unwrap(), 0); + } + + #[test] + fn missing_binary_reports_hint() { + let spec = resolve(&builtin("netcoredbg").unwrap(), std::env::temp_dir()); + assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none()); + let err = missing(&spec); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + assert!(err.message.contains("netcoredbg/releases")); + } + + #[tokio::test] + async fn process_launcher_reports_missing_binary() { + let mut spec = resolve(&builtin("netcoredbg").unwrap(), std::env::temp_dir()); + spec.command = "definitely-not-a-real-binary-openidea".into(); + let err = ProcessLauncher.launch(spec, None).await.err().unwrap(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + } +} diff --git a/crates/ide-dap/src/attach.rs b/crates/ide-dap/src/attach.rs new file mode 100644 index 00000000..9eb9edfa --- /dev/null +++ b/crates/ide-dap/src/attach.rs @@ -0,0 +1,326 @@ +//! Attach to Process: the current user's processes, grouped by the debugger that can +//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node, +//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB). + +use crate::adapter; +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AttachKind { + Java, + Node, + Python, + Go, + Native, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AttachableProcess { + pub pid: u32, + /// Executable name. + pub name: String, + pub command_line: String, + pub kind: AttachKind, + /// Debug adapter that attaches to it. + pub adapter: String, + /// Debug port the process listens on (JDWP, `--inspect`, `debugpy --listen`). + pub host: Option, + pub port: Option, +} + +/// One row of the process table. +#[derive(Clone, Debug)] +pub struct RawProcess { + pub pid: u32, + pub exe: String, + pub args: String, +} + +/// `[host:]port` of a debug agent address (`*` and empty hosts mean loopback). +fn host_port(addr: &str) -> Option<(Option, u16)> { + let addr = addr.trim(); + let (host, port) = match addr.rsplit_once(':') { + Some((h, p)) => (Some(h), p), + None => (None, addr), + }; + let port: u16 = port.parse().ok()?; + let host = host.filter(|h| !h.is_empty() && *h != "*" && *h != "0.0.0.0").map(str::to_owned); + Some((host, port)) +} + +/// `-agentlib:jdwp=transport=dt_socket,server=y,address=*:5005` (or `-Xrunjdwp:`). +pub fn jdwp_address(args: &str) -> Option<(Option, u16)> { + let opts = args + .split_whitespace() + .find_map(|a| a.strip_prefix("-agentlib:jdwp=").or_else(|| a.strip_prefix("-Xrunjdwp:")))?; + let mut server = false; + let mut address = None; + for kv in opts.split(',') { + match kv.split_once('=') { + Some(("server", v)) => server = v == "y", + Some(("address", v)) => address = Some(v), + _ => {} + } + } + if !server { + return None; + } + host_port(address?) +} + +/// `--inspect`, `--inspect-brk=9230`, `--inspect=127.0.0.1:9230` (default port 9229). +fn inspect_address(args: &str) -> Option<(Option, u16)> { + args.split_whitespace().find_map(|a| { + let rest = a.strip_prefix("--inspect-brk").or_else(|| a.strip_prefix("--inspect"))?; + if rest.is_empty() { Some((None, 9229)) } else { host_port(rest.strip_prefix('=')?) } + }) +} + +/// `-m debugpy --listen [host:]port`. +fn debugpy_listen(args: &str) -> Option<(Option, u16)> { + let words: Vec<&str> = args.split_whitespace().collect(); + if !words.contains(&"debugpy") { + return None; + } + let i = words.iter().position(|w| *w == "--listen")?; + host_port(words.get(i + 1)?) +} + +fn file_name(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +fn is_python(name: &str) -> bool { + let n = name.strip_suffix(".exe").unwrap_or(name); + n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')) +} + +/// Classifies a process; `is_go` is asked only for processes no interpreter claims. +pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option { + let name = file_name(&p.exe).to_owned(); + let make = |kind, adapter: &str, addr: Option<(Option, u16)>| { + let (host, port) = match addr { + Some((h, p)) => (h, Some(p)), + None => (None, None), + }; + Some(AttachableProcess { + pid: p.pid, + name: name.clone(), + command_line: p.args.clone(), + kind, + adapter: adapter.to_owned(), + host, + port, + }) + }; + let base = name.strip_suffix(".exe").unwrap_or(&name); + if base == "java" || base == "javaw" { + // Only JVMs started with a JDWP agent can be attached to. + return jdwp_address(&p.args).and_then(|a| make(AttachKind::Java, "java-debug", Some(a))); + } + if base == "node" || base == "nodejs" { + return make(AttachKind::Node, "js-debug", inspect_address(&p.args)); + } + if is_python(&name) { + return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args)); + } + if is_go(&p.exe) { + return make(AttachKind::Go, "delve", None); + } + make(AttachKind::Native, native_adapter, None) +} + +const GO_BUILDINFO_MAGIC: &[u8] = b"\xff Go buildinf:"; +/// Go binaries carry their build info near the start of the data segment. +const GO_SCAN_LIMIT: u64 = 96 << 20; + +/// Whether `exe` is a Go binary (has a `Go buildinf:` header). System locations are skipped. +pub fn is_go_binary(exe: &str) -> bool { + use std::io::Read; + let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"]; + if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() { + return false; + } + let Ok(file) = std::fs::File::open(exe) else { return false }; + let mut reader = std::io::BufReader::with_capacity(1 << 20, file.take(GO_SCAN_LIMIT)); + let finder = memchr::memmem::Finder::new(GO_BUILDINFO_MAGIC); + let mut buf = vec![0u8; 1 << 20]; + let mut carry = Vec::new(); + loop { + let n = match reader.read(&mut buf) { + Ok(0) | Err(_) => return false, + Ok(n) => n, + }; + carry.extend_from_slice(&buf[..n]); + if finder.find(&carry).is_some() { + return true; + } + let keep = carry.len().min(GO_BUILDINFO_MAGIC.len() - 1); + carry.drain(..carry.len() - keep); + } +} + +/// The current user's processes (without this one). +#[cfg(unix)] +pub fn list_processes() -> IdeResult> { + use std::collections::HashMap; + let ps = |fields: &str| -> IdeResult { + let out = std::process::Command::new("ps") + .args(["-x", "-ww", "-o", fields]) + .output() + .map_err(|e| IdeError::unavailable(format!("cannot list processes: {e}")))?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + }; + let split = |line: &str| -> Option<(u32, String)> { + let line = line.trim_start(); + let (pid, rest) = line.split_once(char::is_whitespace)?; + Some((pid.parse().ok()?, rest.trim().to_owned())) + }; + // `comm` is the executable path on macOS; on Linux it is truncated, so read /proc. + let exes: HashMap = + if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() }; + let me = std::process::id(); + Ok(ps("pid=,args=")? + .lines() + .filter_map(split) + .filter(|(pid, _)| *pid != me) + .map(|(pid, args)| { + let exe = exes + .get(&pid) + .cloned() + .or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string())) + .unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned()); + RawProcess { pid, exe, args } + }) + .collect()) +} + +#[cfg(not(unix))] +pub fn list_processes() -> IdeResult> { + Err(IdeError::unavailable("Attach to Process is not supported on this platform yet")) +} + +/// Node's default inspector port, which `SIGUSR1` opens. +pub const NODE_INSPECTOR_PORT: u16 = 9229; + +/// Turns on the inspector of a running Node process (`SIGUSR1`, as js-debug's process +/// picker does) and waits until it listens. Returns the inspector port. +#[cfg(unix)] +pub async fn enable_node_inspector(pid: u32) -> IdeResult { + let status = tokio::process::Command::new("kill") + .args(["-USR1", &pid.to_string()]) + .status() + .await + .map_err(|e| IdeError::unavailable(format!("cannot signal process {pid}: {e}")))?; + if !status.success() { + return Err(IdeError::unavailable(format!("cannot signal process {pid}"))); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if tokio::net::TcpStream::connect(("127.0.0.1", NODE_INSPECTOR_PORT)).await.is_ok() { + return Ok(NODE_INSPECTOR_PORT); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Err(IdeError::unavailable(format!( + "process {pid} did not open its inspector on port {NODE_INSPECTOR_PORT} (is the port taken?)" + ))) +} + +#[cfg(not(unix))] +pub async fn enable_node_inspector(pid: u32) -> IdeResult { + Err(IdeError::unavailable(format!("start process {pid} with --inspect to attach to it"))) +} + +/// Attachable processes, sorted by kind then name. Blocking (runs `ps`, reads binaries). +pub fn attachable_processes() -> IdeResult> { + let cwd = std::env::temp_dir(); + let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" }; + let mut out: Vec = + list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect(); + out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(exe: &str, args: &str) -> RawProcess { + RawProcess { pid: 7, exe: exe.into(), args: args.into() } + } + + fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option)> { + classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port)) + } + + #[test] + fn parses_jdwp_agents() { + let a = "java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar"; + assert_eq!(jdwp_address(a), Some((None, 5005))); + assert_eq!( + jdwp_address("java -Xrunjdwp:transport=dt_socket,server=y,address=10.0.0.2:8000 X"), + Some((Some("10.0.0.2".into()), 8000)) + ); + // A client-mode agent connects out; nothing to attach to. + assert_eq!(jdwp_address("java -agentlib:jdwp=transport=dt_socket,server=n,address=5005 X"), None); + assert_eq!(kind("/jdk/bin/java", a), Some((AttachKind::Java, Some(5005)))); + assert_eq!(kind("/jdk/bin/java", "java -jar app.jar"), None); + } + + #[test] + fn classifies_interpreters() { + assert_eq!(kind("/usr/local/bin/node", "node --inspect server.js"), Some((AttachKind::Node, Some(9229)))); + assert_eq!( + kind("/usr/local/bin/node", "node --inspect-brk=127.0.0.1:9333 a.js"), + Some((AttachKind::Node, Some(9333))) + ); + assert_eq!(kind("/usr/local/bin/node", "node a.js"), Some((AttachKind::Node, None))); + assert_eq!(kind("/usr/bin/python3.12", "python3.12 app.py"), Some((AttachKind::Python, None))); + assert_eq!( + kind("/usr/bin/python3", "python3 -m debugpy --listen 5678 --wait-for-client app.py"), + Some((AttachKind::Python, Some(5678))) + ); + assert_eq!( + kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"), + Some((AttachKind::Python, None)) + ); + assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None))); + assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None))); + assert!(!is_python("pythonista")); + } + + #[test] + fn detects_go_binaries_by_buildinfo() { + let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let go = dir.join("gobin"); + let mut bytes = vec![0u8; (1 << 20) - 5]; + bytes.extend_from_slice(GO_BUILDINFO_MAGIC); + std::fs::write(&go, &bytes).unwrap(); + let other = dir.join("cbin"); + std::fs::write(&other, vec![0u8; 4096]).unwrap(); + assert!(is_go_binary(go.to_str().unwrap())); + assert!(!is_go_binary(other.to_str().unwrap())); + assert!(!is_go_binary("/usr/bin/true")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn lists_own_processes() { + let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap(); + let list = list_processes().unwrap(); + let _ = child.kill(); + let _ = child.wait(); + let found = list.iter().find(|p| p.pid == child.id()).expect("sleep is listed"); + assert!(found.args.contains("sleep 30")); + assert!(found.exe.ends_with("sleep")); + assert!(list.iter().all(|p| p.pid != std::process::id())); + } +} diff --git a/crates/ide-dap/src/cargo.rs b/crates/ide-dap/src/cargo.rs new file mode 100644 index 00000000..6fb6cb8a --- /dev/null +++ b/crates/ide-dap/src/cargo.rs @@ -0,0 +1,110 @@ +//! `cargo` launch integration (what the CodeLLDB VS Code extension does): +//! a launch request may carry `"cargo": {"args": ["build", …], "filter": {…}}` +//! instead of `program`; we run the build with JSON messages and launch the +//! produced executable. + +use crate::adapter::find_binary; +use ide_core::{IdeError, IdeResult}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; + +#[derive(Debug, Default, Clone)] +pub struct CargoFilter { + pub name: Option, + pub kind: Option, +} + +impl CargoFilter { + pub fn from_json(v: Option<&Value>) -> Self { + let get = |k: &str| v.and_then(|v| v.get(k)).and_then(Value::as_str).map(str::to_owned); + Self { name: get("name"), kind: get("kind") } + } +} + +/// Picks the executable from one `cargo --message-format=json` stdout line. +pub fn artifact_executable(line: &str, filter: &CargoFilter) -> Option { + let msg: Value = serde_json::from_str(line).ok()?; + if msg.get("reason")?.as_str()? != "compiler-artifact" { + return None; + } + let exe = msg.get("executable")?.as_str()?; + let target = msg.get("target"); + if let Some(name) = &filter.name + && target.and_then(|t| t.get("name")).and_then(Value::as_str) != Some(name.as_str()) + { + return None; + } + if let Some(kind) = &filter.kind { + let kinds = target.and_then(|t| t.get("kind")).and_then(Value::as_array); + if !kinds.is_some_and(|k| k.iter().any(|x| x.as_str() == Some(kind.as_str()))) { + return None; + } + } + Some(PathBuf::from(exe)) +} + +/// Runs cargo and returns the (last matching) executable artifact. Human +/// readable compiler output is passed to `on_output` line by line. +pub async fn build( + cwd: &Path, + args: &[String], + filter: &CargoFilter, + on_output: impl Fn(String) + Send + Sync + 'static, +) -> IdeResult { + let cargo = find_binary("cargo", cwd).ok_or_else(|| IdeError::unavailable("`cargo` not found on PATH"))?; + let mut cmd = tokio::process::Command::new(cargo); + cmd.args(args) + .arg("--message-format=json-render-diagnostics") + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + on_output(format!("> cargo {}\n", args.join(" "))); + let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to run cargo: {e}")))?; + let stderr = child.stderr.take().ok_or_else(|| IdeError::internal("cargo stderr unavailable"))?; + let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("cargo stdout unavailable"))?; + let on_output = std::sync::Arc::new(on_output); + let err_out = on_output.clone(); + let err_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + err_out(format!("{line}\n")); + } + }); + let mut found = None; + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(exe) = artifact_executable(&line, filter) { + found = Some(exe); + } + } + let _ = err_task.await; + let status = child.wait().await?; + if !status.success() { + return Err(IdeError::invalid(format!("cargo {} failed ({status})", args.first().map_or("", |s| s)))); + } + found.ok_or_else(|| IdeError::invalid("cargo produced no executable to debug")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_executables_with_filters() { + let line = + r#"{"reason":"compiler-artifact","target":{"name":"app","kind":["bin"]},"executable":"/t/debug/app"}"#; + let lib = r#"{"reason":"compiler-artifact","target":{"name":"core","kind":["lib"]},"executable":null}"#; + let any = CargoFilter::default(); + assert_eq!(artifact_executable(line, &any), Some(PathBuf::from("/t/debug/app"))); + assert_eq!(artifact_executable(lib, &any), None); + assert_eq!(artifact_executable("not json", &any), None); + let other = CargoFilter { name: Some("other".into()), kind: None }; + assert_eq!(artifact_executable(line, &other), None); + let bin = CargoFilter { name: None, kind: Some("bin".into()) }; + assert!(artifact_executable(line, &bin).is_some()); + } +} diff --git a/crates/ide-dap/src/connection.rs b/crates/ide-dap/src/connection.rs new file mode 100644 index 00000000..7785fc4f --- /dev/null +++ b/crates/ide-dap/src/connection.rs @@ -0,0 +1,297 @@ +//! One DAP wire connection: `Content-Length` framing (shared with LSP via +//! `ide_jsonrpc::framing`), sequence numbers, request/response correlation, +//! and delivery of events and reverse requests to the session task. +//! +//! DAP is not JSON-RPC: every message is `{seq, type, ...}` where `type` is +//! `request` (`command`, `arguments`), `response` (`request_seq`, `success`, +//! `command`, `message`, `body`) or `event` (`event`, `body`). + +use ide_jsonrpc::framing; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite, BufReader}; +use tokio::sync::{mpsc, oneshot}; + +#[derive(Debug, Clone)] +pub enum DapError { + /// The adapter answered `success: false`. + Failed { + command: String, + message: String, + }, + Closed, + Timeout(String), + Protocol(String), +} + +impl std::fmt::Display for DapError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed { command, message } => write!(f, "{command} failed: {message}"), + Self::Closed => f.write_str("debug adapter connection closed"), + Self::Timeout(command) => write!(f, "debug adapter did not answer `{command}` in time"), + Self::Protocol(m) => write!(f, "invalid debug adapter message: {m}"), + } + } +} + +impl From for ide_core::IdeError { + fn from(e: DapError) -> Self { + use ide_core::error::ErrorKind; + let kind = match e { + DapError::Closed => ErrorKind::Unavailable, + DapError::Failed { .. } => ErrorKind::InvalidInput, + DapError::Timeout(_) | DapError::Protocol(_) => ErrorKind::Protocol, + }; + ide_core::IdeError::new(kind, e.to_string()) + } +} + +/// Adapter-initiated traffic, delivered in arrival order. +#[derive(Debug)] +pub enum Incoming { + Event { + event: String, + body: Value, + }, + /// Reverse request (`runInTerminal`, `startDebugging`); answer with [`Connection::respond`]. + Request { + seq: i64, + command: String, + arguments: Value, + }, + /// The transport is gone; always the last message. + Closed, +} + +type Pending = HashMap>)>; + +struct Inner { + out: mpsc::UnboundedSender>, + pending: Mutex, + seq: AtomicI64, + closed: AtomicBool, + cancel: ide_core::CancellationToken, +} + +impl Inner { + fn close(&self) { + self.closed.store(true, Ordering::Release); + for (_, (_, tx)) in std::mem::take(&mut *self.pending.lock()) { + let _ = tx.send(Err(DapError::Closed)); + } + } +} + +/// A connected adapter. Cloning shares the connection. +#[derive(Clone)] +pub struct Connection { + inner: Arc, +} + +impl Connection { + /// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime. + pub fn start(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver) + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let (out_tx, mut out_rx) = mpsc::unbounded_channel::>(); + let (in_tx, in_rx) = mpsc::unbounded_channel(); + let inner = Arc::new(Inner { + out: out_tx, + pending: Mutex::new(HashMap::new()), + seq: AtomicI64::new(1), + closed: false.into(), + cancel: ide_core::CancellationToken::new(), + }); + + let weak = Arc::downgrade(&inner); + let cancel = inner.cancel.clone(); + tokio::spawn(async move { + loop { + let body = tokio::select! { + body = out_rx.recv() => body, + _ = cancel.cancelled() => None, + }; + let Some(body) = body else { break }; + if let Err(e) = framing::write_message(&mut writer, &body).await { + tracing::debug!("dap write failed: {e}"); + if let Some(inner) = weak.upgrade() { + inner.close(); + } + break; + } + } + let _ = tokio::io::AsyncWriteExt::shutdown(&mut writer).await; + }); + + let reader_inner = inner.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(reader); + loop { + let msg = tokio::select! { + msg = framing::read_message(&mut reader) => msg, + _ = reader_inner.cancel.cancelled() => break, + }; + match msg { + Ok(Some(body)) => dispatch(&reader_inner, &in_tx, &body), + Ok(None) => break, + Err(e) => { + tracing::warn!("dap read failed: {e}"); + break; + } + } + } + reader_inner.close(); + let _ = in_tx.send(Incoming::Closed); + }); + + (Self { inner }, in_rx) + } + + /// Drops the transport; pending requests fail and the incoming stream ends. + pub fn close(&self) { + self.inner.close(); + self.inner.cancel.cancel(); + } + + pub fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::Acquire) + } + + fn send(&self, value: &Value) -> Result<(), DapError> { + if self.is_closed() { + return Err(DapError::Closed); + } + let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?; + tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body)); + self.inner.out.send(body).map_err(|_| DapError::Closed) + } + + /// Sends a request and returns a receiver for its response body, without waiting. + pub fn send_request( + &self, + command: &str, + arguments: Value, + ) -> Result>, DapError> { + let seq = self.inner.seq.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.inner.pending.lock().insert(seq, (command.to_owned(), tx)); + let mut msg = json!({ "seq": seq, "type": "request", "command": command }); + if !arguments.is_null() { + msg["arguments"] = arguments; + } + if let Err(e) = self.send(&msg) { + self.inner.pending.lock().remove(&seq); + return Err(e); + } + Ok(rx) + } + + /// Sends a request and waits for the response body (`null` when absent). + pub async fn request(&self, command: &str, arguments: Value) -> Result { + let rx = self.send_request(command, arguments)?; + rx.await.unwrap_or(Err(DapError::Closed)) + } + + pub async fn request_timeout(&self, command: &str, arguments: Value, timeout: Duration) -> Result { + match tokio::time::timeout(timeout, self.request(command, arguments)).await { + Ok(r) => r, + Err(_) => Err(DapError::Timeout(command.to_owned())), + } + } + + /// Answers a reverse request. + pub fn respond(&self, request_seq: i64, command: &str, result: Result) -> Result<(), DapError> { + let seq = self.inner.seq.fetch_add(1, Ordering::Relaxed); + let mut msg = json!({ + "seq": seq, + "type": "response", + "request_seq": request_seq, + "command": command, + "success": result.is_ok(), + }); + match result { + Ok(body) if !body.is_null() => msg["body"] = body, + Ok(_) => {} + Err(message) => msg["message"] = Value::String(message), + } + self.send(&msg) + } +} + +fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender, body: &[u8]) { + tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body)); + let msg: Value = match serde_json::from_slice(body) { + Ok(v) => v, + Err(e) => { + tracing::warn!("dap: unparsable message: {e}"); + return; + } + }; + match msg.get("type").and_then(Value::as_str) { + Some("response") => { + let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else { + tracing::warn!("dap: response without request_seq"); + return; + }; + let Some((command, tx)) = inner.pending.lock().remove(&request_seq) else { + tracing::debug!("dap: response for unknown request {request_seq}"); + return; + }; + let result = if msg.get("success").and_then(Value::as_bool).unwrap_or(false) { + Ok(msg.get("body").cloned().unwrap_or(Value::Null)) + } else { + Err(DapError::Failed { command, message: error_message(&msg) }) + }; + let _ = tx.send(result); + } + Some("event") => { + let event = msg.get("event").and_then(Value::as_str).unwrap_or_default().to_owned(); + let body = msg.get("body").cloned().unwrap_or(Value::Null); + let _ = incoming.send(Incoming::Event { event, body }); + } + Some("request") => { + let seq = msg.get("seq").and_then(Value::as_i64).unwrap_or_default(); + let command = msg.get("command").and_then(Value::as_str).unwrap_or_default().to_owned(); + let arguments = msg.get("arguments").cloned().unwrap_or(Value::Null); + let _ = incoming.send(Incoming::Request { seq, command, arguments }); + } + other => tracing::warn!("dap: unknown message type {other:?}"), + } +} + +/// `message`, or the formatted `body.error` (`{format, variables}`) when present. +fn error_message(msg: &Value) -> String { + if let Some(err) = msg.pointer("/body/error") + && let Some(format) = err.get("format").and_then(Value::as_str) + { + let mut text = format.to_owned(); + if let Some(vars) = err.get("variables").and_then(Value::as_object) { + for (k, v) in vars { + let v = v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string()); + text = text.replace(&format!("{{{k}}}"), &v); + } + } + return text; + } + msg.get("message").and_then(Value::as_str).unwrap_or("request failed").to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_structured_errors() { + let msg = + json!({"message": "x", "body": {"error": {"format": "cannot find {file}", "variables": {"file": "a.py"}}}}); + assert_eq!(error_message(&msg), "cannot find a.py"); + assert_eq!(error_message(&json!({"message": "nope"})), "nope"); + } +} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs new file mode 100644 index 00000000..957635c3 --- /dev/null +++ b/crates/ide-dap/src/lib.rs @@ -0,0 +1,1273 @@ +//! Debug Adapter Protocol client. +//! +//! [`DebugManager`] owns every debug session plus the per-file breakpoint +//! store (independent of sessions; sent to each session during configuration +//! and updated live). Sessions emit `IdeEvent::Debug`; the UI then pulls +//! threads, frames, scopes and variables on demand. +//! +//! All lines and columns in this API are 0-based; the client tells adapters +//! `linesStartAt1`/`columnsStartAt1` and converts at this edge. + +pub mod adapter; +pub mod attach; +mod cargo; +pub mod connection; +mod session; + +use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; +use connection::{Connection, DapError}; +use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink}; +use ide_language::LanguageRegistry; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use session::Session; +use specta::Type; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Weak}; +use std::time::Duration; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LaunchConfig { + pub name: String, + /// DAP adapter name from `languages/*.toml` (or a built-in: codelldb, debugpy, js-debug, delve, netcoredbg). + pub adapter: String, + /// `launch` or `attach`. + pub request: String, + pub program: Option, + pub args: Vec, + pub cwd: Option, + pub env: BTreeMap, + /// Adapter specific extra arguments as JSON text, merged into the launch request. + /// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo + /// and launches the produced executable; `"debugServer": ` connects to an + /// adapter already listening on that local port (e.g. java-debug inside jdtls) + /// instead of starting one. + pub extra_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Breakpoint { + pub line: u32, + pub condition: Option, + pub hit_condition: Option, + /// Logpoint message; when set the breakpoint logs instead of suspending. + pub log_message: Option, + pub enabled: bool, +} + +/// A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax: +/// `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain +/// function name for debugpy and LLDB. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FunctionBreakpoint { + pub name: String, + pub condition: Option, + pub hit_condition: Option, + pub enabled: bool, +} + +/// A field watchpoint (DAP data breakpoint) on a `dataId` from [`DebugManager::data_breakpoint_info`] +/// (java-debug ids, `Class#field`, stay valid across sessions). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DataBreakpoint { + pub data_id: String, + /// `read`, `write` or `readWrite` (adapter default when unset). + pub access_type: Option, + pub condition: Option, + pub hit_condition: Option, + pub enabled: bool, +} + +/// Answer of `dataBreakpointInfo`: `data_id` is `None` when the value cannot be watched. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DataBreakpointInfo { + pub data_id: Option, + pub description: String, + pub access_types: Vec, +} + +/// A call on the current line that Smart Step Into can enter (`stepInTargets`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StepInTarget { + pub id: i64, + pub label: String, + /// 0-based, when the adapter reports where the call is. + pub line: Option, + pub column: Option, + pub end_column: Option, +} + +/// What a session's adapter supports, for enabling actions. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DebugCapabilities { + pub restart_frame: bool, + pub step_in_targets: bool, + pub function_breakpoints: bool, + pub data_breakpoints: bool, + /// `setVariable` or `setExpression`. + pub set_value: bool, + pub conditional_breakpoints: bool, + pub hit_conditional_breakpoints: bool, + pub log_points: bool, + /// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request. + pub hot_code_replace: bool, +} + +/// Result of [`DebugManager::redefine_classes`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HotSwapResult { + /// Fully qualified names of the classes reloaded (empty: nothing had changed). + pub changed_classes: Vec, + pub error_message: Option, +} + +/// Adapters that implement java-debug's hot code replace protocol. +pub const HOT_CODE_REPLACE_ADAPTERS: &[&str] = &["java-debug"]; + +/// java-debug's `HotCodeReplaceEvent.changeType` (an enum name, or its numeric code). +pub(crate) fn hot_code_replace_type(v: Option<&Value>) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => match n.as_i64() { + Some(-1) => "ERROR", + Some(-2) => "WARNING", + Some(1) => "STARTING", + Some(2) => "END", + Some(3) => "BUILD_COMPLETE", + _ => "UNKNOWN", + } + .to_owned(), + _ => "UNKNOWN".to_owned(), + } +} + +pub(crate) fn hot_swap_result(body: &Value) -> HotSwapResult { + HotSwapResult { + changed_classes: body + .get("changedClasses") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| c.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(), + error_message: body.get("errorMessage").and_then(Value::as_str).filter(|m| !m.is_empty()).map(str::to_owned), + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BreakpointState { + pub line: u32, + pub verified: bool, + pub message: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct ThreadInfo { + pub id: i64, + pub name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StackFrame { + pub id: i64, + pub name: String, + pub path: Option, + pub line: u32, + pub column: u32, + /// `subtle` frames (library/generated) are collapsed in the UI. + pub presentation_hint: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Scope { + pub name: String, + pub variables_reference: i64, + pub expensive: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Variable { + pub name: String, + pub value: String, + pub type_name: Option, + /// > 0 when expandable. + pub variables_reference: i64, + pub evaluate_name: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum StepKind { + Over, + Into, + Out, +} + +/// An exception breakpoint filter offered by an adapter (`exceptionBreakpointFilters`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ExceptionFilter { + pub filter: String, + pub label: String, + pub description: Option, + /// Enabled unless the user chose otherwise. + pub default: bool, + pub supports_condition: bool, +} + +/// Exception filters of one session (see [`DebugManager::exception_filters`]). +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SessionExceptionFilters { + pub session: DebugSessionId, + pub adapter: String, + pub filters: Vec, +} + +/// A live session, as listed by [`DebugManager::sessions`]. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: DebugSessionId, + pub name: String, + pub adapter: String, + /// Set for child sessions started by the adapter (`startDebugging`). + pub parent: Option, +} + +/// Who started a session. Every event of the session, and of the child sessions +/// its adapter starts, goes to `sink` only; `key` (e.g. a window label) scopes +/// listings such as [`DebugManager::sessions`]. +#[derive(Clone)] +pub struct SessionOwner { + pub key: String, + pub sink: SharedSink, +} + +impl SessionOwner { + pub fn new(key: impl Into, sink: SharedSink) -> Self { + Self { key: key.into(), sink } + } +} + +const INIT_TIMEOUT: Duration = Duration::from_secs(20); +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +/// Redefining many classes in a large JVM can take a while. +const HOT_SWAP_TIMEOUT: Duration = Duration::from_secs(120); + +pub struct DebugManager { + languages: Arc, + launcher: Arc, + me: Weak, + sessions: Mutex>>, + /// Session start order, for "the first session" semantics. + order: Mutex>, + breakpoints: Mutex>>, + states: Mutex>>, + /// Explicit exception filter choices (filter id → enabled); unset filters use the adapter default. + exception_choices: Mutex>, + function_breakpoints: Mutex>, + data_breakpoints: Mutex>, +} + +fn no_session(s: DebugSessionId) -> IdeError { + IdeError::not_found(format!("no debug session {}", s.0)) +} + +/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top. +pub fn launch_arguments(config: &LaunchConfig) -> IdeResult { + // VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch + // or attach from `request` there, not from the DAP command. + let mut args = json!({ "name": config.name, "request": config.request }); + if let Some(t) = adapter::default_type(&config.adapter) { + args["type"] = t.into(); + } + if let Some(p) = &config.program { + args["program"] = p.as_str().into(); + } + if !config.args.is_empty() { + args["args"] = json!(config.args); + } + if let Some(cwd) = &config.cwd { + args["cwd"] = json!(cwd); + } + if !config.env.is_empty() { + args["env"] = json!(config.env); + } + if let Some(extra) = config.extra_json.as_deref().filter(|e| !e.trim().is_empty()) { + let extra: Value = + serde_json::from_str(extra).map_err(|e| IdeError::invalid(format!("invalid launch JSON: {e}")))?; + let Value::Object(extra) = extra else { + return Err(IdeError::invalid("launch JSON must be an object")); + }; + if let Some(obj) = args.as_object_mut() { + obj.extend(extra); + } + } + Ok(args) +} + +/// Placeholder in launch arguments for a free local port, the same one everywhere in a +/// launch: Deno's `--inspect-wait=127.0.0.1:${freePort}` and js-debug's +/// `"attachSimplePort": "${freePort}"` (a `port` / `…Port` attribute that is only the +/// placeholder becomes a number; everything else stays a string, e.g. `env` values). +pub const FREE_PORT_PLACEHOLDER: &str = "${freePort}"; + +/// Replaces [`FREE_PORT_PLACEHOLDER`] in every string of `value` with `port()` (called once, if used). +pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() -> IdeResult) -> IdeResult<()> { + fn walk(v: &mut Value, key: &str, port: &mut dyn FnMut() -> IdeResult) -> IdeResult<()> { + match v { + Value::String(s) if s.contains(FREE_PORT_PLACEHOLDER) => { + let p = port()?; + *v = if s == FREE_PORT_PLACEHOLDER && (key == "port" || key.ends_with("Port")) { + p.into() + } else { + s.replace(FREE_PORT_PLACEHOLDER, &p.to_string()).into() + }; + } + Value::Array(a) => a.iter_mut().try_for_each(|x| walk(x, key, port))?, + Value::Object(o) => o.iter_mut().try_for_each(|(k, x)| walk(x, k, port))?, + _ => {} + } + Ok(()) + } + let mut chosen = None; + walk(value, "", &mut || match chosen { + Some(p) => Ok(p), + None => { + let p = port()?; + chosen = Some(p); + Ok(p) + } + }) +} + +/// 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> { + 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}"))), + } +} + +/// DAP stack frame → [`StackFrame`] (1-based → 0-based). +pub(crate) fn frame_from(f: &Value) -> StackFrame { + let source = f.get("source"); + let source_hint = source.and_then(|s| s.get("presentationHint")).and_then(Value::as_str); + let hint = f + .get("presentationHint") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| (source_hint == Some("deemphasize")).then(|| "subtle".to_owned())); + StackFrame { + id: f.get("id").and_then(Value::as_i64).unwrap_or_default(), + name: f.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(), + path: source.and_then(|s| s.get("path")).and_then(Value::as_str).map(Into::into), + line: (f.get("line").and_then(Value::as_u64).unwrap_or(0) as u32).saturating_sub(1), + column: (f.get("column").and_then(Value::as_u64).unwrap_or(0) as u32).saturating_sub(1), + presentation_hint: hint, + } +} + +pub(crate) fn exception_filters_from(caps: &Value) -> Vec { + caps.get("exceptionBreakpointFilters") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|f| { + let filter = f.get("filter").and_then(Value::as_str)?.to_owned(); + Some(ExceptionFilter { + label: f + .get("label") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| filter.clone()), + description: f.get("description").and_then(Value::as_str).map(str::to_owned), + default: f.get("default").and_then(Value::as_bool).unwrap_or(false), + supports_condition: f.get("supportsCondition").and_then(Value::as_bool).unwrap_or(false), + filter, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Filter ids to enable: explicit choices win (`adapter:filter`, then the bare filter id, +/// since adapters reuse ids such as `uncaught`), otherwise the adapter's default. +pub(crate) fn enabled_filters( + filters: &[ExceptionFilter], + choices: &BTreeMap, + adapter: &str, +) -> Vec { + filters + .iter() + .filter(|f| { + choices + .get(&format!("{adapter}:{}", f.filter)) + .or_else(|| choices.get(&f.filter)) + .copied() + .unwrap_or(f.default) + }) + .map(|f| f.filter.clone()) + .collect() +} + +pub(crate) fn step_in_target_from(t: &Value) -> StepInTarget { + let num = |k: &str| t.get(k).and_then(Value::as_u64).map(|n| (n as u32).saturating_sub(1)); + StepInTarget { + id: t.get("id").and_then(Value::as_i64).unwrap_or_default(), + label: t.get("label").and_then(Value::as_str).unwrap_or_default().to_owned(), + line: num("line"), + column: num("column"), + end_column: num("endColumn"), + } +} + +pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities { + let has = |k: &str| caps.get(k).and_then(Value::as_bool).unwrap_or(false); + DebugCapabilities { + restart_frame: has("supportsRestartFrame"), + step_in_targets: has("supportsStepInTargetsRequest"), + function_breakpoints: has("supportsFunctionBreakpoints"), + data_breakpoints: has("supportsDataBreakpoints"), + set_value: has("supportsSetVariable") || has("supportsSetExpression"), + conditional_breakpoints: has("supportsConditionalBreakpoints"), + hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"), + log_points: has("supportsLogPoints"), + hot_code_replace: false, + } +} + +/// States for breakpoints no session has verified yet. +fn unverified(enabled: impl Iterator) -> Vec { + enabled + .map(|e| BreakpointState { line: 0, verified: false, message: (!e).then(|| "Disabled".to_owned()) }) + .collect() +} + +pub(crate) fn variable_from(v: &Value) -> Variable { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); + Variable { + name: s("name").unwrap_or_default(), + value: s("value").unwrap_or_default(), + type_name: s("type").filter(|t| !t.is_empty()), + variables_reference: v.get("variablesReference").and_then(Value::as_i64).unwrap_or(0), + evaluate_name: s("evaluateName"), + } +} + +impl DebugManager { + pub fn new(languages: Arc) -> Arc { + Self::with_launcher(languages, Arc::new(ProcessLauncher)) + } + + /// Like [`new`](Self::new) with a custom way to start adapters (tests use in-memory adapters). + pub fn with_launcher(languages: Arc, launcher: Arc) -> Arc { + Arc::new_cyclic(|me| Self { + languages, + launcher, + me: me.clone(), + sessions: Mutex::default(), + order: Mutex::default(), + breakpoints: Mutex::default(), + states: Mutex::default(), + exception_choices: Mutex::default(), + function_breakpoints: Mutex::default(), + data_breakpoints: Mutex::default(), + }) + } + + fn session(&self, s: DebugSessionId) -> IdeResult> { + self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s)) + } + + fn live_sessions(&self) -> Vec> { + let sessions = self.sessions.lock(); + self.order.lock().iter().filter_map(|id| sessions.get(id).cloned()).collect() + } + + pub(crate) fn stored_breakpoints(&self, path: &Path) -> Vec { + self.breakpoints.lock().get(path).cloned().unwrap_or_default() + } + + /// Live sessions of `owner` (all owners when `None`). + pub fn sessions(&self, owner: Option<&str>) -> Vec { + self.live_sessions() + .iter() + .filter(|s| owner.is_none_or(|o| s.owner.key == o)) + .map(|s| SessionInfo { id: s.id, name: s.name.clone(), adapter: s.adapter.clone(), parent: s.parent }) + .collect() + } + + /// Starts a session: spawns the adapter, `initialize`, `launch`/`attach`, + /// 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 { + let mut arguments = launch_arguments(&config)?; + let cwd = config + .cwd + .clone() + .map(Into::into) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + 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()); + // debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy). + if config.adapter == "debugpy" { + let python = match arguments.get("python") { + Some(Value::String(p)) => Some(p.clone()), + Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned), + _ => None, + }; + if let Some(python) = python { + spec.command = python; + } + } + let id = DebugSessionId::next(); + let debug_server = take_debug_server(&mut arguments)?; + if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) { + let cargo_args: Vec = cargo + .get("args") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_else(|| vec!["build".into()]); + let filter = cargo::CargoFilter::from_json(cargo.get("filter")); + let sink = owner.sink.clone(); + let program = cargo::build(&cwd, &cargo_args, &filter, move |line| { + sink.emit(ide_core::IdeEvent::Debug { + session: id, + event: ide_core::event::DebugEvent::Output { category: "console".into(), output: line }, + }); + }) + .await?; + arguments["program"] = json!(program); + } + // Attach to a Node process started without `--inspect`: open its inspector first. + if config.adapter == "js-debug" && config.request == "attach" && arguments.get("port").is_none() { + let pid = arguments.get("processId").and_then(|p| match p { + Value::String(s) => s.split(':').next().and_then(|p| p.trim().parse::().ok()), + other => other.as_u64().and_then(|p| u32::try_from(p).ok()), + }); + if let Some(pid) = pid { + let port = attach::enable_node_inspector(pid).await?; + if let Some(obj) = arguments.as_object_mut() { + obj.remove("processId"); + obj.insert("port".into(), port.into()); + obj.entry("address").or_insert_with(|| "127.0.0.1".into()); + } + } + } + substitute_free_port(&mut arguments, &mut || adapter::free_port().map_err(IdeError::from))?; + // VS Code passes the workspace folder: js-debug's default `outFiles` (where it looks + // for compiled code of a TypeScript breakpoint before it loads) is relative to it. + if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() { + arguments["__workspaceFolder"] = json!(cwd); + } + 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 + } + + /// `startDebugging` reverse request: a child session on the same adapter. + pub(crate) async fn start_child( + &self, + parent: &Arc, + request: String, + configuration: Value, + ) -> IdeResult<()> { + let name = + configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone()); + let mut configuration = configuration; + if let Some(obj) = configuration.as_object_mut() { + obj.entry("request").or_insert_with(|| request.clone().into()); + } + self.start_session( + DebugSessionId::next(), + parent.owner.clone(), + name, + parent.adapter.clone(), + Some(parent.id), + request, + configuration, + parent.spec.clone(), + parent.port, + ) + .await + .map(|_| ()) + } + + #[allow(clippy::too_many_arguments)] + async fn start_session( + &self, + id: DebugSessionId, + owner: SessionOwner, + name: String, + adapter_name: String, + parent: Option, + request: String, + arguments: Value, + spec: AdapterSpec, + reuse_port: Option, + ) -> IdeResult { + if request != "launch" && request != "attach" { + return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`"))); + } + 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 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()); + self.order.lock().push(id); + // Announced before the event loop runs, so it precedes every adapter event. + session.emit(ide_core::event::DebugEvent::Started { + name: session.name.clone(), + adapter: session.adapter.clone(), + parent, + }); + tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming)); + + match self.configure(&session, arguments).await { + Ok(()) => Ok(id), + Err(e) => { + let e = if session.conn.is_closed() { + let mut msg = format!("debug adapter `{}` exited during startup", session.adapter); + let tail = stderr.lock().join("\n"); + if !tail.is_empty() { + msg.push_str(":\n"); + msg.push_str(&tail); + } + if let Some(hint) = &session.spec.install_hint { + msg.push('\n'); + msg.push_str(hint); + } + IdeError::unavailable(msg) + } else { + e + }; + session.terminated.store(true, Ordering::Release); + self.shutdown(&session, true).await; + Err(e) + } + } + } + + async fn configure(&self, s: &Arc, arguments: Value) -> IdeResult<()> { + let caps = s + .conn + .request_timeout( + "initialize", + json!({ + "clientID": "openidea", + "clientName": "OpenIDEA", + "adapterID": s.adapter, + "locale": "en", + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path", + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": true, + "supportsStartDebuggingRequest": true, + "supportsProgressReporting": true, + "supportsInvalidatedEvent": false, + "supportsMemoryReferences": false, + "supportsArgsCanBeInterpretedByShell": false, + }), + INIT_TIMEOUT, + ) + .await?; + *s.caps.lock() = if caps.is_object() { caps } else { json!({}) }; + + // Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet. + let launch = s.conn.send_request(&s.request, arguments)?; + let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) }); + let mut initialized = s.initialized.subscribe(); + let mut launch_result = None; + let got_initialized = tokio::select! { + r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true } + r = &mut launch => { + r?; + launch_result = Some(()); + false + } + _ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()), + }; + if !got_initialized { + // Launched before `initialized`; give the adapter a moment to send it. + let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await; + } + + let files: Vec<(PathBuf, Vec)> = + self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect(); + // Breakpoint states follow the owner's first session. + let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id); + for (path, bps) in files { + match s.send_breakpoints(&path, &bps, None).await { + Ok(states) => { + if first { + self.states.lock().insert(path.clone(), states); + s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() }); + } + } + Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()), + } + } + let functions = self.function_breakpoints.lock().clone(); + if !functions.is_empty() + && s.supports("supportsFunctionBreakpoints") + && let Err(e) = s.send_function_breakpoints(&functions).await + { + tracing::debug!("setFunctionBreakpoints failed: {e}"); + } + let data = self.data_breakpoints.lock().clone(); + if !data.is_empty() + && s.supports("supportsDataBreakpoints") + && let Err(e) = s.send_data_breakpoints(&data).await + { + tracing::debug!("setDataBreakpoints failed: {e}"); + } + let has_filters = s.caps.lock().get("exceptionBreakpointFilters").and_then(Value::as_array).is_some(); + if has_filters { + let _ = self.send_exception_breakpoints(s).await; + } + if s.supports("supportsConfigurationDoneRequest") { + s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?; + } + s.configured.store(true, Ordering::Release); + if launch_result.is_none() { + match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await { + Ok(r) => { + r?; + } + Err(_) => return Err(DapError::Timeout(s.request.clone()).into()), + } + } + Ok(()) + } + + /// The connection dropped (adapter exited or `shutdown` finished). + pub(crate) fn session_closed(&self, s: &Arc) { + s.kill_processes(); + let removed = self.sessions.lock().remove(&s.id).is_some(); + self.order.lock().retain(|id| *id != s.id); + if removed && !s.terminated.swap(true, Ordering::AcqRel) { + s.emit(ide_core::event::DebugEvent::Terminated); + } + } + + /// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill. + pub(crate) async fn shutdown(&self, s: &Arc, terminate: bool) { + if !s.conn.is_closed() { + if terminate && s.request == "launch" && s.supports("supportsTerminateRequest") { + let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await; + } + let _ = s + .conn + .request_timeout( + "disconnect", + json!({ "restart": false, "terminateDebuggee": s.request == "launch" }), + SHUTDOWN_TIMEOUT, + ) + .await; + } + s.conn.close(); + self.session_closed(s); + } + + /// Breakpoints are kept per file even without a session and sent on start. + /// Returns states from the first live session, or unverified states when none. + pub async fn set_breakpoints( + &self, + path: PathBuf, + breakpoints: Vec, + ) -> IdeResult> { + { + let mut store = self.breakpoints.lock(); + if breakpoints.is_empty() { + store.remove(&path); + } else { + store.insert(path.clone(), breakpoints.clone()); + } + } + let mut result: Option> = None; + for s in self.live_sessions() { + if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() { + continue; + } + match s.send_breakpoints(&path, &breakpoints, None).await { + Ok(states) => { + result.get_or_insert(states); + } + Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0), + } + } + let states = result.unwrap_or_else(|| { + breakpoints + .iter() + .map(|b| BreakpointState { + line: b.line, + verified: false, + message: (!b.enabled).then(|| "Disabled".to_owned()), + }) + .collect() + }); + self.states.lock().insert(path, states.clone()); + Ok(states) + } + + /// Last known verification states of a file's breakpoints (after `BreakpointsChanged`). + pub fn breakpoint_states(&self, path: &Path) -> Vec { + self.states.lock().get(path).cloned().unwrap_or_default() + } + + pub(crate) fn update_breakpoint_state(&self, path: &Path, index: usize, bp: &Value) -> bool { + let mut states = self.states.lock(); + let Some(slot) = states.get_mut(path).and_then(|v| v.get_mut(index)) else { return false }; + let next = session::state_from(bp, slot.line); + let next = BreakpointState { message: next.message.or_else(|| slot.message.clone()), ..next }; + let changed = *slot != next; + *slot = next; + changed + } + + async fn send_exception_breakpoints(&self, s: &Arc) -> IdeResult<()> { + let filters = exception_filters_from(&s.caps.lock()); + let enabled = enabled_filters(&filters, &self.exception_choices.lock(), &s.adapter); + s.conn.request_timeout("setExceptionBreakpoints", json!({ "filters": enabled }), REQUEST_TIMEOUT).await?; + Ok(()) + } + + /// Exception filters offered by the adapter of each live session of `owner` (all when `None`). + pub fn exception_filters(&self, owner: Option<&str>) -> Vec { + self.live_sessions() + .iter() + .filter(|s| owner.is_none_or(|o| s.owner.key == o)) + .map(|s| SessionExceptionFilters { + session: s.id, + adapter: s.adapter.clone(), + filters: exception_filters_from(&s.caps.lock()), + }) + .collect() + } + + /// Stores exception filter choices (filter id → enabled; kept without a session, applied on + /// start) and sends them to every configured session that offers exception filters. + pub async fn set_exception_breakpoints(&self, choices: BTreeMap) -> IdeResult<()> { + *self.exception_choices.lock() = choices; + for s in self.live_sessions() { + if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() { + continue; + } + if exception_filters_from(&s.caps.lock()).is_empty() { + continue; + } + if let Err(e) = self.send_exception_breakpoints(&s).await { + tracing::debug!("setExceptionBreakpoints on session {} failed: {e}", s.id.0); + } + } + Ok(()) + } + + /// Changes a variable's value: `setVariable` in its container, or `setExpression` + /// on its evaluate name when the adapter only supports that. Returns the new value. + pub async fn set_variable( + &self, + s: DebugSessionId, + container: i64, + name: String, + evaluate_name: Option, + frame_id: Option, + value: String, + ) -> IdeResult { + let session = self.session(s)?; + let body = if container > 0 && session.supports("supportsSetVariable") { + session + .conn + .request_timeout( + "setVariable", + json!({ "variablesReference": container, "name": name, "value": value }), + REQUEST_TIMEOUT, + ) + .await? + } else if let (Some(expr), true) = (evaluate_name.as_ref(), session.supports("supportsSetExpression")) { + let mut args = json!({ "expression": expr, "value": value }); + if let Some(f) = frame_id { + args["frameId"] = f.into(); + } + session.conn.request_timeout("setExpression", args, REQUEST_TIMEOUT).await? + } else { + return Err(IdeError::unavailable(format!("debug adapter `{}` cannot change values", session.adapter))); + }; + Ok(Variable { + name: name.clone(), + value: body.get("value").and_then(Value::as_str).unwrap_or(&value).to_owned(), + type_name: body.get("type").and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned), + variables_reference: body.get("variablesReference").and_then(Value::as_i64).unwrap_or(0), + evaluate_name: evaluate_name.or(Some(name)), + }) + } + + pub async fn threads(&self, s: DebugSessionId) -> IdeResult> { + let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?; + Ok(body + .get("threads") + .and_then(Value::as_array) + .map(|t| { + t.iter() + .map(|t| ThreadInfo { + id: t.get("id").and_then(Value::as_i64).unwrap_or_default(), + name: t.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(), + }) + .collect() + }) + .unwrap_or_default()) + } + + pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult> { + let body = self + .session(s)? + .conn + .request_timeout( + "stackTrace", + json!({ "threadId": thread_id, "startFrame": 0, "levels": 400 }), + REQUEST_TIMEOUT, + ) + .await?; + Ok(body + .get("stackFrames") + .and_then(Value::as_array) + .map(|f| f.iter().map(frame_from).collect()) + .unwrap_or_default()) + } + + pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult> { + let body = + self.session(s)?.conn.request_timeout("scopes", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?; + Ok(body + .get("scopes") + .and_then(Value::as_array) + .map(|sc| { + sc.iter() + .map(|sc| Scope { + name: sc.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(), + variables_reference: sc.get("variablesReference").and_then(Value::as_i64).unwrap_or(0), + expensive: sc.get("expensive").and_then(Value::as_bool).unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default()) + } + + pub async fn variables(&self, s: DebugSessionId, reference: i64) -> IdeResult> { + let body = self + .session(s)? + .conn + .request_timeout("variables", json!({ "variablesReference": reference }), REQUEST_TIMEOUT) + .await?; + Ok(body + .get("variables") + .and_then(Value::as_array) + .map(|v| v.iter().map(variable_from).collect()) + .unwrap_or_default()) + } + + /// Evaluates in the `repl` context. + pub async fn evaluate(&self, s: DebugSessionId, frame_id: Option, expression: String) -> IdeResult { + self.evaluate_in(s, frame_id, expression, None).await + } + + /// Evaluates with an explicit DAP context: `repl` (default), `watch`, `hover`, `clipboard`. + pub async fn evaluate_in( + &self, + s: DebugSessionId, + frame_id: Option, + expression: String, + context: Option, + ) -> IdeResult { + let session = self.session(s)?; + let mut context = context.unwrap_or_else(|| "repl".into()); + if context == "hover" && !session.supports("supportsEvaluateForHovers") { + context = "watch".into(); + } + let mut args = json!({ "expression": expression, "context": context }); + if let Some(f) = frame_id { + args["frameId"] = f.into(); + } + let body = session.conn.request_timeout("evaluate", args, REQUEST_TIMEOUT).await?; + Ok(Variable { + name: expression.clone(), + value: body.get("result").and_then(Value::as_str).unwrap_or_default().to_owned(), + type_name: body.get("type").and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned), + variables_reference: body.get("variablesReference").and_then(Value::as_i64).unwrap_or(0), + evaluate_name: Some(expression), + }) + } + + pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> { + let session = self.session(s)?; + let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; + let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true); + session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all }); + Ok(()) + } + + pub async fn pause(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> { + self.session(s)?.conn.request_timeout("pause", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; + Ok(()) + } + + pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> { + let command = match kind { + StepKind::Over => "next", + StepKind::Into => "stepIn", + StepKind::Out => "stepOut", + }; + let session = self.session(s)?; + session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; + session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true }); + Ok(()) + } + + /// Run to cursor: a temporary breakpoint at `line` (0-based) plus `continue`; + /// the file's real breakpoints are restored on the next stop. `force` (Force Run + /// to Cursor) also suspends every other breakpoint until then. + pub async fn run_to( + &self, + s: DebugSessionId, + thread_id: i64, + path: PathBuf, + line: u32, + force: bool, + ) -> IdeResult<()> { + let session = self.session(s)?; + if force { + self.suspend_breakpoints(&session, Some(&path)).await?; + } + let stored = self.stored_breakpoints(&path); + let sent: Vec = + if force { stored.iter().map(|b| Breakpoint { enabled: false, ..b.clone() }).collect() } else { stored }; + session.send_breakpoints(&path, &sent, Some(line)).await?; + session.pending_restore.lock().files.insert(path); + self.resume(s, thread_id).await + } + + /// Sends empty breakpoint lists (line, function, data) to `session` until its next + /// stop, when the stored ones are restored. `except` is left to the caller. + async fn suspend_breakpoints(&self, session: &Arc, except: Option<&Path>) -> IdeResult<()> { + let files: Vec = self.breakpoints.lock().keys().cloned().collect(); + for path in files { + if Some(path.as_path()) == except { + continue; + } + session.send_breakpoints(&path, &[], None).await?; + session.pending_restore.lock().files.insert(path); + } + if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") { + session.send_function_breakpoints(&[]).await?; + session.pending_restore.lock().functions = true; + } + if !self.data_breakpoints.lock().is_empty() && session.supports("supportsDataBreakpoints") { + session.send_data_breakpoints(&[]).await?; + session.pending_restore.lock().data = true; + } + Ok(()) + } + + /// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced. + pub(crate) async fn restore_breakpoints(&self, session: &Arc) { + let pending = std::mem::take(&mut *session.pending_restore.lock()); + for path in pending.files { + let bps = self.stored_breakpoints(&path); + if let Err(e) = session.send_breakpoints(&path, &bps, None).await { + tracing::debug!("restoring breakpoints of {} failed: {e}", path.display()); + } + } + if pending.functions { + let functions = self.function_breakpoints.lock().clone(); + if let Err(e) = session.send_function_breakpoints(&functions).await { + tracing::debug!("restoring function breakpoints failed: {e}"); + } + } + if pending.data { + let data = self.data_breakpoints.lock().clone(); + if let Err(e) = session.send_data_breakpoints(&data).await { + tracing::debug!("restoring data breakpoints failed: {e}"); + } + } + } + + /// Force Step Over: `next` with every breakpoint suspended until the step ends. + pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> { + let session = self.session(s)?; + self.suspend_breakpoints(&session, None).await?; + self.step(s, thread_id, StepKind::Over).await + } + + /// Step into a specific call of the current line (Smart Step Into). + pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> { + let session = self.session(s)?; + session + .conn + .request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT) + .await?; + session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true }); + Ok(()) + } + + /// Calls on the current line of `frame_id` that can be stepped into. + pub async fn step_in_targets(&self, s: DebugSessionId, frame_id: i64) -> IdeResult> { + let session = self.session(s)?; + if !session.supports("supportsStepInTargetsRequest") { + return Err(IdeError::unavailable(format!( + "debug adapter `{}` does not support Smart Step Into", + session.adapter + ))); + } + let body = + session.conn.request_timeout("stepInTargets", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?; + Ok(body + .get("targets") + .and_then(Value::as_array) + .map(|t| t.iter().map(step_in_target_from).collect()) + .unwrap_or_default()) + } + + /// Reset Frame (IDEA's Drop Frame): `restartFrame`, so execution re-enters the frame. + pub async fn restart_frame(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<()> { + let session = self.session(s)?; + if !session.supports("supportsRestartFrame") { + return Err(IdeError::unavailable(format!("debug adapter `{}` cannot reset frames", session.adapter))); + } + session.conn.request_timeout("restartFrame", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?; + Ok(()) + } + + pub fn capabilities(&self, s: DebugSessionId) -> IdeResult { + let session = self.session(s)?; + let mut caps = capabilities_from(&session.caps.lock()); + caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str()); + Ok(caps) + } + + /// HotSwap: reloads the classes compiled since the session started (or since the + /// last reload) into the debuggee (java-debug `redefineClasses`). + pub async fn redefine_classes(&self, s: DebugSessionId) -> IdeResult { + let session = self.session(s)?; + if !HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str()) { + return Err(IdeError::unavailable(format!( + "debug adapter `{}` cannot reload changed classes", + session.adapter + ))); + } + let body = session.conn.request_timeout("redefineClasses", json!({}), HOT_SWAP_TIMEOUT).await?; + Ok(hot_swap_result(&body)) + } + + /// Stores method breakpoints (kept without a session, sent on start) and sends them to + /// every configured session whose adapter supports them. States from the first one. + pub async fn set_function_breakpoints(&self, list: Vec) -> IdeResult> { + *self.function_breakpoints.lock() = list.clone(); + let mut result = None; + for s in self.live_sessions() { + if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() || !s.supports("supportsFunctionBreakpoints") + { + continue; + } + match s.send_function_breakpoints(&list).await { + Ok(states) => { + result.get_or_insert(states); + } + Err(e) => tracing::debug!("setFunctionBreakpoints on session {} failed: {e}", s.id.0), + } + } + Ok(result.unwrap_or_else(|| unverified(list.iter().map(|b| b.enabled)))) + } + + /// Stores field watchpoints and sends them to every configured session that supports them. + pub async fn set_data_breakpoints(&self, list: Vec) -> IdeResult> { + *self.data_breakpoints.lock() = list.clone(); + let mut result = None; + for s in self.live_sessions() { + if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() || !s.supports("supportsDataBreakpoints") { + continue; + } + match s.send_data_breakpoints(&list).await { + Ok(states) => { + result.get_or_insert(states); + } + Err(e) => tracing::debug!("setDataBreakpoints on session {} failed: {e}", s.id.0), + } + } + Ok(result.unwrap_or_else(|| unverified(list.iter().map(|b| b.enabled)))) + } + + /// Whether (and how) `name` in the variables container `container` can be watched. + pub async fn data_breakpoint_info( + &self, + s: DebugSessionId, + container: i64, + name: String, + frame_id: Option, + ) -> IdeResult { + let session = self.session(s)?; + if !session.supports("supportsDataBreakpoints") { + return Err(IdeError::unavailable(format!( + "debug adapter `{}` does not support field watchpoints", + session.adapter + ))); + } + let mut args = json!({ "name": name }); + if container > 0 { + args["variablesReference"] = container.into(); + } + if let Some(f) = frame_id { + args["frameId"] = f.into(); + } + let body = session.conn.request_timeout("dataBreakpointInfo", args, REQUEST_TIMEOUT).await?; + Ok(DataBreakpointInfo { + data_id: body.get("dataId").and_then(Value::as_str).map(str::to_owned), + description: body.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(), + access_types: body + .get("accessTypes") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(), + }) + } + + /// Stops a session and its child sessions. + pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { + let session = self.session(s)?; + let children: Vec> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect(); + for child in children { + self.shutdown(&child, true).await; + } + self.shutdown(&session, true).await; + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs new file mode 100644 index 00000000..80534b96 --- /dev/null +++ b/crates/ide-dap/src/session.rs @@ -0,0 +1,450 @@ +//! One debug session: the adapter connection, its capabilities, the event +//! loop translating DAP events into `IdeEvent::Debug`, and reverse requests. + +use crate::adapter::AdapterSpec; +use crate::connection::{Connection, DapError, Incoming}; +use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner}; +use ide_core::event::DebugEvent; +use ide_core::{DebugSessionId, IdeEvent, TaskId}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Weak; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Child; +use tokio::sync::{mpsc, watch}; + +/// What has to be re-sent on the next stop. +#[derive(Default)] +pub(crate) struct PendingRestore { + pub files: BTreeSet, + pub functions: bool, + pub data: bool, +} + +pub(crate) struct Session { + pub id: DebugSessionId, + /// Who started the session (inherited by child sessions); all its events go to the owner's sink. + pub owner: SessionOwner, + pub name: String, + pub adapter: String, + pub parent: Option, + pub request: String, + pub conn: Connection, + pub spec: AdapterSpec, + /// TCP port of a server-mode adapter (for `startDebugging` child sessions). + pub port: Option, + pub caps: Mutex, + pub adapter_process: Mutex>, + /// Processes started for `runInTerminal`. + pub debuggees: Mutex>, + /// Set once `configurationDone` was sent; breakpoint updates go out live after that. + pub configured: AtomicBool, + pub initialized: watch::Sender, + pub terminated: AtomicBool, + /// Breakpoints replaced for run to cursor / force stepping, restored on the next stop. + pub pending_restore: Mutex, + /// Adapter breakpoint id → (file, index in the stored list). + pub breakpoint_ids: Mutex>, + progress: Mutex>, +} + +impl Session { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: DebugSessionId, + owner: SessionOwner, + name: String, + adapter: String, + parent: Option, + request: String, + conn: Connection, + spec: AdapterSpec, + port: Option, + child: Option, + ) -> Self { + Self { + id, + owner, + name, + adapter, + parent, + request, + conn, + spec, + port, + caps: Mutex::new(Value::Null), + adapter_process: Mutex::new(child), + debuggees: Mutex::new(Vec::new()), + configured: AtomicBool::new(false), + initialized: watch::channel(false).0, + terminated: AtomicBool::new(false), + pending_restore: Mutex::default(), + breakpoint_ids: Mutex::new(HashMap::new()), + progress: Mutex::new(HashMap::new()), + } + } + + pub fn emit(&self, event: DebugEvent) { + self.owner.sink.emit(IdeEvent::Debug { session: self.id, event }); + } + + pub fn supports(&self, cap: &str) -> bool { + self.caps.lock().get(cap).and_then(Value::as_bool).unwrap_or(false) + } + + /// Sends the breakpoints of one file (disabled ones omitted, plus an optional + /// temporary line) and returns verification states aligned with `breakpoints`. + pub async fn send_breakpoints( + &self, + path: &Path, + breakpoints: &[Breakpoint], + temp_line: Option, + ) -> Result, DapError> { + let enabled: Vec = (0..breakpoints.len()).filter(|&i| breakpoints[i].enabled).collect(); + let mut source_bps: Vec = enabled + .iter() + .map(|&i| { + let b = &breakpoints[i]; + let mut v = json!({ "line": b.line + 1 }); + if let Some(c) = b.condition.as_deref().filter(|c| !c.trim().is_empty()) { + v["condition"] = c.into(); + } + if let Some(c) = b.hit_condition.as_deref().filter(|c| !c.trim().is_empty()) { + v["hitCondition"] = c.into(); + } + if let Some(m) = b.log_message.as_deref().filter(|m| !m.is_empty()) { + v["logMessage"] = m.into(); + } + v + }) + .collect(); + if let Some(line) = temp_line + && !enabled.iter().any(|&i| breakpoints[i].line == line) + { + source_bps.push(json!({ "line": line + 1 })); + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()); + let body = self + .conn + .request_timeout( + "setBreakpoints", + json!({ + "source": { "path": path, "name": name }, + "breakpoints": source_bps, + "sourceModified": false, + }), + Duration::from_secs(10), + ) + .await?; + let returned = body.get("breakpoints").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut ids = self.breakpoint_ids.lock(); + ids.retain(|_, (p, _)| p != path); + let mut states: Vec = breakpoints + .iter() + .map(|b| BreakpointState { + line: b.line, + verified: false, + message: (!b.enabled).then(|| "Disabled".to_owned()), + }) + .collect(); + for (k, &i) in enabled.iter().enumerate() { + let Some(r) = returned.get(k) else { continue }; + if let Some(id) = r.get("id").and_then(Value::as_i64) { + ids.insert(id, (path.to_path_buf(), i)); + } + states[i] = state_from(r, breakpoints[i].line); + } + Ok(states) + } + + /// Sends method breakpoints (disabled ones omitted); states aligned with `list`. + pub async fn send_function_breakpoints( + &self, + list: &[FunctionBreakpoint], + ) -> Result, DapError> { + let enabled: Vec = (0..list.len()).filter(|&i| list[i].enabled).collect(); + let sent: Vec = enabled + .iter() + .map(|&i| { + let b = &list[i]; + let mut v = json!({ "name": b.name }); + with_conditions(&mut v, b.condition.as_deref(), b.hit_condition.as_deref()); + v + }) + .collect(); + let body = self + .conn + .request_timeout("setFunctionBreakpoints", json!({ "breakpoints": sent }), Duration::from_secs(10)) + .await?; + Ok(align_states(list.iter().map(|b| b.enabled), &enabled, &body)) + } + + /// Sends field watchpoints (disabled ones omitted); states aligned with `list`. + pub async fn send_data_breakpoints(&self, list: &[DataBreakpoint]) -> Result, DapError> { + let enabled: Vec = (0..list.len()).filter(|&i| list[i].enabled).collect(); + let sent: Vec = enabled + .iter() + .map(|&i| { + let b = &list[i]; + let mut v = json!({ "dataId": b.data_id }); + if let Some(a) = b.access_type.as_deref().filter(|a| !a.is_empty()) { + v["accessType"] = a.into(); + } + with_conditions(&mut v, b.condition.as_deref(), b.hit_condition.as_deref()); + v + }) + .collect(); + let body = self + .conn + .request_timeout("setDataBreakpoints", json!({ "breakpoints": sent }), Duration::from_secs(10)) + .await?; + Ok(align_states(list.iter().map(|b| b.enabled), &enabled, &body)) + } + + /// Kills the adapter and every debuggee we started. + pub fn kill_processes(&self) { + if let Some(mut c) = self.adapter_process.lock().take() { + let _ = c.start_kill(); + } + for mut c in std::mem::take(&mut *self.debuggees.lock()) { + let _ = c.start_kill(); + } + } +} + +fn with_conditions(v: &mut Value, condition: Option<&str>, hit_condition: Option<&str>) { + if let Some(c) = condition.filter(|c| !c.trim().is_empty()) { + v["condition"] = c.into(); + } + if let Some(c) = hit_condition.filter(|c| !c.trim().is_empty()) { + v["hitCondition"] = c.into(); + } +} + +/// States for a list whose enabled entries (`sent`, by index) went out in order. +fn align_states(all: impl Iterator, sent: &[usize], body: &Value) -> Vec { + let returned = body.get("breakpoints").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut states: Vec = all + .map(|e| BreakpointState { line: 0, verified: false, message: (!e).then(|| "Disabled".to_owned()) }) + .collect(); + for (k, &i) in sent.iter().enumerate() { + if let Some(r) = returned.get(k) { + states[i] = state_from(r, 0); + } + } + states +} + +pub(crate) fn state_from(r: &Value, fallback_line: u32) -> BreakpointState { + BreakpointState { + line: r.get("line").and_then(Value::as_u64).map(|l| (l as u32).saturating_sub(1)).unwrap_or(fallback_line), + verified: r.get("verified").and_then(Value::as_bool).unwrap_or(false), + message: r.get("message").and_then(Value::as_str).map(str::to_owned), + } +} + +/// Processes adapter traffic until the connection closes. +pub(crate) async fn event_loop( + manager: Weak, + session: Arc, + mut incoming: mpsc::UnboundedReceiver, +) { + while let Some(msg) = incoming.recv().await { + let Some(mgr) = manager.upgrade() else { break }; + match msg { + Incoming::Event { event, body } => on_event(&mgr, &session, &event, body).await, + Incoming::Request { seq, command, arguments } => { + on_reverse_request(&mgr, &session, seq, &command, arguments) + } + Incoming::Closed => break, + } + } + if let Some(mgr) = manager.upgrade() { + mgr.session_closed(&session); + } +} + +async fn on_event(mgr: &Arc, s: &Arc, event: &str, body: Value) { + let int = |k: &str| body.get(k).and_then(Value::as_i64); + let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned); + match event { + "initialized" => { + s.initialized.send_replace(true); + s.emit(DebugEvent::Initialized); + } + "stopped" => { + mgr.restore_breakpoints(s).await; + s.emit(DebugEvent::Stopped { + thread_id: int("threadId").unwrap_or(0), + reason: str_("reason").unwrap_or_else(|| "pause".into()), + description: str_("description").or_else(|| str_("text")), + all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false), + }); + } + "continued" => s.emit(DebugEvent::Continued { + thread_id: int("threadId").unwrap_or(0), + all_threads_continued: body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true), + }), + "output" => { + let category = str_("category").unwrap_or_else(|| "console".into()); + if category == "telemetry" { + return; + } + let output = str_("output").unwrap_or_default(); + s.emit(DebugEvent::Output { category, output }); + } + "thread" => s.emit(DebugEvent::Thread { + thread_id: int("threadId").unwrap_or(0), + started: body.get("reason").and_then(Value::as_str) != Some("exited"), + }), + "breakpoint" => { + let Some(bp) = body.get("breakpoint") else { return }; + let Some(id) = bp.get("id").and_then(Value::as_i64) else { return }; + let Some((path, index)) = s.breakpoint_ids.lock().get(&id).cloned() else { return }; + if mgr.update_breakpoint_state(&path, index, bp) { + s.emit(DebugEvent::BreakpointsChanged { path: path.into() }); + } + } + "exited" => s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }), + "terminated" => { + if !s.terminated.swap(true, Ordering::AcqRel) { + s.emit(DebugEvent::Terminated); + } + let mgr = mgr.clone(); + let s = s.clone(); + tokio::spawn(async move { mgr.shutdown(&s, false).await }); + } + "capabilities" => { + if let Some(new) = body.get("capabilities").and_then(Value::as_object) { + let mut caps = s.caps.lock(); + if !caps.is_object() { + *caps = json!({}); + } + if let Some(obj) = caps.as_object_mut() { + obj.extend(new.clone()); + } + } + } + "progressStart" | "progressUpdate" | "progressEnd" => { + let Some(pid) = str_("progressId") else { return }; + let done = event == "progressEnd"; + let (id, title) = { + let mut progress = s.progress.lock(); + let id = + if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) }; + (id, str_("title").unwrap_or_else(|| s.name.clone())) + }; + let Some(id) = id else { return }; + s.owner.sink.emit(IdeEvent::Task { + id, + title, + message: str_("message"), + fraction: body.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0) as f32), + done, + }); + } + "hotcodereplace" => s.emit(DebugEvent::HotCodeReplace { + change_type: crate::hot_code_replace_type(body.get("changeType")), + message: str_("message").unwrap_or_default(), + }), + _ => tracing::trace!("dap: ignored event {event}"), + } +} + +fn on_reverse_request(mgr: &Arc, s: &Arc, seq: i64, command: &str, args: Value) { + match command { + "runInTerminal" => { + let result = run_in_terminal(s, &args); + let _ = s.conn.respond(seq, command, result); + } + "startDebugging" => { + let mgr = mgr.clone(); + let s = s.clone(); + tokio::spawn(async move { + let request = args.get("request").and_then(Value::as_str).unwrap_or("launch").to_owned(); + let configuration = args.get("configuration").cloned().unwrap_or_else(|| json!({})); + let result = mgr.start_child(&s, request, configuration).await; + let _ = s.conn.respond(seq, "startDebugging", result.map(|_| Value::Null).map_err(|e| e.message)); + }); + } + _ => { + let _ = s.conn.respond(seq, command, Err(format!("unsupported reverse request {command}"))); + } + } +} + +/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console. +pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result { + let argv: Vec = args + .get("args") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + let Some((program, rest)) = argv.split_first() else { + 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); + if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) { + cmd.current_dir(cwd); + } + if let Some(env) = args.get("env").and_then(Value::as_object) { + for (k, v) in env { + match v.as_str() { + Some(v) => cmd.env(k, v), + None => cmd.env_remove(k), + }; + } + } + let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?; + let pid = child.id(); + if let Some(out) = child.stdout.take() { + forward_output(s, out, "stdout"); + } + if let Some(err) = child.stderr.take() { + forward_output(s, err, "stderr"); + } + s.debuggees.lock().push(child); + Ok(json!({ "processId": pid })) +} + +fn forward_output(s: &Session, mut stream: R, category: &'static str) { + let session = s.id; + let sink = s.owner.sink.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + let mut pending: Vec = Vec::new(); + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + pending.extend_from_slice(&buf[..n]); + // Keep incomplete UTF-8 sequences for the next chunk. + let valid = match std::str::from_utf8(&pending) { + Ok(_) => pending.len(), + Err(e) if e.error_len().is_none() => e.valid_up_to(), + Err(_) => pending.len(), + }; + let text = String::from_utf8_lossy(&pending[..valid]).into_owned(); + pending.drain(..valid); + if !text.is_empty() { + sink.emit(IdeEvent::Debug { + session, + event: DebugEvent::Output { category: category.into(), output: text }, + }); + } + } + } + } + if !pending.is_empty() { + let output = String::from_utf8_lossy(&pending).into_owned(); + sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: category.into(), output } }); + } + }); +} diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs new file mode 100644 index 00000000..39939630 --- /dev/null +++ b/crates/ide-dap/src/tests.rs @@ -0,0 +1,839 @@ +//! Protocol flow against an in-memory fake adapter. + +use super::*; +use crate::adapter::{AdapterIo, LaunchFuture}; +use ide_core::IdeEvent; +use ide_core::event::DebugEvent; +use ide_jsonrpc::framing; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +type Log = Arc>>; + +#[derive(Clone, Default)] +struct Script { + /// Send a `runInTerminal` reverse request when `launch` arrives. + run_in_terminal: Option>, + /// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives. + start_debugging: bool, +} + +struct FakeLauncher { + log: Log, + script: Script, +} + +impl AdapterLauncher for FakeLauncher { + fn launch(&self, _spec: AdapterSpec, _reuse: Option) -> LaunchFuture { + let (client, server) = tokio::io::duplex(1 << 16); + let log = self.log.clone(); + let script = self.script.clone(); + tokio::spawn(fake_adapter(server, log, script)); + let (r, w) = tokio::io::split(client); + Box::pin(async move { + Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() }) + }) + } +} + +async fn fake_adapter(stream: S, log: Log, script: Script) { + let (r, mut w) = tokio::io::split(stream); + let mut r = BufReader::new(r); + let mut seq = 1000; + let mut pending_launch: Option = None; + macro_rules! send { + ($v:expr) => {{ + let mut v: Value = $v; + seq += 1; + v["seq"] = seq.into(); + framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap(); + }}; + } + macro_rules! ok { + ($req:expr, $body:expr) => { + send!(json!({"type": "response", "request_seq": $req["seq"], "command": $req["command"], "success": true, "body": $body})) + }; + } + while let Ok(Some(body)) = framing::read_message(&mut r).await { + let msg: Value = serde_json::from_slice(&body).unwrap(); + log.lock().push(msg.clone()); + if msg["type"] == "response" { + continue; + } + match msg["command"].as_str().unwrap_or_default() { + "initialize" => ok!( + msg, + json!({ + "supportsConfigurationDoneRequest": true, + "supportsTerminateRequest": true, + "supportsSetVariable": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsStepInTargetsRequest": true, + "supportsRestartFrame": true, + "exceptionBreakpointFilters": [ + {"filter": "raised", "label": "Raised"}, + {"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"}, + ], + }) + ), + "launch" | "attach" => { + pending_launch = msg["seq"].as_i64(); + if let Some(args) = &script.run_in_terminal { + send!( + json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}}) + ); + } + if script.start_debugging && msg["arguments"]["isChild"] != true { + send!( + json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}}) + ); + } + send!(json!({"type": "event", "event": "initialized"})); + } + "setBreakpoints" => { + let bps: Vec = msg["arguments"]["breakpoints"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]})) + .collect(); + ok!(msg, json!({"breakpoints": bps})); + } + "setExceptionBreakpoints" => ok!(msg, json!({})), + "setFunctionBreakpoints" | "setDataBreakpoints" => { + let bps: Vec = msg["arguments"]["breakpoints"] + .as_array() + .unwrap() + .iter() + .map(|b| json!({"verified": b["name"] != "missing", "message": b["name"]})) + .collect(); + ok!(msg, json!({"breakpoints": bps})); + } + "dataBreakpointInfo" => ok!( + msg, + json!({"dataId": format!("Req#{}", msg["arguments"]["name"].as_str().unwrap()), "description": "field", "accessTypes": ["write", "read"]}) + ), + "stepInTargets" => ok!( + msg, + json!({"targets": [{"id": 1, "label": "parse()", "line": 5, "column": 9, "endColumn": 16}, {"id": 2, "label": "run()"}]}) + ), + "restartFrame" => { + ok!(msg, Value::Null); + send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}})); + } + "next" => { + ok!(msg, Value::Null); + send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}})); + } + "setVariable" => { + ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"})) + } + "configurationDone" => { + ok!(msg, Value::Null); + if let Some(l) = pending_launch.take() { + send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true})); + } + send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}})); + send!( + json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}}) + ); + send!( + json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9}}}) + ); + } + "threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})), + "stackTrace" => ok!( + msg, + json!({"stackFrames": [ + {"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}}, + {"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}}, + ]}) + ), + "scopes" => { + ok!(msg, json!({"scopes": [{"name": "Locals", "variablesReference": 100, "expensive": false}]})) + } + "variables" => ok!( + msg, + json!({"variables": [ + {"name": "x", "value": "1", "type": "int", "variablesReference": 0}, + {"name": "req", "value": "{Req}", "type": "Req", "variablesReference": 101, "evaluateName": "req"}, + ]}) + ), + "evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})), + "continue" => { + ok!(msg, json!({"allThreadsContinued": true})); + send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}})); + send!(json!({"type": "event", "event": "terminated"})); + } + "stepIn" | "stepOut" | "pause" => ok!(msg, Value::Null), + "redefineClasses" => { + send!( + json!({"type": "event", "event": "hotcodereplace", "body": {"changeType": "STARTING", "message": "Start hot code replacement procedure..."}}) + ); + ok!(msg, json!({"changedClasses": ["com.x.App"]})); + send!( + json!({"type": "event", "event": "hotcodereplace", "body": {"changeType": 3, "message": "Build completed"}}) + ); + } + "terminate" => { + ok!(msg, Value::Null); + send!(json!({"type": "event", "event": "terminated"})); + } + "disconnect" => { + ok!(msg, Value::Null); + let _ = w.shutdown().await; + break; + } + other => send!( + json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"}) + ), + } + } +} + +struct Harness { + manager: Arc, + log: Log, + events: Arc>>, + owner: SessionOwner, +} + +/// An owner whose events are collected in the returned list. +fn recording_owner(key: &str) -> (SessionOwner, Arc>>) { + let events: Arc>> = Arc::default(); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e: IdeEvent| sink_events.lock().push(e)); + (SessionOwner::new(key, sink), events) +} + +fn harness(script: Script) -> Harness { + let log: Log = Arc::default(); + let (owner, events) = recording_owner("main"); + let manager = DebugManager::with_launcher( + Arc::new(LanguageRegistry::from_configs(Vec::new())), + Arc::new(FakeLauncher { log: log.clone(), script }), + ); + Harness { manager, log, events, owner } +} + +/// Session ids that appear in debug events. +fn event_sessions(events: &Mutex>) -> std::collections::BTreeSet { + events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Debug { session, .. } => Some(session.0), + _ => None, + }) + .collect() +} + +impl Harness { + async fn start(&self, config: LaunchConfig) -> IdeResult { + self.manager.start(config, self.owner.clone()).await + } + + async fn wait_event(&self, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent { + for _ in 0..200 { + let found = self.events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()), + _ => None, + }); + if let Some(e) = found { + return e; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("event not received; got {:?}", self.events.lock()); + } + + fn commands(&self) -> Vec { + self.log + .lock() + .iter() + .filter(|m| m["type"] == "request") + .map(|m| m["command"].as_str().unwrap().to_owned()) + .collect() + } + + fn request(&self, command: &str) -> Value { + self.log.lock().iter().find(|m| m["command"] == command && m["type"] == "request").cloned().unwrap() + } +} + +fn config() -> LaunchConfig { + LaunchConfig { + name: "app".into(), + adapter: "debugpy".into(), + request: "launch".into(), + program: Some("/p/a.py".into()), + args: vec!["--flag".into()], + cwd: Some("/p".into()), + env: BTreeMap::from([("A".into(), "1".into())]), + extra_json: Some(r#"{"justMyCode": false, "args": ["--override"]}"#.into()), + } +} + +fn bp(line: u32) -> Breakpoint { + Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true } +} + +#[tokio::test] +async fn full_protocol_flow() { + let h = harness(Script::default()); + let path = PathBuf::from("/p/a.py"); + let disabled = Breakpoint { enabled: false, ..bp(2) }; + let cond = Breakpoint { condition: Some("x > 1".into()), ..bp(7) }; + let states = h.manager.set_breakpoints(path.clone(), vec![bp(4), disabled, cond]).await.unwrap(); + assert!(states.iter().all(|s| !s.verified), "no session yet"); + + let id = h.start(config()).await.unwrap(); + assert!( + matches!(h.events.lock().first(), Some(IdeEvent::Debug { session, event: DebugEvent::Started { parent: None, .. } }) if *session == id), + "a session is announced first" + ); + assert_eq!( + h.commands(), + ["initialize", "launch", "setBreakpoints", "setExceptionBreakpoints", "configurationDone"], + "lifecycle order" + ); + let init = h.request("initialize"); + assert_eq!(init["arguments"]["clientID"], "openidea"); + assert_eq!(init["arguments"]["linesStartAt1"], true); + assert_eq!(init["arguments"]["columnsStartAt1"], true); + assert_eq!(init["arguments"]["pathFormat"], "path"); + assert_eq!(init["arguments"]["supportsRunInTerminalRequest"], true); + let launch = h.request("launch"); + assert_eq!(launch["arguments"]["program"], "/p/a.py"); + assert_eq!(launch["arguments"]["args"], json!(["--override"]), "extra_json wins"); + assert_eq!(launch["arguments"]["justMyCode"], false); + assert_eq!(launch["arguments"]["env"]["A"], "1"); + assert_eq!(launch["arguments"]["cwd"], "/p"); + let sb = h.request("setBreakpoints"); + assert_eq!(sb["arguments"]["source"]["path"], "/p/a.py"); + // 0-based → 1-based, disabled omitted, condition passed through. + assert_eq!(sb["arguments"]["breakpoints"], json!([{"line": 5}, {"line": 8, "condition": "x > 1"}])); + assert_eq!(h.request("setExceptionBreakpoints")["arguments"]["filters"], json!(["uncaught"]), "defaults"); + + match h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await { + DebugEvent::Stopped { thread_id, reason, all_threads_stopped, .. } => { + assert_eq!((thread_id, reason.as_str(), all_threads_stopped), (1, "breakpoint", true)); + } + _ => unreachable!(), + } + h.wait_event( + |e| matches!(e, DebugEvent::Output { category, output } if category == "stdout" && output == "hello\n"), + ) + .await; + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + + // Breakpoint states: stored after configuration, updated by the `breakpoint` event (id 1 → first bp). + h.wait_event(|e| matches!(e, DebugEvent::BreakpointsChanged { path } if path.ends_with("a.py"))).await; + for _ in 0..100 { + if h.manager.breakpoint_states(&path).first().is_some_and(|s| !s.verified) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let states = h.manager.breakpoint_states(&path); + assert_eq!(states[0], BreakpointState { line: 8, verified: false, message: Some("moved".into()) }); + assert_eq!(states[1].message.as_deref(), Some("Disabled")); + assert!(states[2].verified); + + let threads = h.manager.threads(id).await.unwrap(); + assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "MainThread")); + let frames = h.manager.stack_trace(id, 1).await.unwrap(); + assert_eq!((frames[0].line, frames[0].column), (4, 2), "1-based → 0-based"); + assert_eq!(frames[0].path.as_deref(), Some(Path::new("/p/a.py"))); + assert_eq!(frames[1].presentation_hint.as_deref(), Some("subtle")); + let scopes = h.manager.scopes(id, 10).await.unwrap(); + assert_eq!(scopes[0].variables_reference, 100); + let vars = h.manager.variables(id, 100).await.unwrap(); + assert_eq!(vars[0].type_name.as_deref(), Some("int")); + assert_eq!(vars[1].variables_reference, 101); + let v = h.manager.evaluate_in(id, Some(10), "x * 42".into(), Some("watch".into())).await.unwrap(); + assert_eq!((v.name.as_str(), v.value.as_str()), ("x * 42", "42")); + let eval = h.request("evaluate"); + assert_eq!(eval["arguments"]["context"], "watch"); + assert_eq!(eval["arguments"]["frameId"], 10); + + // Live breakpoint updates reach the session. + let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap(); + assert!(states[0].verified); + + h.manager.resume(id, 1).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Continued { thread_id: 1, .. })).await; + h.wait_event(|e| matches!(e, DebugEvent::Exited { code: 3 })).await; + h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await; + for _ in 0..100 { + if h.manager.sessions(None).is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(h.manager.sessions(None).is_empty(), "session removed after termination"); + assert!(h.commands().contains(&"disconnect".to_owned())); + let terminated = + h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Terminated, .. })).count(); + assert_eq!(terminated, 1, "terminated emitted once"); +} + +#[tokio::test] +async fn run_to_cursor_uses_temporary_breakpoint() { + let h = harness(Script::default()); + let path = PathBuf::from("/p/a.py"); + h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap(); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + h.manager.run_to(id, 1, path.clone(), 9, false).await.unwrap(); + let sets: Vec = h + .log + .lock() + .iter() + .filter(|m| m["command"] == "setBreakpoints" && m["type"] == "request") + .map(|m| m["arguments"]["breakpoints"].clone()) + .collect(); + assert_eq!(sets.last().unwrap(), &json!([{"line": 2}, {"line": 10}])); + assert!(h.commands().contains(&"continue".to_owned())); +} + +#[tokio::test] +async fn stop_terminates_and_disconnects() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + h.manager.stop(id).await.unwrap(); + let cmds = h.commands(); + let t = cmds.iter().position(|c| c == "terminate").unwrap(); + let d = cmds.iter().position(|c| c == "disconnect").unwrap(); + assert!(t < d); + assert!(h.manager.sessions(None).is_empty()); + h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await; + assert!(h.manager.threads(id).await.is_err()); +} + +#[tokio::test] +async fn concurrent_sessions_are_independent() { + let h = harness(Script::default()); + let a = h.start(config()).await.unwrap(); + let b = h.start(config()).await.unwrap(); + assert_ne!(a, b); + assert_eq!(h.manager.sessions(None).len(), 2); + h.manager.stop(a).await.unwrap(); + assert_eq!(h.manager.sessions(None).iter().map(|s| s.id).collect::>(), [b]); + h.manager.stop(b).await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test] +async fn run_in_terminal_spawns_debuggee() { + let h = harness(Script { + run_in_terminal: Some(vec!["sh".into(), "-c".into(), "echo from-debuggee".into()]), + ..Script::default() + }); + h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Output { category, output } if category == "stdout" && output.contains("from-debuggee"))) + .await; + let response = + h.log.lock().iter().find(|m| m["type"] == "response" && m["command"] == "runInTerminal").cloned().unwrap(); + assert_eq!(response["success"], true); + assert!(response["body"]["processId"].as_u64().unwrap() > 0); +} + +#[test] +fn launch_arguments_merge_and_defaults() { + let mut c = config(); + c.extra_json = None; + c.args.clear(); + c.env.clear(); + let args = launch_arguments(&c).unwrap(); + assert_eq!( + args, + json!({"name": "app", "request": "launch", "type": "debugpy", "program": "/p/a.py", "cwd": "/p"}), + "`request` is in the arguments, as VS Code sends it" + ); + c.extra_json = Some("[1]".into()); + assert!(launch_arguments(&c).is_err()); + c.extra_json = Some("{bad".into()); + assert!(launch_arguments(&c).is_err()); +} + +#[test] +fn frame_and_variable_conversion() { + let f = frame_from(&json!({"id": 1, "name": "f", "line": 1, "column": 1})); + assert_eq!((f.line, f.column, f.path), (0, 0, None)); + let f = frame_from(&json!({"id": 1, "name": "f", "line": 0, "column": 0, "presentationHint": "label"})); + assert_eq!((f.line, f.presentation_hint.as_deref()), (0, Some("label"))); + let v = variable_from(&json!({"name": "a", "value": "1", "type": ""})); + assert_eq!(v.type_name, None); +} + +#[tokio::test] +async fn unknown_adapter_is_unavailable() { + let h = harness(Script::default()); + let mut c = config(); + c.adapter = "no-such-adapter".into(); + let err = h.start(c).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); +} + +#[tokio::test] +async fn exception_filters_and_set_variable() { + let h = harness(Script::default()); + h.manager.set_exception_breakpoints(BTreeMap::from([("raised".into(), true)])).await.unwrap(); + let id = h.start(config()).await.unwrap(); + let sets = || -> Vec { + h.log + .lock() + .iter() + .filter(|m| m["command"] == "setExceptionBreakpoints" && m["type"] == "request") + .map(|m| m["arguments"]["filters"].clone()) + .collect() + }; + assert_eq!(sets(), [json!(["raised", "uncaught"])], "choice + default applied at start"); + + let all = h.manager.exception_filters(Some("main")); + assert_eq!(all.len(), 1); + assert_eq!(all[0].session, id); + assert_eq!(all[0].filters[1].label, "Uncaught"); + assert!(all[0].filters[1].default); + assert_eq!(all[0].filters[1].description.as_deref(), Some("Uncaught exceptions")); + + h.manager + .set_exception_breakpoints(BTreeMap::from([("raised".into(), false), ("uncaught".into(), false)])) + .await + .unwrap(); + assert_eq!(sets().last().unwrap(), &json!([]), "live update"); + h.manager + .set_exception_breakpoints(BTreeMap::from([ + ("debugpy:raised".into(), true), + ("raised".into(), false), + ("js-debug:uncaught".into(), true), + ])) + .await + .unwrap(); + assert_eq!( + sets().last().unwrap(), + &json!(["raised", "uncaught"]), + "adapter-scoped choice wins; others' don't apply" + ); + + let v = h.manager.set_variable(id, 100, "x".into(), None, Some(10), "5".into()).await.unwrap(); + assert_eq!((v.value.as_str(), v.type_name.as_deref()), ("5!", Some("int"))); + let req = h.request("setVariable"); + assert_eq!(req["arguments"], json!({"variablesReference": 100, "name": "x", "value": "5"})); + // No setExpression support: a variable without a container cannot be changed. + assert!(h.manager.set_variable(id, 0, "y".into(), Some("y".into()), None, "1".into()).await.is_err()); + h.manager.stop(id).await.unwrap(); +} + +#[tokio::test] +async fn sessions_deliver_events_to_their_owner_only() { + let h = harness(Script { start_debugging: true, ..Script::default() }); + let (other, other_events) = recording_owner("other"); + let a = h.start(config()).await.unwrap(); + let b = h.manager.start(config(), other).await.unwrap(); + + // Each adapter starts a child session, which inherits its parent's owner. + let mut child = None; + for _ in 0..200 { + child = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(a)); + if child.is_some() && h.manager.sessions(Some("other")).iter().any(|s| s.parent == Some(b)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let child = child.expect("child session started").id; + for _ in 0..200 { + if event_sessions(&h.events).contains(&child.0) && event_sessions(&other_events).contains(&b.0) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + + let ids = + |owner| h.manager.sessions(Some(owner)).into_iter().map(|s| s.id.0).collect::>(); + let (main_ids, other_ids) = (ids("main"), ids("other")); + assert!(main_ids.contains(&a.0) && main_ids.contains(&child.0) && !main_ids.contains(&b.0)); + assert!(other_ids.contains(&b.0) && main_ids.is_disjoint(&other_ids)); + assert_eq!(other_ids.len(), 2, "b and its child"); + + let mine = event_sessions(&h.events); + let theirs = event_sessions(&other_events); + assert!(mine.contains(&a.0) && mine.contains(&child.0), "owner gets its sessions and their children: {mine:?}"); + assert!(mine.is_subset(&main_ids), "no events of another owner's sessions: {mine:?}"); + assert!(theirs.contains(&b.0) && theirs.is_subset(&other_ids), "other owner only sees its own: {theirs:?}"); + // A child is announced to its owner with its parent, before any of its other events. + let first_of_child = h.events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { session, event } if *session == child => Some(event.clone()), + _ => None, + }); + assert!( + matches!(&first_of_child, Some(DebugEvent::Started { name, parent: Some(p), .. }) if name == "child" && *p == a), + "{first_of_child:?}" + ); + let filters: Vec = h.manager.exception_filters(Some("other")).iter().map(|f| f.session.0).collect(); + assert!(filters.iter().all(|id| other_ids.contains(id)) && filters.contains(&b.0)); + assert_eq!(h.manager.sessions(None).len(), 4); + + h.manager.stop(b).await.unwrap(); + for _ in 0..100 { + if other_events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Terminated, .. })) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + !h.events + .lock() + .iter() + .any(|e| matches!(e, IdeEvent::Debug { session, event: DebugEvent::Terminated } if *session == b)), + "termination of another owner's session is not delivered here" + ); + assert!(other_events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Terminated, .. }))); + h.manager.stop(a).await.unwrap(); +} + +#[test] +fn debug_server_is_taken_from_launch_arguments() { + let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"}); + assert_eq!(take_debug_server(&mut args).unwrap(), Some(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!({"name": "x"})).unwrap(), None); + assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err()); +} + +#[test] +fn free_port_placeholder_is_one_port_everywhere() { + let mut args = json!({ + "runtimeArgs": ["run", "--inspect-wait=127.0.0.1:${freePort}", "main.ts"], + "attachSimplePort": "${freePort}", + "env": {"PORT": "${freePort}"}, + "name": "no placeholder", + }); + let mut calls = 0; + substitute_free_port(&mut args, &mut || { + calls += 1; + Ok(9339) + }) + .unwrap(); + assert_eq!(calls, 1, "one port per launch"); + assert_eq!(args["runtimeArgs"], json!(["run", "--inspect-wait=127.0.0.1:9339", "main.ts"])); + assert_eq!(args["attachSimplePort"], json!(9339), "a bare placeholder becomes a number"); + assert_eq!(args["env"]["PORT"], "9339", "environment values stay strings"); + assert_eq!(args["name"], "no placeholder"); + let mut none = json!({"program": "a.js"}); + substitute_free_port(&mut none, &mut || panic!("no port needed")).unwrap(); +} + +/// Deno under js-debug: the placeholder gets a real port, and js-debug gets the +/// workspace folder its default `outFiles` (TypeScript breakpoints via source maps) need. +#[tokio::test] +async fn js_debug_launch_gets_port_and_workspace_folder() { + let h = harness(Script::default()); + let config = LaunchConfig { + name: "main.ts".into(), + adapter: "js-debug".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: Some("/p".into()), + env: BTreeMap::new(), + extra_json: Some( + json!({ + "runtimeExecutable": "deno", + "runtimeArgs": ["run", "--inspect-wait=127.0.0.1:${freePort}", "main.ts"], + "attachSimplePort": "${freePort}", + }) + .to_string(), + ), + }; + h.start(config).await.unwrap(); + let launch = h.request("launch")["arguments"].clone(); + let port = launch["attachSimplePort"].as_u64().expect("a number"); + assert!(port > 0); + assert_eq!(launch["runtimeArgs"][1], format!("--inspect-wait=127.0.0.1:{port}")); + assert_eq!(launch["__workspaceFolder"], "/p"); + assert_eq!(launch["type"], "pwa-node"); +} + +/// An adapter that is already listening (java-debug inside jdtls): the client +/// connects over TCP to the `debugServer` port instead of spawning anything. +#[tokio::test] +async fn connects_to_running_debug_server_over_tcp() { + 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: "App".into(), + adapter: "java-debug".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: Some(std::env::temp_dir().into()), + env: BTreeMap::new(), + extra_json: Some(json!({"debugServer": port, "mainClass": "demo.App"}).to_string()), + }; + let id = manager.start(config, owner).await.unwrap(); + let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap(); + assert_eq!(launch["arguments"]["mainClass"], "demo.App"); + assert_eq!(launch["arguments"]["type"], "java"); + assert!(launch["arguments"].get("debugServer").is_none(), "consumed by the client"); + assert_eq!(manager.threads(id).await.unwrap()[0].name, "MainThread"); + manager.stop(id).await.unwrap(); + assert!(manager.sessions(None).is_empty()); + assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. }))); +} + +fn requests(h: &Harness, command: &str) -> Vec { + h.log + .lock() + .iter() + .filter(|m| m["command"] == command && m["type"] == "request") + .map(|m| m["arguments"].clone()) + .collect() +} + +fn function_bp(name: &str, enabled: bool) -> FunctionBreakpoint { + FunctionBreakpoint { name: name.into(), condition: None, hit_condition: None, enabled } +} + +#[tokio::test] +async fn function_and_data_breakpoints_are_stored_and_sent() { + let h = harness(Script::default()); + let cond = FunctionBreakpoint { condition: Some("n > 1".into()), ..function_bp("com.a.B#run", true) }; + let states = h.manager.set_function_breakpoints(vec![cond, function_bp("off", false)]).await.unwrap(); + assert!(!states[0].verified && states[1].message.as_deref() == Some("Disabled"), "no session yet"); + let watch = DataBreakpoint { + data_id: "Req#count".into(), + access_type: Some("write".into()), + condition: None, + hit_condition: Some("2".into()), + enabled: true, + }; + h.manager.set_data_breakpoints(vec![watch]).await.unwrap(); + + let id = h.start(config()).await.unwrap(); + assert_eq!( + requests(&h, "setFunctionBreakpoints"), + [json!({"breakpoints": [{"name": "com.a.B#run", "condition": "n > 1"}]})], + "sent during configuration, disabled omitted" + ); + assert_eq!( + requests(&h, "setDataBreakpoints"), + [json!({"breakpoints": [{"dataId": "Req#count", "accessType": "write", "hitCondition": "2"}]})] + ); + let order = h.commands(); + let pos = |c: &str| order.iter().position(|x| x == c).unwrap(); + assert!(pos("setFunctionBreakpoints") < pos("configurationDone")); + + let states = + h.manager.set_function_breakpoints(vec![function_bp("missing", true), function_bp("ok", true)]).await.unwrap(); + assert_eq!((states[0].verified, states[1].verified), (false, true), "live states from the session"); + + let info = h.manager.data_breakpoint_info(id, 101, "count".into(), Some(10)).await.unwrap(); + assert_eq!(info.data_id.as_deref(), Some("Req#count")); + assert_eq!(info.access_types, ["write", "read"]); + assert_eq!( + h.request("dataBreakpointInfo")["arguments"], + json!({"name": "count", "variablesReference": 101, "frameId": 10}) + ); + + let caps = h.manager.capabilities(id).unwrap(); + assert!(caps.function_breakpoints && caps.data_breakpoints && caps.restart_frame && caps.step_in_targets); + assert!(caps.set_value && !caps.log_points); + h.manager.stop(id).await.unwrap(); +} + +#[tokio::test] +async fn smart_step_into_and_reset_frame() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + let targets = h.manager.step_in_targets(id, 10).await.unwrap(); + assert_eq!(targets.len(), 2); + assert_eq!( + targets[0], + StepInTarget { id: 1, label: "parse()".into(), line: Some(4), column: Some(8), end_column: Some(15) } + ); + assert_eq!(targets[1].line, None); + h.manager.step_in_target(id, 1, 2).await.unwrap(); + assert_eq!(requests(&h, "stepIn").last().unwrap(), &json!({"threadId": 1, "targetId": 2})); + + h.manager.restart_frame(id, 11).await.unwrap(); + assert_eq!(h.request("restartFrame")["arguments"], json!({"frameId": 11})); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "restart")).await; + h.manager.stop(id).await.unwrap(); +} + +#[tokio::test] +async fn force_stepping_suspends_breakpoints_until_the_next_stop() { + let h = harness(Script::default()); + let a = PathBuf::from("/p/a.py"); + let b = PathBuf::from("/p/b.py"); + h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap(); + h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap(); + h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap(); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + let sent_for = |path: &str| -> Vec { + requests(&h, "setBreakpoints") + .into_iter() + .filter(|r| r["source"]["path"] == path) + .map(|r| r["breakpoints"].clone()) + .collect() + }; + + h.manager.force_step_over(id, 1).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await; + for _ in 0..100 { + if sent_for("/p/b.py").len() >= 3 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(sent_for("/p/b.py"), [json!([{"line": 4}]), json!([]), json!([{"line": 4}])], "muted, then restored"); + let functions = requests(&h, "setFunctionBreakpoints"); + assert_eq!(functions[1], json!({"breakpoints": []})); + assert_eq!(functions[2], json!({"breakpoints": [{"name": "run"}]})); + + // Force run to cursor: only the temporary breakpoint stays in the target file. + h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap(); + assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}])); + assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([])); +} + +#[tokio::test] +async fn hot_swap_only_for_java_debug() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + assert!(!h.manager.capabilities(id).unwrap().hot_code_replace); + let err = h.manager.redefine_classes(id).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + + let java = LaunchConfig { adapter: "java-debug".into(), extra_json: None, ..config() }; + let id = h.start(java).await.unwrap(); + assert!(h.manager.capabilities(id).unwrap().hot_code_replace); + let result = h.manager.redefine_classes(id).await.unwrap(); + assert_eq!(result, HotSwapResult { changed_classes: vec!["com.x.App".into()], error_message: None }); + match h + .wait_event(|e| matches!(e, DebugEvent::HotCodeReplace { change_type, .. } if change_type == "BUILD_COMPLETE")) + .await + { + DebugEvent::HotCodeReplace { message, .. } => assert_eq!(message, "Build completed"), + other => panic!("{other:?}"), + } + let failed = hot_swap_result(&json!({"changedClasses": [], "errorMessage": "boom"})); + assert_eq!(failed.error_message.as_deref(), Some("boom")); +} diff --git a/crates/ide-dap/tests/debugpy.rs b/crates/ide-dap/tests/debugpy.rs new file mode 100644 index 00000000..3590c7a8 --- /dev/null +++ b/crates/ide-dap/tests/debugpy.rs @@ -0,0 +1,82 @@ +//! End-to-end against a real debugpy (`python3 -m debugpy --version` must work). +//! Run with `cargo test -p ide-dap --test debugpy -- --ignored`. + +use ide_core::event::DebugEvent; +use ide_core::{IdeEvent, SharedSink}; +use ide_dap::{Breakpoint, DebugManager, LaunchConfig, SessionOwner}; +use ide_language::LanguageRegistry; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +async fn wait(events: &Mutex>, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent { + for _ in 0..1500 { + let hit = events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()), + _ => None, + }); + if let Some(e) = hit { + return e; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out; events: {:?}", events.lock()); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs python3 with debugpy installed"] +async fn debugpy_end_to_end() { + let ok = std::process::Command::new("python3").args(["-m", "debugpy", "--version"]).output(); + if !ok.is_ok_and(|o| o.status.success()) { + eprintln!("debugpy not installed; skipping"); + return; + } + let dir = std::env::temp_dir().join(format!("openidea-dap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("prog.py"); + std::fs::write( + &script, + "def main():\n total = 0\n for i in range(3):\n total += i\n print('total', total)\n\nmain()\n", + ) + .unwrap(); + let script = script.canonicalize().unwrap(); + + let events: Arc>> = Arc::default(); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e: IdeEvent| sink_events.lock().push(e)); + let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new()))); + let bp = Breakpoint { line: 3, condition: None, hit_condition: None, log_message: None, enabled: true }; + manager.set_breakpoints(script.clone(), vec![bp]).await.unwrap(); + let id = manager + .start( + LaunchConfig { + name: "prog".into(), + adapter: "debugpy".into(), + request: "launch".into(), + program: Some(script.to_string_lossy().into_owned()), + args: vec![], + cwd: Some(dir.clone().into()), + env: BTreeMap::new(), + extra_json: Some(r#"{"console": "internalConsole", "justMyCode": true}"#.into()), + }, + SessionOwner::new("main", sink), + ) + .await + .unwrap(); + let DebugEvent::Stopped { thread_id, .. } = wait(&events, |e| matches!(e, DebugEvent::Stopped { .. })).await else { + unreachable!() + }; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].line, 3, "0-based"); + let scopes = manager.scopes(id, frames[0].id).await.unwrap(); + let vars = manager.variables(id, scopes[0].variables_reference).await.unwrap(); + assert!(vars.iter().any(|v| v.name == "total"), "{vars:?}"); + let v = manager.evaluate(id, Some(frames[0].id), "total + 100".into()).await.unwrap(); + assert_eq!(v.value, "100"); + manager.set_breakpoints(script.clone(), vec![]).await.unwrap(); + manager.resume(id, thread_id).await.unwrap(); + wait(&events, |e| matches!(e, DebugEvent::Output { output, .. } if output.contains("total 3"))).await; + wait(&events, |e| matches!(e, DebugEvent::Terminated)).await; + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ide-dap/tests/real_adapters.rs b/crates/ide-dap/tests/real_adapters.rs new file mode 100644 index 00000000..d9f8c679 --- /dev/null +++ b/crates/ide-dap/tests/real_adapters.rs @@ -0,0 +1,647 @@ +//! Drives real debug adapters against tiny programs (one per language), checking the +//! launch templates and the IDEA-parity features: line / method breakpoints, Smart Step +//! Into, Reset Frame, Force Step Over, Force Run to Cursor and Set Value. +//! +//! Ignored by default (needs the adapters installed). Run with +//! `OPENIDEA_DAP_FIXTURES= cargo test -p ide-dap --test real_adapters -- --ignored --nocapture` +//! where `` holds `py/app.py`, `node/app.js`, `go/demo` (built with `-N -l`), +//! `rust/target/debug/demo` and `c/main`. Adapters not found on `PATH` are skipped. + +use ide_core::event::DebugEvent; +use ide_core::{DebugSessionId, IdeEvent, SharedSink}; +use ide_dap::*; +use ide_language::LanguageRegistry; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +struct Case { + adapter: &'static str, + /// Source file and the 0-based line of `total += parse(...)` inside the loop. + source: PathBuf, + loop_line: u32, + /// Line of the `return total` after the loop (Force Run to Cursor target). + after_loop: u32, + function: &'static str, + /// Persistent field watchpoint id, when the adapter has one (java-debug). + field: Option<&'static str>, + config: LaunchConfig, +} + +fn launch(adapter: &str, program: &Path, cwd: &Path, extra: serde_json::Value) -> LaunchConfig { + LaunchConfig { + name: format!("{adapter} fixture"), + adapter: adapter.into(), + request: "launch".into(), + program: Some(program.display().to_string()), + args: vec![], + cwd: Some(cwd.into()), + env: BTreeMap::from([("OPENIDEA_FIXTURE".into(), "1".into())]), + extra_json: Some(extra.to_string()), + } +} + +fn cases(dir: &Path) -> Vec { + let py = dir.join("py"); + let node = dir.join("node"); + let go = dir.join("go"); + let rust = dir.join("rust"); + let c = dir.join("c"); + let java = dir.join("java"); + // java-debug runs inside jdtls: OPENIDEA_JAVA_DEBUG_SERVER is the port the IDE's + // `java_debug_server` returned for this folder (classes compiled to java/out). + let java_case = + std::env::var("OPENIDEA_JAVA_DEBUG_SERVER").ok().and_then(|p| p.parse::().ok()).map(|port| Case { + adapter: "java-debug", + source: java.join("src/demo/Main.java"), + loop_line: 14, + after_loop: 16, + function: "demo.Main#parse", + field: Some("demo.Main#count"), + config: LaunchConfig { + program: None, + ..launch( + "java-debug", + Path::new(""), + &java, + serde_json::json!({ + "debugServer": port, + "mainClass": "demo.Main", + "classPaths": [java.join("out")], + "console": "internalConsole", + }), + ) + }, + }); + let mut out: Vec = java_case.into_iter().collect(); + out.extend(vec![ + Case { + adapter: "debugpy", + source: py.join("app.py"), + loop_line: 8, + after_loop: 9, + function: "parse", + field: None, + config: launch( + "debugpy", + &py.join("app.py"), + &py, + serde_json::json!({ + "console": "internalConsole", + "justMyCode": true, + "redirectOutput": true, + "python": python(), + }), + ), + }, + Case { + adapter: "js-debug", + source: node.join("app.js"), + loop_line: 8, + after_loop: 10, + function: "parse", + field: None, + config: launch( + "js-debug", + &node.join("app.js"), + &node, + serde_json::json!({"type": "pwa-node", "console": "internalConsole", "outputCapture": "std"}), + ), + }, + Case { + adapter: "delve", + source: go.join("main.go"), + loop_line: 15, + after_loop: 17, + function: "main.parse", + field: None, + config: launch("delve", &go.join("demo"), &go, serde_json::json!({"mode": "exec"})), + }, + Case { + adapter: "codelldb", + source: rust.join("src/main.rs"), + loop_line: 8, + after_loop: 10, + function: "parse", + field: None, + config: launch( + "codelldb", + &rust.join("target/debug/demo"), + &rust, + serde_json::json!({"sourceLanguages": ["rust"]}), + ), + }, + Case { + adapter: "codelldb", + source: c.join("main.c"), + loop_line: 13, + after_loop: 15, + function: "parse", + field: None, + config: launch("codelldb", &c.join("main"), &c, serde_json::json!({})), + }, + Case { + adapter: "lldb-dap", + source: c.join("main.c"), + loop_line: 13, + after_loop: 15, + function: "parse", + field: None, + config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})), + }, + ]); + out +} + +struct Run { + manager: Arc, + events: Arc>>, + owner: SessionOwner, +} + +impl Run { + fn new() -> Self { + let events: Arc>> = Arc::default(); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e: IdeEvent| { + if let IdeEvent::Debug { session, event } = e { + sink_events.lock().push((session, event)); + } + }); + let languages = Arc::new(LanguageRegistry::from_configs(Vec::new())); + Self { manager: DebugManager::new(languages), events, owner: SessionOwner::new("test", sink) } + } + + /// Waits for a `stopped` event after `seen` events; returns (session, thread, reason). + async fn stopped_after(&self, seen: usize) -> Option<(DebugSessionId, i64, String)> { + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + let found = self.events.lock().iter().skip(seen).find_map(|(s, e)| match e { + DebugEvent::Stopped { thread_id, reason, .. } => Some((*s, *thread_id, reason.clone())), + _ => None, + }); + if found.is_some() { + return found; + } + if self.events.lock().iter().skip(seen).any(|(_, e)| matches!(e, DebugEvent::Terminated)) { + return None; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + None + } + + fn seen(&self) -> usize { + self.events.lock().len() + } + + async fn top(&self, s: DebugSessionId, thread: i64) -> StackFrame { + self.manager.stack_trace(s, thread).await.unwrap().into_iter().next().unwrap() + } +} + +/// Bin dirs of the IDE's managed tools (the app prepends them to `PATH` at startup). +fn add_managed_tools_to_path() { + // `RUST_LOG=ide_dap::wire=trace` prints the DAP traffic. + let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).try_init(); + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return }; + // Like the app: adapters installed by OpenIDEA are found without being on PATH. + adapter::set_tools_root(home.join("Library/Application Support/ai.l0ng.openidea/tools")); +} + +/// The debuggee interpreter (`OPENIDEA_PYTHON`, e.g. one without debugpy). +fn python() -> String { + std::env::var("OPENIDEA_PYTHON").unwrap_or_else(|_| "python3".into()) +} + +fn installed(adapter: &str) -> bool { + let cmd = match adapter { + "debugpy" => { + let managed = adapter::tools_root() + .is_some_and(|r| r.join(adapter::DEBUGPY_TOOL).join("lib/debugpy/__init__.py").is_file()); + return managed + || std::process::Command::new(python()) + .args(["-c", "import debugpy"]) + .status() + .is_ok_and(|s| s.success()); + } + "js-debug" => "js-debug-adapter", + "delve" => "dlv", + "java-debug" => return true, + other => other, + }; + adapter::resolve_binary(cmd, Path::new("/")).is_some() +} + +async fn scenario(case: &Case) -> Result, String> { + let mut report = Vec::new(); + let run = Run::new(); + let m = &run.manager; + let line_bp = + Breakpoint { line: case.loop_line, condition: None, hit_condition: None, log_message: None, enabled: true }; + m.set_breakpoints(case.source.clone(), vec![line_bp]).await.map_err(|e| e.message)?; + + let seen = run.seen(); + let id = m.start(case.config.clone(), run.owner.clone()).await.map_err(|e| format!("start: {}", e.message))?; + let Some((s, thread, reason)) = run.stopped_after(seen).await else { + let log: Vec = run.events.lock().iter().map(|(_, e)| format!("{e:?}")).collect(); + return Err(format!( + "no stop at the line breakpoint; events: {log:#?}; states: {:?}", + m.breakpoint_states(&case.source) + )); + }; + let top = run.top(s, thread).await; + if top.line != case.loop_line { + return Err(format!("stopped at line {} ({reason}), expected {}", top.line, case.loop_line)); + } + report.push(format!("launch + line breakpoint: stopped at {}:{}", top.name, top.line + 1)); + let caps = m.capabilities(s).map_err(|e| e.message)?; + report.push(format!("capabilities: {caps:?}")); + + // Set Value on the first local that is an integer. + let scopes = m.scopes(s, top.id).await.map_err(|e| e.message)?; + for scope in scopes.iter().filter(|sc| !sc.expensive) { + let vars = m.variables(s, scope.variables_reference).await.map_err(|e| e.message)?; + if let Some(v) = vars.iter().find(|v| v.name == "total") { + match m + .set_variable( + s, + scope.variables_reference, + v.name.clone(), + v.evaluate_name.clone(), + Some(top.id), + "40".into(), + ) + .await + { + Ok(nv) => report.push(format!("set value: total = {}", nv.value)), + Err(e) => report.push(format!("set value: FAILED {}", e.message)), + } + break; + } + } + + // Reset Frame: one line further, then back to the start of `run`. + if caps.restart_frame { + let seen = run.seen(); + m.step(s, thread, StepKind::Over).await.map_err(|e| e.message)?; + run.stopped_after(seen).await.ok_or("no stop after step over")?; + let before = run.top(s, thread).await; + let seen = run.seen(); + match m.restart_frame(s, before.id).await { + Ok(()) => { + run.stopped_after(seen).await.ok_or("no stop after reset frame")?; + let r = run.top(s, thread).await; + report.push(format!("reset frame: {}:{} -> {}:{}", before.name, before.line + 1, r.name, r.line + 1)); + } + Err(e) => report.push(format!("reset frame: FAILED {}", e.message)), + } + let seen = run.seen(); + m.resume(s, thread).await.map_err(|e| e.message)?; + run.stopped_after(seen).await.ok_or("no stop at the loop after reset frame")?; + } + + // Smart Step Into. + if caps.step_in_targets { + let targets = m.step_in_targets(s, top.id).await.map_err(|e| e.message)?; + report.push(format!("step-in targets: {:?}", targets.iter().map(|t| t.label.as_str()).collect::>())); + if let Some(t) = targets.iter().find(|t| t.label.contains("parse")) { + let seen = run.seen(); + m.step_in_target(s, thread, t.id).await.map_err(|e| e.message)?; + run.stopped_after(seen).await.ok_or("no stop after smart step into")?; + let f = run.top(s, thread).await; + report.push(format!("smart step into: now in {}", f.name)); + // Reset Frame back to the start of parse. + if caps.restart_frame { + let seen = run.seen(); + match m.restart_frame(s, f.id).await { + Ok(()) => { + run.stopped_after(seen).await.ok_or("no stop after reset frame")?; + let r = run.top(s, thread).await; + report.push(format!("reset frame: at {}:{}", r.name, r.line + 1)); + } + Err(e) => report.push(format!("reset frame: FAILED {}", e.message)), + } + } + let seen = run.seen(); + m.step(s, thread, StepKind::Out).await.map_err(|e| e.message)?; + run.stopped_after(seen).await.ok_or("no stop after step out")?; + } + } else { + report.push("step-in targets: not supported".into()); + } + + // Method breakpoint: continue → stops in `parse`. + if caps.function_breakpoints { + let fb = FunctionBreakpoint { name: case.function.into(), condition: None, hit_condition: None, enabled: true }; + let states = m.set_function_breakpoints(vec![fb]).await.map_err(|e| e.message)?; + report.push(format!("method breakpoint `{}`: verified={}", case.function, states[0].verified)); + m.set_breakpoints(case.source.clone(), vec![]).await.map_err(|e| e.message)?; + let seen = run.seen(); + m.resume(s, thread).await.map_err(|e| e.message)?; + match run.stopped_after(seen).await { + Some((_, t, reason)) => { + let f = run.top(s, t).await; + report.push(format!("method breakpoint hit: {reason} in {}", f.name)); + } + None => report.push("method breakpoint: NOT HIT".into()), + } + m.set_function_breakpoints(vec![]).await.map_err(|e| e.message)?; + m.set_breakpoints(case.source.clone(), vec![line_bp_at(case.loop_line)]).await.map_err(|e| e.message)?; + let seen = run.seen(); + m.step(s, thread, StepKind::Out).await.map_err(|e| e.message)?; + run.stopped_after(seen).await.ok_or("no stop after step out of parse")?; + } else { + report.push("method breakpoints: not supported".into()); + } + + // Force Step Over ignores the breakpoint inside parse. + m.set_breakpoints(case.source.clone(), vec![line_bp_at(case.loop_line)]).await.map_err(|e| e.message)?; + let seen = run.seen(); + m.force_step_over(s, thread).await.map_err(|e| e.message)?; + let (_, t, reason) = run.stopped_after(seen).await.ok_or("no stop after force step over")?; + let f = run.top(s, t).await; + report.push(format!("force step over: {reason} at line {}", f.line + 1)); + + // Field watchpoint / data breakpoint on `total` (java-debug: a field id). + if caps.data_breakpoints { + let frame = run.top(s, thread).await; + let mut data_id = case.field.map(str::to_owned); + if data_id.is_none() { + for scope in m.scopes(s, frame.id).await.map_err(|e| e.message)?.iter().filter(|sc| !sc.expensive) { + if let Ok(info) = + m.data_breakpoint_info(s, scope.variables_reference, "total".into(), Some(frame.id)).await + && info.data_id.is_some() + { + data_id = info.data_id; + break; + } + } + } + match data_id { + Some(data_id) => { + let wp = DataBreakpoint { + data_id: data_id.clone(), + access_type: Some("write".into()), + condition: None, + hit_condition: None, + enabled: true, + }; + let states = m.set_data_breakpoints(vec![wp]).await.map_err(|e| e.message)?; + m.set_breakpoints(case.source.clone(), vec![]).await.map_err(|e| e.message)?; + let seen = run.seen(); + m.resume(s, thread).await.map_err(|e| e.message)?; + match run.stopped_after(seen).await { + Some((_, t, reason)) => { + let f = run.top(s, t).await; + report.push(format!( + "watchpoint `{data_id}` (verified={}): {reason} at {}:{}", + states[0].verified, + f.name, + f.line + 1 + )); + } + None => report.push(format!("watchpoint `{data_id}`: NOT HIT")), + } + m.set_data_breakpoints(vec![]).await.map_err(|e| e.message)?; + m.set_breakpoints(case.source.clone(), vec![line_bp_at(case.loop_line)]) + .await + .map_err(|e| e.message)?; + } + None => report.push("watchpoint: no data id for `total`".into()), + } + } + + // Force Run to Cursor past the loop, ignoring the loop breakpoint. + let seen = run.seen(); + m.run_to(s, thread, case.source.clone(), case.after_loop, true).await.map_err(|e| e.message)?; + let (_, t, reason) = run.stopped_after(seen).await.ok_or("no stop after force run to cursor")?; + let f = run.top(s, t).await; + report.push(format!("force run to cursor: {reason} at line {} (want {})", f.line + 1, case.after_loop + 1)); + + m.stop(id).await.map_err(|e| e.message)?; + Ok(report) +} + +fn line_bp_at(line: u32) -> Breakpoint { + Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs real debug adapters and OPENIDEA_DAP_FIXTURES"] +async fn real_adapters() { + let Some(dir) = std::env::var_os("OPENIDEA_DAP_FIXTURES").map(PathBuf::from) else { + eprintln!("OPENIDEA_DAP_FIXTURES not set"); + return; + }; + add_managed_tools_to_path(); + let only = std::env::var("OPENIDEA_DAP_ONLY").ok(); + let mut failures = Vec::new(); + for case in cases(&dir) { + if only.as_deref().is_some_and(|o| o != case.adapter) { + continue; + } + let name = format!("{} ({})", case.adapter, case.source.file_name().unwrap().to_string_lossy()); + if !installed(case.adapter) { + println!("== {name}: SKIPPED (not installed)"); + continue; + } + match tokio::time::timeout(Duration::from_secs(240), scenario(&case)).await { + Ok(Ok(lines)) => { + println!("== {name}: OK"); + for l in lines { + println!(" {l}"); + } + } + Ok(Err(e)) => { + println!("== {name}: FAILED {e}"); + failures.push(name); + } + Err(_) => { + println!("== {name}: TIMED OUT"); + failures.push(name); + } + } + } + assert!(failures.is_empty(), "failed: {failures:?}"); +} + +struct AttachCase { + kind: attach::AttachKind, + command: Vec, + source: PathBuf, + /// 0-based line inside `tick`. + line: u32, + extra: fn(u32, Option) -> serde_json::Value, +} + +/// Attach to Process: each fixture server is listed with the right debugger, and attaching +/// with the request `attach.ts` builds stops at a breakpoint in its loop. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs real debug adapters and OPENIDEA_DAP_FIXTURES"] +async fn attach_to_processes() { + let Some(dir) = std::env::var_os("OPENIDEA_DAP_FIXTURES").map(PathBuf::from) else { return }; + add_managed_tools_to_path(); + let s = |p: &Path| p.display().to_string(); + let java_server = std::env::var("OPENIDEA_JAVA_DEBUG_SERVER").ok().and_then(|p| p.parse::().ok()); + let mut cases = vec![ + // `debugpy --pid` injection ships only an x86_64 helper on macOS, so on Apple + // Silicon the attach goes through a `debugpy --listen` process. + AttachCase { + kind: attach::AttachKind::Python, + command: vec![ + "python3".into(), + "-m".into(), + "debugpy".into(), + "--listen".into(), + "127.0.0.1:5931".into(), + s(&dir.join("py/server.py")), + ], + source: dir.join("py/server.py"), + line: 4, + extra: |pid, port| match port { + // `debugpy --listen` runs its own adapter on that port: talk DAP to it directly. + Some(port) => serde_json::json!({"debugServer": port, "justMyCode": true}), + None => serde_json::json!({"processId": pid, "justMyCode": true}), + }, + }, + AttachCase { + kind: attach::AttachKind::Node, + command: match std::env::var("OPENIDEA_NODE_INSPECT") { + Ok(flag) => vec!["node".into(), flag, s(&dir.join("node/server.js"))], + Err(_) => vec!["node".into(), s(&dir.join("node/server.js"))], + }, + source: dir.join("node/server.js"), + line: 2, + extra: |pid, port| match port { + Some(port) => { + serde_json::json!({"type": "pwa-node", "address": "127.0.0.1", "port": port, "continueOnAttach": true}) + } + None => serde_json::json!({"type": "pwa-node", "processId": pid.to_string(), "continueOnAttach": true}), + }, + }, + AttachCase { + kind: attach::AttachKind::Go, + command: vec![s(&dir.join("goserver/server"))], + source: dir.join("goserver/main.go"), + line: 5, + extra: |pid, _| serde_json::json!({"mode": "local", "processId": pid}), + }, + AttachCase { + kind: attach::AttachKind::Native, + command: vec![s(&dir.join("c/server"))], + source: dir.join("c/server.c"), + line: 3, + extra: |pid, _| serde_json::json!({"pid": pid}), + }, + ]; + if java_server.is_some() { + cases.insert( + 0, + AttachCase { + kind: attach::AttachKind::Java, + command: vec![ + "java".into(), + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5917".into(), + "-cp".into(), + s(&dir.join("java/out")), + "demo.Server".into(), + ], + source: dir.join("java/src/demo/Server.java"), + line: 4, + extra: |_, port| serde_json::json!({"hostName": "localhost", "port": port}), + }, + ); + } + let only = std::env::var("OPENIDEA_DAP_ONLY").ok(); + let mut failures = Vec::new(); + for case in cases { + let label = format!("{:?}", case.kind); + if only.as_deref().is_some_and(|o| !label.eq_ignore_ascii_case(o)) { + continue; + } + let mut child = std::process::Command::new(&case.command[0]) + .args(&case.command[1..]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + tokio::time::sleep(Duration::from_millis(1500)).await; + let result = async { + let list = + tokio::task::spawn_blocking(attach::attachable_processes).await.unwrap().map_err(|e| e.message)?; + let p = list.iter().find(|p| p.pid == child.id()).ok_or("process not listed")?; + if p.kind != case.kind { + return Err(format!("listed as {:?}", p.kind)); + } + let mut extra = (case.extra)(p.pid, p.port); + if p.kind == attach::AttachKind::Java { + extra["debugServer"] = java_server.unwrap().into(); + } + let config = LaunchConfig { + name: format!("{} ({})", p.name, p.pid), + adapter: p.adapter.clone(), + request: "attach".into(), + program: None, + args: vec![], + cwd: case.source.parent().map(Into::into), + env: BTreeMap::new(), + extra_json: Some(extra.to_string()), + }; + let run = Run::new(); + run.manager + .set_breakpoints(case.source.clone(), vec![line_bp_at(case.line)]) + .await + .map_err(|e| e.message)?; + let id = + run.manager.start(config, run.owner.clone()).await.map_err(|e| format!("attach: {}", e.message))?; + let Some((s, thread, reason)) = run.stopped_after(0).await else { + let mut log: Vec = run.events.lock().iter().map(|(_, e)| format!("{e:?}")).collect(); + for info in run.manager.sessions(None) { + let threads = run.manager.threads(info.id).await; + log.push(format!("session {:?} threads {threads:?}", info.id)); + if let Ok(t) = threads + && let Some(t) = t.first() + { + let seen = run.seen(); + let _ = run.manager.pause(info.id, t.id).await; + if let Some((s, th, r)) = run.stopped_after(seen).await { + let frames = run.manager.stack_trace(s, th).await; + log.push(format!( + "paused ({r}): {:?}", + frames.map(|f| f + .into_iter() + .take(3) + .map(|f| (f.name, f.path, f.line)) + .collect::>()) + )); + } + } + } + log.push(format!("states: {:?}", run.manager.breakpoint_states(&case.source))); + let _ = run.manager.stop(id).await; + return Err(format!("no stop after attaching: {log:#?}")); + }; + let top = run.top(s, thread).await; + let _ = run.manager.stop(id).await; + Ok::<_, String>(format!("{} via {}: {reason} at {}:{}", p.name, p.adapter, top.name, top.line + 1)) + }; + let outcome = tokio::time::timeout(Duration::from_secs(150), result).await; + let _ = child.kill(); + let _ = child.wait(); + match outcome { + Ok(Ok(line)) => println!("== attach {label}: OK {line}"), + Ok(Err(e)) => { + println!("== attach {label}: FAILED {e}"); + failures.push(label); + } + Err(_) => { + println!("== attach {label}: TIMED OUT"); + failures.push(label); + } + } + } + assert!(failures.is_empty(), "failed: {failures:?}"); +} diff --git a/crates/ide-dap/tests/script_debuggers.rs b/crates/ide-dap/tests/script_debuggers.rs new file mode 100644 index 00000000..c7773ff9 --- /dev/null +++ b/crates/ide-dap/tests/script_debuggers.rs @@ -0,0 +1,406 @@ +//! Drives the script-language debuggers (debugpy, js-debug for Node / TypeScript / npm +//! scripts / Deno) through a whole session with the launch arguments the UI builds +//! (`src/features/debugger/launch.ts`): breakpoint verified and hit, stack, scopes, +//! variables, evaluate, step into / over / out, continue, run to exit, then a restart +//! that is terminated while paused. +//! +//! Ignored by default (needs the adapters and runtimes). Run with +//! `OPENIDEA_DAP_FIXTURES= cargo test -p ide-dap --test script_debuggers -- --ignored --nocapture` +//! where `` holds `py/app.py`, `node/app.js`, `ts/app.ts`, `tsmap/app.ts` compiled +//! with `tsc --sourceMap --outDir out`, `tsx/app.ts` (with `npm i tsx` there), `npm/` +//! (script `start` = `node app.js`) and `deno/app.ts`, all with the same layout (see `LOOP_LINE`). `OPENIDEA_DAP_ONLY=` +//! runs one case. Cases whose adapter or runtime is missing are skipped. + +use ide_core::event::DebugEvent; +use ide_core::{DebugSessionId, IdeEvent, SharedSink}; +use ide_dap::*; +use ide_language::LanguageRegistry; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// 0-based line of `total += parse(s)` in every fixture. +const LOOP_LINE: u32 = 8; +/// First line of `parse`'s body, and the one after it. +const PARSE_BODY: u32 = 2; +/// `console.log` / `print` of the result: `total 12`. +const OUTPUT: &str = "total 12"; + +struct Case { + name: &'static str, + /// Breakpoints are set here (the TypeScript source for source-mapped cases). + source: PathBuf, + /// The runtime that must be installed, besides the adapter. + runtime: &'static str, + config: LaunchConfig, +} + +fn config(adapter: &str, program: Option, cwd: &Path, extra: Value) -> LaunchConfig { + LaunchConfig { + name: "fixture".into(), + adapter: adapter.into(), + request: "launch".into(), + program: program.map(|p| p.display().to_string()), + args: vec![], + cwd: Some(cwd.into()), + env: BTreeMap::new(), + extra_json: Some(extra.to_string()), + } +} + +/// `jsDebugLaunch` in launch.ts, plus `more`. +fn js_extra(more: Value) -> Value { + let mut extra = json!({ + "type": "pwa-node", + "console": "internalConsole", + "outputCapture": "std", + "skipFiles": ["/**", "**/node_modules/**"], + "autoAttachChildProcesses": true, + }); + if let (Some(e), Value::Object(more)) = (extra.as_object_mut(), more) { + e.extend(more); + } + extra +} + +fn cases(dir: &Path) -> Vec { + let d = |s: &str| dir.join(s); + vec![ + Case { + name: "python", + source: d("py/app.py"), + runtime: "python3", + config: config( + "debugpy", + Some(d("py/app.py")), + &d("py"), + json!({ + "python": "python3", + "console": "internalConsole", + "justMyCode": true, + "subProcess": true, + "redirectOutput": true, + }), + ), + }, + Case { + name: "node", + source: d("node/app.js"), + runtime: "node", + config: config("js-debug", Some(d("node/app.js")), &d("node"), js_extra(json!({}))), + }, + Case { + // Node 22.18+ strips types natively. + name: "typescript", + source: d("ts/app.ts"), + runtime: "node", + config: config("js-debug", Some(d("ts/app.ts")), &d("ts"), js_extra(json!({}))), + }, + Case { + name: "typescript-flag", + source: d("ts/app.ts"), + runtime: "node", + config: config( + "js-debug", + None, + &d("ts"), + js_extra(json!({"runtimeExecutable": "node", "runtimeArgs": ["--experimental-strip-types", "app.ts"]})), + ), + }, + Case { + // `tsx/node_modules/.bin/tsx` (`npm i tsx` there): a runner on Node, source-mapped. + name: "tsx", + source: d("tsx/app.ts"), + runtime: "node", + config: config( + "js-debug", + None, + &d("tsx"), + js_extra(json!({ + "runtimeExecutable": d("tsx/node_modules/.bin/tsx"), + "runtimeArgs": ["app.ts"], + })), + ), + }, + Case { + name: "sourcemap", + source: d("tsmap/app.ts"), + runtime: "node", + config: config("js-debug", Some(d("tsmap/out/app.js")), &d("tsmap"), js_extra(json!({}))), + }, + Case { + name: "npm", + source: d("npm/app.js"), + runtime: "npm", + config: config( + "js-debug", + None, + &d("npm"), + js_extra(json!({"runtimeExecutable": "npm", "runtimeArgs": ["run", "start"]})), + ), + }, + Case { + name: "deno", + source: d("deno/app.ts"), + runtime: "deno", + config: config( + "js-debug", + None, + &d("deno"), + js_extra(json!({ + "runtimeExecutable": "deno", + "runtimeArgs": ["run", "--inspect-wait=127.0.0.1:${freePort}", "app.ts"], + "attachSimplePort": "${freePort}", + })), + ), + }, + ] +} + +struct Run { + manager: Arc, + events: Arc>>, + owner: SessionOwner, +} + +impl Run { + fn new() -> Self { + let events: Arc>> = Arc::default(); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e: IdeEvent| { + if let IdeEvent::Debug { session, event } = e { + sink_events.lock().push((session, event)); + } + }); + let languages = Arc::new(LanguageRegistry::from_configs(Vec::new())); + Self { manager: DebugManager::new(languages), events, owner: SessionOwner::new("test", sink) } + } + + fn seen(&self) -> usize { + self.events.lock().len() + } + + fn log(&self) -> String { + let log: Vec = self.events.lock().iter().map(|(s, e)| format!("{}: {e:?}", s.0)).collect(); + log.join("\n") + } + + async fn wait( + &self, + seen: usize, + what: &str, + f: impl Fn(DebugSessionId, &DebugEvent) -> Option, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if let Some(hit) = self.events.lock().iter().skip(seen).find_map(|(s, e)| f(*s, e)) { + return Ok(hit); + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + Err(format!("timed out waiting for {what}; events:\n{}", self.log())) + } + + async fn stopped(&self, seen: usize, what: &str) -> Result<(DebugSessionId, i64), String> { + self.wait(seen, what, |s, e| match e { + DebugEvent::Stopped { thread_id, .. } => Some((s, *thread_id)), + _ => None, + }) + .await + } + + async fn step(&self, s: DebugSessionId, thread: i64, kind: StepKind) -> Result { + let seen = self.seen(); + self.manager.step(s, thread, kind).await.map_err(|e| e.message)?; + self.stopped(seen, &format!("{kind:?}")).await?; + self.top(s, thread).await + } + + async fn top(&self, s: DebugSessionId, thread: i64) -> Result { + let frames = self.manager.stack_trace(s, thread).await.map_err(|e| e.message)?; + frames.into_iter().next().ok_or_else(|| "empty stack".into()) + } + + /// Every session of the run has ended (js-debug runs the program in a child session). + async fn all_terminated(&self) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if self.manager.sessions(None).is_empty() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(format!("sessions still live: {:?}", self.manager.sessions(None))) + } +} + +fn bp(line: u32) -> Breakpoint { + Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true } +} + +fn expect_line(f: &StackFrame, line: u32, what: &str) -> Result<(), String> { + if f.line == line { + Ok(()) + } else { + Err(format!("{what}: at {}:{} ({:?}), expected line {}", f.name, f.line + 1, f.path, line + 1)) + } +} + +async fn scenario(case: &Case) -> Result, String> { + let mut report = Vec::new(); + let run = Run::new(); + let m = &run.manager; + m.set_breakpoints(case.source.clone(), vec![bp(LOOP_LINE)]).await.map_err(|e| e.message)?; + let id = m.start(case.config.clone(), run.owner.clone()).await.map_err(|e| format!("start: {}", e.message))?; + + let (s, thread) = run.stopped(0, "the loop breakpoint").await?; + // js-debug verifies in a `breakpoint` event right after the stop. + let deadline = Instant::now() + Duration::from_secs(5); + while !m.breakpoint_states(&case.source).first().is_some_and(|st| st.verified) { + if Instant::now() > deadline { + return Err(format!("breakpoint not verified: {:?}", m.breakpoint_states(&case.source))); + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + let top = run.top(s, thread).await?; + expect_line(&top, LOOP_LINE, "breakpoint")?; + if top.path.as_deref().map(|p| p.canonicalize().ok()) != Some(case.source.canonicalize().ok()) { + return Err(format!("frame source {:?}, expected {}", top.path, case.source.display())); + } + report.push(format!("hit {}:{} (verified)", top.name, top.line + 1)); + + let scopes = m.scopes(s, top.id).await.map_err(|e| e.message)?; + // js-debug puts the loop variable in a Block scope before Local. + let mut vars = Vec::new(); + for scope in scopes.iter().filter(|sc| !sc.expensive) { + vars.extend(m.variables(s, scope.variables_reference).await.map_err(|e| e.message)?); + } + let total = vars.iter().find(|v| v.name == "total").ok_or_else(|| format!("no `total` in {vars:?}"))?; + let items = vars.iter().find(|v| v.name == "items").ok_or_else(|| format!("no `items` in {vars:?}"))?; + let children = m.variables(s, items.variables_reference).await.map_err(|e| e.message)?; + report.push(format!( + "scopes {:?}; total = {}; items has {} children", + scopes.iter().map(|sc| sc.name.as_str()).collect::>(), + total.value, + children.len() + )); + let v = m.evaluate(s, Some(top.id), "total + 100".into()).await.map_err(|e| e.message)?; + if v.value != "100" { + return Err(format!("evaluate: {}", v.value)); + } + + let f = run.step(s, thread, StepKind::Into).await?; + if !f.name.ends_with("parse") { + return Err(format!("step into: in {} at {}", f.name, f.line + 1)); + } + expect_line(&f, PARSE_BODY, "step into")?; + let f = run.step(s, thread, StepKind::Over).await?; + expect_line(&f, PARSE_BODY + 1, "step over")?; + let f = run.step(s, thread, StepKind::Out).await?; + if !f.name.ends_with("run") { + return Err(format!("step out: in {} at {}", f.name, f.line + 1)); + } + report.push(format!("step into / over / out: back in {}:{}", f.name, f.line + 1)); + + // Continue: the loop breakpoint again (second item). + let seen = run.seen(); + m.resume(s, thread).await.map_err(|e| e.message)?; + let (s2, t2) = run.stopped(seen, "the second hit").await?; + expect_line(&run.top(s2, t2).await?, LOOP_LINE, "continue")?; + + // Without breakpoints, the program runs to its end. + m.set_breakpoints(case.source.clone(), vec![]).await.map_err(|e| e.message)?; + let seen = run.seen(); + m.resume(s2, t2).await.map_err(|e| e.message)?; + run.wait(seen, "terminated", |s, e| (s == id && matches!(e, DebugEvent::Terminated)).then_some(())).await?; + // debugpy may deliver one `print` in several chunks. + let stdout: String = run + .events + .lock() + .iter() + .filter_map(|(_, e)| match e { + DebugEvent::Output { category, output } if category == "stdout" => Some(output.as_str()), + _ => None, + }) + .collect(); + if !stdout.contains(OUTPUT) { + return Err(format!("stdout {stdout:?} lacks {OUTPUT:?}")); + } + run.all_terminated().await?; + report.push("continue to exit: output and terminated".into()); + + // Restart (the UI starts the same configuration again), then Stop while paused. + m.set_breakpoints(case.source.clone(), vec![bp(LOOP_LINE)]).await.map_err(|e| e.message)?; + let seen = run.seen(); + let again = m.start(case.config.clone(), run.owner.clone()).await.map_err(|e| format!("restart: {}", e.message))?; + let (s, thread) = run.stopped(seen, "the breakpoint after restart").await?; + expect_line(&run.top(s, thread).await?, LOOP_LINE, "restart")?; + let seen = run.seen(); + m.stop(again).await.map_err(|e| e.message)?; + run.wait(seen, "terminated after stop", |s, e| (s == again && matches!(e, DebugEvent::Terminated)).then_some(())) + .await?; + run.all_terminated().await?; + report.push("restart + stop while paused: terminated".into()); + Ok(report) +} + +fn on_path(cmd: &str) -> bool { + adapter::resolve_binary(cmd, Path::new("/")).is_some() +} + +fn installed(case: &Case) -> bool { + let adapter = match case.config.adapter.as_str() { + "debugpy" => { + std::process::Command::new("python3").args(["-c", "import debugpy"]).status().is_ok_and(|s| s.success()) + || adapter::tools_root() + .is_some_and(|r| r.join(adapter::DEBUGPY_TOOL).join("lib/debugpy/__init__.py").is_file()) + } + _ => on_path("js-debug-adapter"), + }; + adapter && on_path(case.runtime) && case.source.is_file() +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs real debug adapters and OPENIDEA_DAP_FIXTURES"] +async fn script_debuggers() { + let Some(dir) = std::env::var_os("OPENIDEA_DAP_FIXTURES").map(PathBuf::from) else { + eprintln!("OPENIDEA_DAP_FIXTURES not set"); + return; + }; + let dir = dir.canonicalize().unwrap(); + let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).try_init(); + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + adapter::set_tools_root(home.join("Library/Application Support/ai.l0ng.openidea/tools")); + } + let only = std::env::var("OPENIDEA_DAP_ONLY").ok(); + let mut failures = Vec::new(); + for case in cases(&dir) { + if only.as_deref().is_some_and(|o| o != case.name) { + continue; + } + if !installed(&case) { + println!("== {}: SKIPPED (adapter, runtime or fixture missing)", case.name); + continue; + } + match tokio::time::timeout(Duration::from_secs(180), scenario(&case)).await { + Ok(Ok(lines)) => { + println!("== {}: OK", case.name); + for l in lines { + println!(" {l}"); + } + } + Ok(Err(e)) => { + println!("== {}: FAILED {e}", case.name); + failures.push(case.name); + } + Err(_) => { + println!("== {}: TIMED OUT", case.name); + failures.push(case.name); + } + } + } + assert!(failures.is_empty(), "failed: {failures:?}"); +} diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml new file mode 100644 index 00000000..4e062df7 --- /dev/null +++ b/crates/ide-db/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ide-db" +description = "Database tools: data sources, connections, introspection, query execution and table editing" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +toml.workspace = true +tokio.workspace = true +tracing.workspace = true +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" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tempfile.workspace = true diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs new file mode 100644 index 00000000..8e7d6c75 --- /dev/null +++ b/crates/ide-db/src/conn.rs @@ -0,0 +1,436 @@ +//! Live connections (one small pool per data source) and statement execution. +//! +//! Statements run through `raw_sql` (PostgreSQL's simple query protocol, +//! MySQL's text protocol), so every value arrives as text and is shown as is, +//! whatever its type. + +use crate::source::{DataSource, DbDriver, DbSslMode}; +use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use sqlx::{ + Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef, + mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode}, + pool::PoolConnection, + postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode}, + sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}, +}; +use std::path::Path; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbResultColumn")] +pub struct ResultColumn { + pub name: String, + pub type_name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbQueryResult")] +pub struct QueryResult { + pub statement: String, + pub columns: Vec, + /// Cell text; `None` is SQL NULL. Binary values are `0x…` hex. + pub rows: Vec>>, + /// Rows changed by a DML statement (`None` for queries). + pub affected: Option, + /// More rows exist beyond the limit. + pub has_more: bool, + pub elapsed_ms: f64, +} + +pub enum Pool { + Sqlite(SqlitePool), + Postgres(PgPool), + Mysql(MySqlPool), +} + +/// Runs `$body` with `$p` bound to the concrete pool of each driver. +macro_rules! with_pool { + ($pool:expr, $p:ident => $body:expr) => { + match $pool { + Pool::Sqlite($p) => $body, + Pool::Postgres($p) => $body, + Pool::Mysql($p) => $body, + } + }; +} + +pub(crate) fn db_err(e: sqlx::Error) -> IdeError { + match &e { + sqlx::Error::Database(d) => { + let code = d.code().map(|c| c.into_owned()); + // Wrong password: PostgreSQL 28P01, MySQL 1045 (SQLSTATE 28000). + let auth = matches!(code.as_deref(), Some("28P01" | "28000" | "1045")); + let msg = match code { + Some(c) => format!("[{c}] {}", d.message()), + None => d.message().to_owned(), + }; + IdeError::new(if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput }, msg) + } + sqlx::Error::PoolTimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"), + sqlx::Error::Io(_) | sqlx::Error::Tls(_) => IdeError::new(ErrorKind::Io, e.to_string()), + _ => IdeError::new(ErrorKind::Protocol, e.to_string()), + } +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 + bytes.len() * 2); + s.push_str("0x"); + for b in bytes { + s.push_str(&format!("{b:02X}")); + } + s +} + +/// The text of column `i` of `row`. +fn cell(row: &R, i: usize) -> Option +where + usize: ColumnIndex, + for<'r> String: Decode<'r, R::Database>, + for<'r> Vec: Decode<'r, R::Database>, +{ + let raw = row.try_get_raw(i).ok()?; + if raw.is_null() { + return None; + } + let ty = raw.type_info().name().to_ascii_uppercase(); + if ty.contains("BLOB") || ty.contains("BINARY") { + let raw = row.try_get_raw(i).ok()?; + return as Decode>::decode(raw).ok().map(|b| hex(&b)); + } + match >::decode(raw) { + Ok(s) => Some(s), + Err(_) => { + let raw = row.try_get_raw(i).ok()?; + as Decode>::decode(raw) + .ok() + .map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes()))) + } + } +} + +fn result_columns(row: &R) -> Vec { + row.columns() + .iter() + .map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() }) + .collect() +} + +/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows. +macro_rules! fetch { + ($exec:expr, $sql:expr, $max:expr) => {{ + use futures::StreamExt; + let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec); + let mut columns: Vec = Vec::new(); + let mut rows: Vec>> = Vec::new(); + let mut affected: Option = None; + let mut has_more = false; + let mut failure = None; + while let Some(item) = stream.next().await { + match item { + Ok(sqlx::Either::Left(done)) => affected = Some(affected.unwrap_or(0) + done.rows_affected()), + Ok(sqlx::Either::Right(row)) => { + if columns.is_empty() { + columns = result_columns(&row); + } + if rows.len() >= $max { + has_more = true; + break; + } + rows.push((0..row.len()).map(|i| cell(&row, i)).collect()); + } + Err(e) => { + failure = Some(db_err(e)); + break; + } + } + } + drop(stream); + match failure { + Some(e) => Err(e), + None => Ok((columns, rows, affected, has_more)), + } + }}; +} + +type Fetched = (Vec, Vec>>, Option, bool); + +/// The server-side id of a connection (to cancel its statement from another one). +macro_rules! backend_id { + ($conn:expr, $sql:expr) => {{ + let fetched: IdeResult = fetch!($conn, $sql, 1); + fetched + .ok() + .and_then(|f| f.1.into_iter().next()) + .and_then(|r| r.into_iter().next().flatten()) + .filter(|id| id.chars().all(|c| c.is_ascii_digit())) + }}; +} + +fn looks_like_query(sql: &str) -> bool { + let first = sql + .lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with("--")) + .and_then(|l| l.split_whitespace().next()) + .unwrap_or("") + .to_ascii_uppercase(); + matches!( + first.as_str(), + "SELECT" | "WITH" | "VALUES" | "TABLE" | "SHOW" | "PRAGMA" | "EXPLAIN" | "DESCRIBE" | "DESC" + ) +} + +impl Pool { + pub fn driver(&self) -> DbDriver { + match self { + Pool::Sqlite(_) => DbDriver::Sqlite, + Pool::Postgres(_) => DbDriver::Postgres, + Pool::Mysql(_) => DbDriver::Mysql, + } + } + + /// Opens a pool and one connection (so bad settings fail here). + pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult { + const MAX: u32 = 4; + let timeout = Duration::from_secs(30); + match source.driver { + DbDriver::Sqlite => { + let file = source.file.as_deref().ok_or_else(|| IdeError::invalid("no database file"))?; + let file = match root { + Some(r) if file.is_relative() => r.join(file), + _ => file.to_path_buf(), + }; + let opts = + SqliteConnectOptions::new().filename(&file).create_if_missing(false).read_only(source.read_only); + let pool = SqlitePoolOptions::new() + .max_connections(MAX) + .acquire_timeout(timeout) + .connect_with(opts) + .await + .map_err(db_err)?; + Ok(Pool::Sqlite(pool)) + } + DbDriver::Postgres => { + let mut opts = PgConnectOptions::new() + .host(&source.host) + .port(source.port.unwrap_or(5432)) + .username(&source.user) + .application_name("OpenIDEA") + .ssl_mode(match source.ssl { + DbSslMode::Disable => PgSslMode::Disable, + DbSslMode::Prefer => PgSslMode::Prefer, + DbSslMode::Require => PgSslMode::Require, + }); + 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")]); + } + let pool = PgPoolOptions::new() + .max_connections(MAX) + .acquire_timeout(timeout) + .connect_with(opts) + .await + .map_err(db_err)?; + Ok(Pool::Postgres(pool)) + } + DbDriver::Mysql => { + let mut opts = MySqlConnectOptions::new() + .host(&source.host) + .port(source.port.unwrap_or(3306)) + .username(&source.user) + .ssl_mode(match source.ssl { + DbSslMode::Disable => MySqlSslMode::Disabled, + DbSslMode::Prefer => MySqlSslMode::Preferred, + DbSslMode::Require => MySqlSslMode::Required, + }); + 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; + 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(()) + }) + }) + .connect_with(opts) + .await + .map_err(db_err)?; + Ok(Pool::Mysql(pool)) + } + } + } + + pub async fn close(&self) { + with_pool!(self, p => p.close().await) + } + + /// Runs `sql` (no row limit beyond `max`), for introspection. + pub async fn rows(&self, sql: &str, max: usize) -> IdeResult>>> { + let fetched: IdeResult = with_pool!(self, p => fetch!(p, sql, max)); + Ok(fetched?.1) + } + + /// Runs one statement and keeps at most `max_rows` rows. Cancelling stops + /// the statement on the server (PostgreSQL, MySQL) and drops the connection. + pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult { + let mut conn = Held::acquire(self).await?; + let r = conn.execute(self, sql, max_rows, cancel).await; + if cancel.is_cancelled() { + conn.discard().await; + return Err(IdeError::cancelled()); + } + r + } + + /// Runs `statements` in one transaction; each must change `expect_one` + /// row when flagged (table editor submits). + pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult { + with_pool!(self, p => { + let mut tx = p.begin().await.map_err(db_err)?; + let mut total = 0; + for (sql, expect_one) in statements { + let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?; + let n = done.rows_affected(); + // MySQL reports 0 for an UPDATE that leaves the row unchanged. + if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) { + return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved"))); + } + total += n; + } + tx.commit().await.map_err(db_err)?; + Ok(total) + }) + } +} + +/// A connection taken out of a pool: for one statement, or held by a +/// manual-commit console across statements with its transaction open. +pub enum Held { + Sqlite(PoolConnection), + Postgres(PoolConnection), + Mysql(PoolConnection), +} + +impl Held { + pub async fn acquire(pool: &Pool) -> IdeResult { + Ok(match pool { + Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?), + Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?), + Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?), + }) + } + + /// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`). + pub async fn command(&mut self, sql: &str) -> IdeResult<()> { + let sql = sqlx::AssertSqlSafe(sql.to_owned()); + match self { + Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + } + .map_err(db_err) + } + + /// Closes the connection instead of returning it to the pool (its statement was + /// interrupted; an open transaction is rolled back by the server). + pub async fn discard(self) { + match self { + Held::Sqlite(c) => drop(c.detach()), + Held::Postgres(c) => { + let _ = c.detach().close().await; + } + Held::Mysql(c) => { + let _ = c.detach().close().await; + } + } + } + + /// Runs one statement on this connection (see [`Pool::execute`]); after a + /// cancellation the caller must [`discard`](Self::discard) it. + pub async fn execute( + &mut self, + pool: &Pool, + sql: &str, + max_rows: usize, + cancel: &CancellationToken, + ) -> IdeResult { + let started = Instant::now(); + // `$id`: the query naming the server process, `$kill`: how another connection stops it. + macro_rules! run { + ($conn:expr, $id:expr, $kill:expr) => {{ + let backend = match $id { + Some(q) => backend_id!(&mut **$conn, q), + None => None, + }; + let r = tokio::select! { + r = async { fetch!(&mut **$conn, sql, max_rows) } => r, + () = cancel.cancelled() => Err(IdeError::cancelled()), + }; + if cancel.is_cancelled() { + if let Some(b) = backend { + let _ = pool.rows(&$kill(b), 1).await; + } + return Err(IdeError::cancelled()); + } + let mut r = r?; + if r.0.is_empty() && looks_like_query(sql) { + r.0 = (&mut **$conn) + .prepare(sqlx::AssertSqlSafe(sql.to_owned()).into_sql_str()) + .await + .map(|s| statement_columns(&s)) + .unwrap_or_default(); + } + r + }}; + } + let fetched: Fetched = match self { + Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()), + Held::Postgres(c) => { + run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})")) + } + Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")), + }; + let (columns, rows, affected, has_more) = fetched; + 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, + }) + } +} + +/// The statement opening a transaction. +pub fn begin_statement(driver: DbDriver) -> &'static str { + match driver { + DbDriver::Mysql => "START TRANSACTION", + DbDriver::Sqlite | DbDriver::Postgres => "BEGIN", + } +} + +fn statement_columns(s: &S) -> Vec { + s.columns() + .iter() + .map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() }) + .collect() +} diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs new file mode 100644 index 00000000..dbf3b82f --- /dev/null +++ b/crates/ide-db/src/lib.rs @@ -0,0 +1,793 @@ +//! Database tools: data sources ([`source`]), connections and statement +//! execution ([`conn`]), introspection, table data paging / editing ([`sql`]) +//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB. + +pub mod conn; +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 sql::{CellValue, DbRowEdit, SortKey}; + +use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; +use parking_lot::Mutex; +use secrets::SecretStore; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DbTableKind { + Table, + View, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbTableInfo")] +pub struct TableInfo { + pub name: String, + pub kind: DbTableKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbColumnInfo")] +pub struct ColumnInfo { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub default: Option, + pub primary_key: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbIndexInfo")] +pub struct IndexInfo { + pub name: String, + pub unique: bool, + pub columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbTableDetails")] +pub struct TableDetails { + pub columns: Vec, + pub indexes: Vec, + pub foreign_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbCompletionColumn")] +pub struct CompletionColumn { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub primary_key: bool, + /// Has a default (or is generated): optional in INSERT. + pub has_default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbCompletionTable")] +pub struct CompletionTable { + pub schema: String, + pub name: String, + pub kind: DbTableKind, + pub columns: Vec, +} + +/// `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbForeignKey")] +pub struct ForeignKey { + pub schema: String, + pub table: String, + pub columns: Vec, + pub ref_schema: String, + pub ref_table: String, + pub ref_columns: Vec, +} + +/// The schema model SQL completion and resolution work from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbCompletion")] +pub struct CompletionModel { + /// Schema unqualified names resolve to. + pub default_schema: String, + pub tables: Vec, + pub foreign_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbTablePage")] +pub struct TablePage { + pub result: QueryResult, + pub details: TableDetails, + pub offset: f64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbDatabases")] +pub struct Databases { + /// The database the data source connects to (its setting, or the server default). + pub current: String, + pub names: Vec, +} + +/// Connections of one project's data sources, keyed by connection target (see [`split_target`]). +pub struct DbManager { + root: PathBuf, + pools: Mutex>>, + /// SSH tunnels by data source id, shared by the pools of its databases + /// (closed with the last of them). + tunnels: Mutex>>, + /// 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>, + known_hosts: PathBuf, + ssh_config: Option, + saved_passwords: SecretStore, + /// Passwords entered this session (not saved, or not yet), by credential key. + session_passwords: Mutex>, + /// Manual-commit data sources, each with the connection of its open + /// transaction (taken by the first statement after a commit / rollback). + sessions: Mutex>, +} + +type Session = Arc>>; + +const MAX_META_ROWS: usize = 100_000; + +fn flag(v: &Option) -> bool { + matches!(v.as_deref(), Some("1" | "t" | "true" | "YES")) +} + +/// Groups [`sql::foreign_keys`] rows into keys; a missing ref column (SQLite's +/// implicit primary key reference) takes the referenced table's primary key. +fn group_foreign_keys(rows: Vec>>, tables: &[CompletionTable]) -> Vec { + let mut out: Vec<(String, ForeignKey)> = Vec::new(); + for r in rows { + let mut it = r.into_iter(); + let mut next = || it.next().flatten(); + let (Some(schema), Some(table), Some(constraint), Some(column), ref_schema, Some(ref_table), ref_column) = + (next(), next(), next(), next(), next(), next(), next()) + else { + continue; + }; + let ref_schema = ref_schema.unwrap_or_else(|| schema.clone()); + let same = |(c, k): &(String, ForeignKey)| *c == constraint && k.schema == schema && k.table == table; + if !out.last().is_some_and(same) { + out.push(( + constraint.clone(), + ForeignKey { schema, table, columns: Vec::new(), ref_schema, ref_table, ref_columns: Vec::new() }, + )); + } + let key = &mut out.last_mut().expect("pushed above").1; + key.columns.push(column); + key.ref_columns.extend(ref_column); + } + out.into_iter() + .filter_map(|(_, mut k)| { + if k.ref_columns.is_empty() { + let Some(target) = tables.iter().find(|t| t.schema == k.ref_schema && t.name == k.ref_table) else { + // Resolved by the caller (or dropped) when there is no table list. + return tables.is_empty().then_some(k); + }; + k.ref_columns = target.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect(); + } + (k.ref_columns.len() == k.columns.len()).then_some(k) + }) + .collect() +} + +/// Connection target: a data source id (its configured database), or +/// `:` for another database of the same server. Data source ids +/// never contain `:`. +pub fn split_target(target: &str) -> (&str, Option<&str>) { + match target.split_once(':') { + Some((id, db)) if !db.is_empty() => (id, Some(db)), + Some((id, _)) => (id, None), + None => (target, None), + } +} + +/// 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 }; + 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), + ) +} + +/// Rolls back a session's transaction and releases its connection. +async fn end_session(session: &Session) { + let held = session.lock().await.take(); + if let Some(mut conn) = held + && conn.command("ROLLBACK").await.is_err() + { + conn.discard().await; + } +} + +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, + ) -> Self { + Self { + saved_passwords: SecretStore::new(passwords, &root), + root, + pools: Mutex::default(), + tunnels: Mutex::default(), + tunnel_open: tokio::sync::Mutex::new(()), + unknown_host_keys: Mutex::default(), + known_hosts, + ssh_config, + session_passwords: Mutex::default(), + sessions: Mutex::default(), + } + } + + pub fn sources(&self) -> IdeResult> { + source::load(&self.root) + } + + /// The data source of connection target `target`, with its database for `:`. + pub fn source(&self, target: &str) -> IdeResult { + 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}")))?; + if let Some(db) = database { + source.database = db.to_owned(); + } + Ok(source) + } + + /// Saves the data source list; connections of removed or changed sources are closed. + pub async fn save_sources(&self, sources: Vec) -> IdeResult<()> { + let old = self.sources().unwrap_or_default(); + source::save(&self.root, &sources)?; + for o in old { + match sources.iter().find(|s| s.id == o.id) { + Some(n) if *n == o => {} + gone_or_changed => { + if gone_or_changed.is_none() { + for k in [o.id.clone(), secrets::ssh_key(&o.id)] { + self.session_passwords.lock().remove(&k); + self.saved_passwords.delete(&k); + } + } + self.disconnect_source(&o.id).await; + } + } + } + Ok(()) + } + + /// Remembers `password` for the session, and in the saved passwords when + /// the data source saves passwords (`None` forgets it). + pub async fn set_password(&self, id: &str, password: Option, save: bool) -> IdeResult<()> { + self.remember(id.to_owned(), password, save).await + } + + /// [`DbManager::set_password`] for the SSH password / key passphrase. + pub async fn set_ssh_secret(&self, id: &str, secret: Option, save: bool) -> IdeResult<()> { + self.remember(secrets::ssh_key(id), secret, save).await + } + + async fn remember(&self, key: String, secret: Option, save: bool) -> IdeResult<()> { + match &secret { + Some(p) => { + self.session_passwords.lock().insert(key.clone(), p.clone()); + } + None => { + self.session_passwords.lock().remove(&key); + } + } + let result = match (secret, save) { + (Some(p), true) => self.saved_passwords.set(&key, &p), + _ => { + self.saved_passwords.delete(&key); + Ok(()) + } + }; + if let Err(e) = result { + // The session copy still works; the user is asked again next time. + tracing::warn!("could not save the password: {}", e.message); + } + Ok(()) + } + + async fn secret(&self, key: String, saved: bool) -> Option { + if let Some(p) = self.session_passwords.lock().get(&key) { + return Some(p.clone()); + } + if !saved { + return None; + } + self.saved_passwords.get(&key) + } + + async fn password(&self, source: &DataSource) -> Option { + self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await + } + + /// 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()))? + } + + /// Opens the SSH tunnel of `source` when it has one, returning the settings to + /// connect with (the tunnel's local end instead of the database host). + /// `shared` reuses (or registers) the data source's tunnel for its pools; Test + /// Connection opens a private one. + async fn endpoint( + &self, + source: &DataSource, + ssh_secret: Option, + shared: bool, + ) -> IdeResult<(DataSource, Option>)> { + let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && source.driver != DbDriver::Sqlite) else { + return Ok((source.clone(), None)); + }; + let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() }; + let _opening = if shared { Some(self.tunnel_open.lock().await) } else { None }; + if shared { + let existing = self.tunnels.lock().get(&source.id).filter(|t| !t.is_closed()).cloned(); + if let Some(t) = existing { + 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 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)) + } + } + } + + /// Connects with `source` as given (not necessarily saved): Test Connection. + pub async fn test( + &self, + source: &DataSource, + password: Option, + ssh_secret: Option, + ) -> IdeResult { + 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 version = match source.driver { + DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()", + DbDriver::Postgres => "SELECT version()", + DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())", + }; + let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?); + pool.close().await; + Ok(v.unwrap_or_else(|| "Connected".into())) + } + + pub fn is_connected(&self, id: &str) -> bool { + self.pools.lock().contains_key(id) + } + + /// Connected targets (data source ids, `:`). + pub fn connected(&self) -> Vec { + self.pools.lock().keys().cloned().collect() + } + + /// The pool of target `id`, connecting first if needed. + pub async fn pool(&self, id: &str) -> IdeResult> { + let (source_id, _) = split_target(id); + // A dropped SSH session leaves every pool through it unusable: start over. + if self.tunnels.lock().get(source_id).is_some_and(|t| t.is_closed()) { + self.disconnect_source(source_id).await; + } + if let Some(p) = self.pools.lock().get(id) { + return Ok(p.clone()); + } + let source = self.source(id)?; + let password = self.password(&source).await; + let (target, tunnel) = self.endpoint(&source, None, true).await?; + let pool = match Pool::connect(&target, password.as_deref(), Some(&self.root)).await { + Ok(p) => Arc::new(p), + Err(e) => { + self.drop_unused_tunnel(source_id); + return Err(through_tunnel(e, &source, tunnel.is_some())); + } + }; + let mut pools = self.pools.lock(); + // Another caller may have connected meanwhile; keep theirs. + Ok(pools.entry(id.to_owned()).or_insert(pool).clone()) + } + + /// Closes the tunnel of `source_id` once none of its targets is connected. + fn drop_unused_tunnel(&self, source_id: &str) { + let in_use = self.pools.lock().keys().any(|k| split_target(k).0 == source_id); + if !in_use { + self.tunnels.lock().remove(source_id); + } + } + + /// Disconnects target `id` (one database of a data source). + pub async fn disconnect(&self, id: &str) { + // A held connection would keep the pool from closing; manual mode stays on. + let session = self.sessions.lock().get(id).cloned(); + if let Some(s) = session { + end_session(&s).await; + } + let pool = self.pools.lock().remove(id); + if let Some(p) = pool { + p.close().await; + } + self.drop_unused_tunnel(split_target(id).0); + } + + /// Disconnects every database of data source `id`. + pub async fn disconnect_source(&self, id: &str) { + let targets: Vec = self.connected().into_iter().filter(|t| split_target(t).0 == id).collect(); + for t in targets { + self.disconnect(&t).await; + } + self.tunnels.lock().remove(id); + } + + /// The databases of the server of data source `id` (PostgreSQL), the connected one first. + pub async fn databases(&self, id: &str) -> IdeResult { + let pool = self.pool(split_target(id).0).await?; + if pool.driver() != DbDriver::Postgres { + return Err(IdeError::invalid("only PostgreSQL data sources list databases")); + } + let first = |rows: Vec>>| 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 mut names: Vec = + first(rows).filter(|n| *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect(); + if !names.contains(¤t) { + names.insert(0, current.clone()); + } + Ok(Databases { current, names }) + } + + pub async fn schemas(&self, id: &str) -> IdeResult> { + let pool = self.pool(id).await?; + let rows = pool.rows(sql::schemas(pool.driver()), MAX_META_ROWS).await?; + Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect()) + } + + pub async fn tables(&self, id: &str, schema: &str) -> IdeResult> { + let pool = self.pool(id).await?; + let rows = pool.rows(&sql::tables(pool.driver(), schema), MAX_META_ROWS).await?; + Ok(rows + .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("view") { DbTableKind::View } else { DbTableKind::Table }; + Some(TableInfo { name, kind }) + }) + .collect()) + } + + pub async fn table_details(&self, id: &str, schema: &str, table: &str) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let columns = pool + .rows(&sql::columns(driver, schema, table), MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + let name = it.next().flatten()?; + let data_type = it.next().flatten().unwrap_or_default(); + let nullable = flag(&it.next().flatten()); + let default = it.next().flatten(); + let primary_key = flag(&it.next().flatten()); + Some(ColumnInfo { name, data_type, nullable, default, primary_key }) + }) + .collect(); + let mut indexes: Vec = Vec::new(); + for r in pool.rows(&sql::indexes(driver, schema, table), MAX_META_ROWS).await? { + let mut it = r.into_iter(); + let (Some(name), unique, Some(col)) = (it.next().flatten(), it.next().flatten(), it.next().flatten()) + else { + continue; + }; + match indexes.last_mut() { + Some(last) if last.name == name => last.columns.push(col), + _ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }), + } + } + let fk_rows = + pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default(); + let mut foreign_keys = group_foreign_keys(fk_rows, &[]); + // SQLite keys that reference the primary key implicitly: look it up. + for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) { + k.ref_columns = pool + .rows(&sql::columns(driver, &k.ref_schema, &k.ref_table), MAX_META_ROWS) + .await? + .into_iter() + .filter(|r| flag(&r.get(4).cloned().flatten())) + .filter_map(|r| r.into_iter().next().flatten()) + .collect(); + } + foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len()); + Ok(TableDetails { columns, indexes, foreign_keys }) + } + + /// The DDL of table or view `name`: SQLite's stored statements, MySQL's + /// `SHOW CREATE`, PostgreSQL's generated from the catalog. + pub async fn ddl(&self, id: &str, schema: &str, name: &str) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let missing = || IdeError::not_found(format!("{schema}.{name}")); + let cells = |rows: Vec>>, i: usize| -> Vec { + rows.into_iter().filter_map(|r| r.into_iter().nth(i).flatten()).collect() + }; + match driver { + DbDriver::Sqlite => { + let stmts = cells(pool.rows(&sql::sqlite_ddl(schema, name), MAX_META_ROWS).await?, 0); + if stmts.is_empty() { + return Err(missing()); + } + Ok(stmts + .iter() + .map(|s| format!("{};\n", s.trim_end().trim_end_matches(';'))) + .collect::>() + .join("\n")) + } + DbDriver::Mysql => { + let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name)); + let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next(); + ddl.map(|d| format!("{d};\n")).ok_or_else(missing) + } + DbDriver::Postgres => { + let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next(); + let (oid, kind) = match rel.as_deref() { + Some([Some(oid), Some(kind)]) => { + (oid.parse::().map_err(|e| IdeError::invalid(e.to_string()))?, kind.clone()) + } + _ => return Err(missing()), + }; + if kind == "v" || kind == "m" { + let def = cells(pool.rows(&format!("SELECT pg_get_viewdef({oid}, true)"), 1).await?, 0); + let what = if kind == "m" { "materialized view" } else { "view" }; + let body = def.into_iter().next().unwrap_or_default(); + let target = sql::qualified(driver, schema, name); + return Ok(format!("create {what} {target} as\n{};\n", body.trim_end().trim_end_matches(';'))); + } + let columns = pool + .rows(&sql::postgres_ddl_columns(oid), MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + Some(sql::DdlColumn { + name: it.next().flatten()?, + data_type: it.next().flatten().unwrap_or_default(), + not_null: flag(&it.next().flatten()), + default: it.next().flatten(), + }) + }) + .collect::>(); + let constraints = pool + .rows(&sql::postgres_ddl_constraints(oid), MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + Some((it.next().flatten()?, it.next().flatten()?)) + }) + .collect::>(); + let indexes = cells(pool.rows(&sql::postgres_ddl_indexes(oid), MAX_META_ROWS).await?, 0); + Ok(sql::postgres_table_ddl(schema, name, &columns, &constraints, &indexes)) + } + } + } + + pub async fn completion(&self, id: &str) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let mut tables: Vec = Vec::new(); + for r in pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await? { + let mut it = r.into_iter().map(Option::unwrap_or_default); + let mut next = || it.next().unwrap_or_default(); + let (schema, name, kind) = (next(), next(), next()); + let column = CompletionColumn { + name: next(), + data_type: next(), + nullable: flag(&Some(next())), + primary_key: flag(&Some(next())), + has_default: flag(&Some(next())), + }; + if name.is_empty() || column.name.is_empty() { + continue; + } + match tables.last_mut() { + Some(t) if t.schema == schema && t.name == name => t.columns.push(column), + _ => tables.push(CompletionTable { + schema, + name, + kind: if kind == "view" { DbTableKind::View } else { DbTableKind::Table }, + columns: vec![column], + }), + } + } + // Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes. + let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| { + tracing::warn!("foreign keys: {}", e.message); + Vec::new() + }); + let foreign_keys = group_foreign_keys(fk_rows, &tables); + let default_schema = match sql::current_schema(driver) { + None => "main".to_owned(), + Some(q) => pool + .rows(q, 1) + .await? + .into_iter() + .next() + .and_then(|r| r.into_iter().next().flatten()) + .unwrap_or_default(), + }; + Ok(CompletionModel { default_schema, tables, foreign_keys }) + } + + pub async fn execute( + &self, + id: &str, + statement: &str, + max_rows: usize, + cancel: &CancellationToken, + ) -> IdeResult { + let pool = self.pool(id).await?; + let session = self.sessions.lock().get(id).cloned(); + let Some(session) = session else { + return pool.execute(statement, max_rows, cancel).await; + }; + let mut held = session.lock().await; + if held.is_none() { + let mut conn = Held::acquire(&pool).await?; + conn.command(conn::begin_statement(pool.driver())).await?; + *held = Some(conn); + } + let conn = held.as_mut().expect("opened above"); + let r = conn.execute(&pool, statement, max_rows, cancel).await; + if cancel.is_cancelled() { + // The server rolls the transaction back with the connection. + held.take().expect("opened above").discard().await; + return Err(IdeError::cancelled()); + } + r + } + + /// Switches `id` between auto-commit and manual commit. Leaving manual + /// mode rolls back what was not committed. + pub async fn set_manual_commit(&self, id: &str, manual: bool) { + if manual { + self.sessions.lock().entry(id.to_owned()).or_default(); + } else { + let session = self.sessions.lock().remove(id); + if let Some(s) = session { + end_session(&s).await; + } + } + } + + /// Commits (or rolls back) the open transaction of a manual-commit data source. + pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> { + let session = self.sessions.lock().get(id).cloned(); + let Some(session) = session else { return Ok(()) }; + let mut held = session.lock().await; + let Some(mut conn) = held.take() else { return Ok(()) }; + match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await { + Ok(()) => Ok(()), + Err(e) => { + conn.discard().await; + Err(e) + } + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn table_page( + &self, + id: &str, + schema: &str, + table: &str, + filter: Option<&str>, + sort: &[SortKey], + offset: u64, + limit: u64, + cancel: &CancellationToken, + ) -> IdeResult { + let details = self.table_details(id, schema, table).await?; + let pool = self.pool(id).await?; + let sql = sql::table_page(pool.driver(), schema, table, filter, sort, offset, limit); + let mut result = pool.execute(&sql, limit as usize, cancel).await?; + if result.columns.is_empty() { + result.columns = details + .columns + .iter() + .map(|c| ResultColumn { name: c.name.clone(), type_name: c.data_type.clone() }) + .collect(); + } + Ok(TablePage { result, details, offset: offset as f64 }) + } + + /// Applies the table editor's pending changes in one transaction. + pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let statements = edits + .iter() + .map(|e| { + let sql = sql::edit_statement(driver, schema, table, e).map_err(IdeError::invalid)?; + Ok((sql, !matches!(e, DbRowEdit::Insert { .. }))) + }) + .collect::>>()?; + pool.transaction(&statements).await + } +} diff --git a/crates/ide-db/src/secrets.rs b/crates/ide-db/src/secrets.rs new file mode 100644 index 00000000..8de033f8 --- /dev/null +++ b/crates/ide-db/src/secrets.rs @@ -0,0 +1,109 @@ +//! Saved data source passwords, in the app's data directory rather than the OS +//! credential store (no system prompts). One JSON file for all projects, keyed +//! by project root, then credential key; owner-only on Unix. + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Project root → credential key → secret. +type Contents = BTreeMap>; + +/// Serialises read-modify-write across the projects sharing the file. +static LOCK: Mutex<()> = Mutex::new(()); + +pub struct SecretStore { + path: PathBuf, + project: String, +} + +/// Credential key of the SSH password / key passphrase of data source `id`. +pub fn ssh_key(id: &str) -> String { + format!("{id}#ssh") +} + +impl SecretStore { + pub fn new(path: PathBuf, root: &Path) -> Self { + Self { path, project: root.to_string_lossy().into_owned() } + } + + pub fn get(&self, key: &str) -> Option { + let _g = LOCK.lock(); + self.read().remove(&self.project)?.remove(key) + } + + pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> { + let _g = LOCK.lock(); + let mut all = self.read(); + all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned()); + self.write(&all) + } + + pub fn delete(&self, key: &str) { + let _g = LOCK.lock(); + let mut all = self.read(); + let Some(project) = all.get_mut(&self.project) else { return }; + if project.remove(key).is_none() { + return; + } + if project.is_empty() { + all.remove(&self.project); + } + if let Err(e) = self.write(&all) { + tracing::warn!("saved passwords: {}", e.message); + } + } + + fn read(&self) -> Contents { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!("saved passwords: {}: {e}", self.path.display()); + Contents::new() + }), + Err(_) => Contents::new(), + } + } + + fn write(&self, all: &Contents) -> IdeResult<()> { + let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}")); + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir).map_err(err)?; + } + let tmp = self.path.with_extension("json.tmp"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?; + std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?; + std::fs::rename(&tmp, &self.path).map_err(err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_projects_apart_and_drops_empty_ones() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("db-passwords.json"); + let a = SecretStore::new(file.clone(), Path::new("/p/a")); + let b = SecretStore::new(file.clone(), Path::new("/p/b")); + a.set("s1", "pw-a").unwrap(); + b.set("s1", "pw-b").unwrap(); + b.set(&ssh_key("s1"), "pass").unwrap(); + assert_eq!(a.get("s1").as_deref(), Some("pw-a")); + assert_eq!(b.get("s1#ssh").as_deref(), Some("pass")); + a.delete("s1"); + assert_eq!(a.get("s1"), None); + assert_eq!(b.get("s1").as_deref(), Some("pw-b")); + assert!(!std::fs::read_to_string(&file).unwrap().contains("/p/a")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); + } + } +} diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs new file mode 100644 index 00000000..c691ad34 --- /dev/null +++ b/crates/ide-db/src/source.rs @@ -0,0 +1,167 @@ +//! Data sources: per-project connection settings in `.openidea/data-sources.toml`. +//! Passwords never go there: saved ones live in [`crate::secrets`], the others +//! only in memory for the session. + +use ide_core::{IdeError, IdeResult, IpcPath, project_dir}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +const FILE: &str = "data-sources.toml"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DbDriver { + Sqlite, + Postgres, + /// MySQL and MariaDB. + Mysql, +} + +impl DbDriver { + pub fn default_port(self) -> Option { + match self { + DbDriver::Sqlite => None, + DbDriver::Postgres => Some(5432), + DbDriver::Mysql => Some(3306), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DbSslMode { + Disable, + #[default] + Prefer, + Require, +} + +/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type"). +#[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. + #[default] + OpenSsh, +} + +/// 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, + /// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`]. + pub host: String, + pub port: Option, + pub user: String, + pub auth: DbSshAuth, + /// Private key for [`DbSshAuth::KeyPair`]; `~/` is the home directory. + pub key_file: String, + /// Keep the SSH password / key passphrase in the saved passwords. + pub save_secret: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase", default)] +pub struct DataSource { + pub id: String, + pub name: String, + pub driver: DbDriver, + pub host: String, + pub port: Option, + pub database: String, + pub user: String, + /// SQLite database file (absolute, or relative to the project root). + pub file: Option, + pub ssl: DbSslMode, + /// Keep the password in the saved passwords. + pub save_password: bool, + /// Refuse statements other than queries (enforced by the database session where possible). + pub read_only: bool, + /// SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`). + pub ssh: Option, +} + +impl Default for DataSource { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + driver: DbDriver::Sqlite, + host: "localhost".into(), + port: None, + database: String::new(), + user: String::new(), + file: None, + ssl: DbSslMode::Prefer, + save_password: false, + read_only: false, + ssh: None, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SourcesFile { + #[serde(default, rename = "data-source")] + sources: Vec, +} + +pub fn load(root: &Path) -> IdeResult> { + let path = project_dir::path(root, FILE); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + let file: SourcesFile = toml::from_str(&text).map_err(|e| IdeError::invalid(format!("{}: {e}", path.display())))?; + Ok(file.sources) +} + +pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> { + for s in sources { + if s.id.is_empty() || s.name.trim().is_empty() { + return Err(IdeError::invalid("every data source needs an id and a name")); + } + } + let path = project_dir::path(root, FILE); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() }) + .map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(path, text)?; + Ok(()) +} + +/// Credential store access. Blocking (may show an OS prompt): call off the async runtime. +/// Keys are data source ids; [`secrets::ssh_key`] names the SSH password / passphrase. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_without_passwords() { + let tmp = tempfile::tempdir().unwrap(); + assert!(load(tmp.path()).unwrap().is_empty()); + let s = DataSource { + id: "a1".into(), + name: "local".into(), + driver: DbDriver::Postgres, + port: Some(5433), + database: "app".into(), + user: "me".into(), + save_password: true, + ..Default::default() + }; + save(tmp.path(), std::slice::from_ref(&s)).unwrap(); + let text = std::fs::read_to_string(tmp.path().join(".openidea/data-sources.toml")).unwrap(); + assert!(!text.contains("password ="), "{text}"); + assert_eq!(load(tmp.path()).unwrap(), vec![s]); + assert!(save(tmp.path(), &[DataSource::default()]).is_err()); + } +} diff --git a/crates/ide-db/src/split.rs b/crates/ide-db/src/split.rs new file mode 100644 index 00000000..e9cdbc84 --- /dev/null +++ b/crates/ide-db/src/split.rs @@ -0,0 +1,269 @@ +//! Splitting SQL scripts into statements (`;`-terminated), aware of string +//! literals, quoted identifiers, comments and PostgreSQL dollar quoting. +//! Offsets are UTF-16 code units, like everything on the IPC boundary. + +use crate::DbDriver; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SqlStatement { + /// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). + pub from: u32, + pub to: u32, + pub text: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Code, + Single, + Double, + Backtick, + LineComment, + BlockComment, +} + +/// Every statement of `text` that contains more than whitespace and comments. +pub fn split(text: &str, driver: DbDriver) -> Vec { + let mysql = driver == DbDriver::Mysql; + let chars: Vec<(usize, char)> = text.char_indices().collect(); + // UTF-16 offset of each char index (plus the end). + let mut u16_at = Vec::with_capacity(chars.len() + 1); + let mut u = 0u32; + for &(_, c) in &chars { + u16_at.push(u); + u += c.len_utf16() as u32; + } + u16_at.push(u); + let byte_at = |i: usize| chars.get(i).map_or(text.len(), |c| c.0); + + let mut out = Vec::new(); + let mut state = State::Code; + let mut start = 0usize; + // Whether the current segment has anything but whitespace / comments. + let mut has_code = false; + let mut i = 0usize; + let push = |out: &mut Vec, s: usize, e: usize, has_code: bool| { + if !has_code { + return; + } + let seg = &text[byte_at(s)..byte_at(e)]; + let lead = seg.len() - seg.trim_start().len(); + let trimmed = seg.trim(); + if trimmed.is_empty() { + return; + } + let lead_chars = seg[..lead].chars().count(); + let len_chars = trimmed.chars().count(); + out.push(SqlStatement { + from: u16_at[s + lead_chars], + to: u16_at[s + lead_chars + len_chars], + text: trimmed.to_owned(), + }); + }; + while i < chars.len() { + let c = chars[i].1; + let next = chars.get(i + 1).map(|c| c.1); + match state { + State::Code => match c { + ';' => { + push(&mut out, start, i, has_code); + start = i + 1; + has_code = false; + } + '\'' => { + state = State::Single; + has_code = true; + } + '"' => { + state = State::Double; + has_code = true; + } + '`' if mysql => { + state = State::Backtick; + has_code = true; + } + '-' if next == Some('-') => { + state = State::LineComment; + i += 1; + } + '#' if mysql => state = State::LineComment, + '/' if next == Some('*') => { + state = State::BlockComment; + i += 1; + } + '$' if driver == DbDriver::Postgres => { + // `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes. + let rest = &text[byte_at(i)..]; + let tag_len = rest[1..].find('$').filter(|&n| { + rest[1..1 + n].chars().all(|c| c.is_alphanumeric() || c == '_') + && !rest[1..1 + n].starts_with(|c: char| c.is_ascii_digit()) + }); + has_code = true; + if let Some(n) = tag_len { + let tag = &rest[..n + 2]; + let body_start = byte_at(i) + tag.len(); + let close = text[body_start..].find(tag).map_or(text.len(), |p| body_start + p + tag.len()); + // Jump to the char at byte `close`. + while i < chars.len() && chars[i].0 < close { + i += 1; + } + continue; + } + } + c if !c.is_whitespace() => has_code = true, + _ => {} + }, + State::Single => { + if mysql && c == '\\' { + i += 1; + } else if c == '\'' { + if next == Some('\'') { + i += 1; + } else { + state = State::Code; + } + } + } + State::Double => { + if c == '"' { + if next == Some('"') { + i += 1; + } else { + state = State::Code; + } + } + } + State::Backtick => { + if c == '`' { + state = State::Code; + } + } + State::LineComment => { + if c == '\n' { + state = State::Code; + } + } + State::BlockComment => { + if c == '*' && next == Some('/') { + state = State::Code; + i += 1; + } + } + } + i += 1; + } + push(&mut out, start, chars.len(), has_code); + out +} + +/// The statement to run for a caret at `offset` (UTF-16): the one containing +/// it, else the nearest one before it (caret after `;` or on a blank line +/// below), else the next one. +pub fn statement_at(text: &str, offset: u32, driver: DbDriver) -> Option { + let all = split(text, driver); + if let Some(s) = all.iter().find(|s| s.from <= offset && offset <= s.to) { + return Some(s.clone()); + } + // Between statements: the previous one when only whitespace / `;` separates it from the caret. + let prev = all.iter().rev().find(|s| s.to <= offset); + let next = all.iter().find(|s| s.from > offset); + match (prev, next) { + (Some(p), Some(n)) => { + let gap = utf16_slice(text, p.to, offset); + let blank_lines = gap.matches('\n').count(); + if blank_lines <= 1 { Some(p.clone()) } else { Some(n.clone()) } + } + (Some(p), None) => Some(p.clone()), + (None, n) => n.cloned(), + } +} + +/// Statements overlapping the UTF-16 range `from..to` (a selection): the +/// selected text itself, split. +pub fn statements_in(text: &str, from: u32, to: u32, driver: DbDriver) -> Vec { + let selected = utf16_slice(text, from, to); + split(selected, driver).into_iter().map(|s| SqlStatement { from: s.from + from, to: s.to + from, ..s }).collect() +} + +fn utf16_slice(text: &str, from: u32, to: u32) -> &str { + let mut u = 0u32; + let (mut a, mut b) = (text.len(), text.len()); + for (i, c) in text.char_indices() { + if u == from && a == text.len() { + a = i; + } + if u == to { + b = i; + break; + } + u += c.len_utf16() as u32; + } + if a > b { "" } else { &text[a..b] } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn texts(sql: &str, d: DbDriver) -> Vec { + split(sql, d).into_iter().map(|s| s.text).collect() + } + + #[test] + fn splits_on_semicolons_outside_literals_and_comments() { + let sql = "select 'a;b' as x; -- c;d\nselect \"x;y\" from t /* ; */;\n\n-- only a comment;\n"; + assert_eq!(texts(sql, DbDriver::Sqlite), vec!["select 'a;b' as x", "-- c;d\nselect \"x;y\" from t /* ; */"]); + } + + #[test] + fn mysql_backticks_hashes_and_backslashes() { + let sql = "select `a;b`, 'it\\'s;' from t # x;y\n; select 2"; + assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]); + } + + #[test] + fn postgres_dollar_quotes() { + let sql = + "create function f() returns int as $$ select 1; $$ language sql; select $1::int; do $x$ begin; end $x$"; + assert_eq!( + texts(sql, DbDriver::Postgres), + vec![ + "create function f() returns int as $$ select 1; $$ language sql", + "select $1::int", + "do $x$ begin; end $x$" + ] + ); + } + + #[test] + fn utf16_offsets() { + let sql = "select '😀';\nselect 2;"; + let s = split(sql, DbDriver::Sqlite); + assert_eq!((s[0].from, s[0].to), (0, 11)); + assert_eq!((s[1].from, s[1].to), (13, 21)); + } + + #[test] + fn statement_at_caret() { + let sql = "select 1;\nselect 2;\n\n\nselect 3"; + let at = |o| statement_at(sql, o, DbDriver::Sqlite).map(|s| s.text); + assert_eq!(at(0).as_deref(), Some("select 1")); + assert_eq!(at(9).as_deref(), Some("select 1"), "right after ;"); + assert_eq!(at(12).as_deref(), Some("select 2")); + assert_eq!(at(20).as_deref(), Some("select 2"), "end of line after ;"); + assert_eq!(at(21).as_deref(), Some("select 3"), "after blank lines, the next one"); + assert_eq!(at(30).as_deref(), Some("select 3")); + assert_eq!(statement_at(" ", 1, DbDriver::Sqlite), None); + } + + #[test] + fn selection() { + let sql = "x; select 1; select 2; y"; + let s = statements_in(sql, 3, 21, DbDriver::Sqlite); + assert_eq!(s.iter().map(|s| s.text.as_str()).collect::>(), vec!["select 1", "select 2"]); + assert_eq!((s[1].from, s[1].to), (13, 21)); + } +} diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs new file mode 100644 index 00000000..f76dfb0a --- /dev/null +++ b/crates/ide-db/src/sql.rs @@ -0,0 +1,477 @@ +//! SQL text generation per dialect: quoting, introspection queries, table +//! pages and row edits. Pure functions, so they are unit-tested without a server. + +use crate::DbDriver; +use serde::{Deserialize, Serialize}; +use specta::Type; + +pub fn quote_ident(driver: DbDriver, name: &str) -> String { + match driver { + DbDriver::Mysql => format!("`{}`", name.replace('`', "``")), + _ => format!("\"{}\"", name.replace('"', "\"\"")), + } +} + +pub fn quote_literal(driver: DbDriver, value: &str) -> String { + let mut s = value.replace('\'', "''"); + if driver == DbDriver::Mysql { + s = s.replace('\\', "\\\\"); + } + format!("'{s}'") +} + +fn literal_or_null(driver: DbDriver, v: Option<&str>) -> String { + v.map_or_else(|| "NULL".to_owned(), |v| quote_literal(driver, v)) +} + +pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String { + if schema.is_empty() { + quote_ident(driver, table) + } else { + format!("{}.{}", quote_ident(driver, schema), quote_ident(driver, table)) + } +} + +/// Databases one can connect to (PostgreSQL). +pub const DATABASES: &str = "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 { + match driver { + DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq", + DbDriver::Postgres => { + "SELECT schema_name FROM information_schema.schemata \ + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ + AND schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1" + } + DbDriver::Mysql => { + "SELECT schema_name FROM information_schema.schemata \ + WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1" + } + } +} + +/// Rows: `name, kind` where kind is `table` or `view`. +pub fn tables(driver: DbDriver, schema: &str) -> String { + match driver { + DbDriver::Sqlite => format!( + "SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \ + AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name", + quote_ident(driver, schema) + ), + _ => 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) + ), + } +} + +/// Rows: `name, type, nullable (YES/NO or 0/1), default, is_pk (0/1)`. +pub fn columns(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, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \ + CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid" + ), + DbDriver::Postgres => format!( + "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \ + CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \ + AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \ + AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END \ + FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \ + ORDER BY c.ordinal_position" + ), + DbDriver::Mysql => format!( + "SELECT column_name, column_type, is_nullable, column_default, \ + CASE WHEN column_key = 'PRI' THEN 1 ELSE 0 END \ + FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \ + ORDER BY ordinal_position" + ), + } +} + +/// Rows: `index name, unique (0/1), column` (one row per indexed column, in order). +pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String { + let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table)); + match driver { + DbDriver::Sqlite => format!( + "SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \ + JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno" + ), + DbDriver::Postgres => format!( + "SELECT i.relname, CASE WHEN ix.indisunique THEN 1 ELSE 0 END, a.attname \ + FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid \ + JOIN pg_namespace n ON n.oid = t.relnamespace \ + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON true \ + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum \ + WHERE n.nspname = {s} AND t.relname = {t} ORDER BY i.relname, k.ord" + ), + DbDriver::Mysql => format!( + "SELECT index_name, CASE WHEN non_unique = 0 THEN 1 ELSE 0 END, column_name \ + FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \ + ORDER BY index_name, seq_in_index" + ), + } +} + +/// Rows: `schema, table, kind (table/view), column, type, nullable (YES/NO), is_pk (0/1), has_default (0/1)` +/// for SQL completion, grouped by table in column order. +pub fn completion_columns(driver: DbDriver) -> &'static str { + match driver { + DbDriver::Sqlite => { + "SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \ + CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \ + CASE WHEN p.dflt_value IS NOT NULL THEN 1 \ + WHEN p.pk = 1 AND upper(p.type) = 'INTEGER' \ + AND (SELECT count(*) FROM pragma_table_info(m.name) k WHERE k.pk > 0) = 1 THEN 1 ELSE 0 END \ + FROM sqlite_master m JOIN pragma_table_info(m.name) p \ + 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, \ + 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, \ + CASE WHEN a.atthasdef OR a.attidentity <> '' THEN 1 ELSE 0 END \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE c.relkind IN ('r', 'v', 'm', 'p', 'f') AND a.attnum > 0 AND NOT a.attisdropped \ + AND n.nspname NOT IN ('pg_catalog', 'information_schema') \ + AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%' \ + ORDER BY n.nspname, c.relname, a.attnum LIMIT 50000" + } + DbDriver::Mysql => { + "SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \ + c.column_name, c.column_type, c.is_nullable, CASE WHEN c.column_key = 'PRI' THEN 1 ELSE 0 END, \ + CASE WHEN c.column_default IS NOT NULL OR c.extra LIKE '%auto_increment%' THEN 1 ELSE 0 END \ + FROM information_schema.columns c JOIN information_schema.tables t \ + ON t.table_schema = c.table_schema AND t.table_name = c.table_name \ + WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \ + ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000" + } + } +} + +/// Rows: `schema, table, constraint, column, ref schema, ref table, ref column` +/// (one row per column pair, in key order). SQLite leaves the ref column NULL +/// when the key references the primary key implicitly. +pub fn foreign_keys(driver: DbDriver) -> &'static str { + match driver { + DbDriver::Sqlite => { + "SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \ + FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \ + WHERE m.type = 'table' ORDER BY m.name, f.id, f.seq" + } + DbDriver::Postgres => { + "SELECT sn.nspname, sc.relname, con.conname, sa.attname, tn.nspname, tc.relname, ta.attname \ + FROM pg_constraint con \ + JOIN pg_class sc ON sc.oid = con.conrelid JOIN pg_namespace sn ON sn.oid = sc.relnamespace \ + JOIN pg_class tc ON tc.oid = con.confrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace \ + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(s, t, ord) \ + JOIN pg_attribute sa ON sa.attrelid = sc.oid AND sa.attnum = k.s \ + JOIN pg_attribute ta ON ta.attrelid = tc.oid AND ta.attnum = k.t \ + WHERE con.contype = 'f' AND sn.nspname NOT IN ('pg_catalog', 'information_schema') \ + ORDER BY 1, 2, 3, k.ord LIMIT 20000" + } + DbDriver::Mysql => { + "SELECT table_schema, table_name, constraint_name, column_name, \ + referenced_table_schema, referenced_table_name, referenced_column_name \ + FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \ + AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \ + ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000" + } + } +} + +/// [`foreign_keys`] rows of one table. +pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String { + let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table)); + match driver { + DbDriver::Sqlite => format!( + "SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq" + ), + DbDriver::Postgres => format!( + "SELECT sn.nspname, sc.relname, con.conname, sa.attname, tn.nspname, tc.relname, ta.attname FROM pg_constraint con JOIN pg_class sc ON sc.oid = con.conrelid JOIN pg_namespace sn ON sn.oid = sc.relnamespace JOIN pg_class tc ON tc.oid = con.confrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(s, t, ord) JOIN pg_attribute sa ON sa.attrelid = sc.oid AND sa.attnum = k.s JOIN pg_attribute ta ON ta.attrelid = tc.oid AND ta.attnum = k.t WHERE con.contype = 'f' AND sn.nspname = {s} AND sc.relname = {t} ORDER BY 3, k.ord" + ), + DbDriver::Mysql => format!( + "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position" + ), + } +} + +/// The schema unqualified names resolve to (`None`: always `main`). +pub fn current_schema(driver: DbDriver) -> Option<&'static str> { + match driver { + DbDriver::Sqlite => None, + DbDriver::Postgres => Some("SELECT current_schema()"), + DbDriver::Mysql => Some("SELECT DATABASE()"), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbSortKey")] +pub struct SortKey { + pub column: String, + pub descending: bool, +} + +/// One page of a table: `limit + 1` rows so the caller knows whether more exist. +pub fn table_page( + driver: DbDriver, + schema: &str, + table: &str, + filter: Option<&str>, + sort: &[SortKey], + offset: u64, + limit: u64, +) -> String { + let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table)); + if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) { + let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f); + sql.push_str(&format!(" WHERE {f}")); + } + if !sort.is_empty() { + let keys: Vec = sort + .iter() + .map(|k| format!("{}{}", quote_ident(driver, &k.column), if k.descending { " DESC" } else { "" })) + .collect(); + sql.push_str(&format!(" ORDER BY {}", keys.join(", "))); + } + sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1)); + sql +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbCellValue")] +pub struct CellValue { + pub column: String, + /// `None` is SQL NULL. + pub value: Option, +} + +/// A pending change of the table editor. `key` identifies the row: its primary +/// key columns with their original values. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum DbRowEdit { + Update { key: Vec, values: Vec }, + Insert { values: Vec }, + Delete { key: Vec }, +} + +fn where_key(driver: DbDriver, key: &[CellValue]) -> String { + key.iter() + .map(|c| match &c.value { + Some(v) => format!("{} = {}", quote_ident(driver, &c.column), quote_literal(driver, v)), + None => format!("{} IS NULL", quote_ident(driver, &c.column)), + }) + .collect::>() + .join(" AND ") +} + +/// The DML statement for `edit`. Values are written as quoted literals, which +/// every supported database coerces to the column type. +pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result { + let target = qualified(driver, schema, table); + match edit { + DbRowEdit::Update { key, values } => { + if key.is_empty() { + return Err("rows can only be updated in tables with a primary key".into()); + } + if values.is_empty() { + return Err("nothing to update".into()); + } + let set: Vec = values + .iter() + .map(|c| { + format!("{} = {}", quote_ident(driver, &c.column), literal_or_null(driver, c.value.as_deref())) + }) + .collect(); + Ok(format!("UPDATE {target} SET {} WHERE {}", set.join(", "), where_key(driver, key))) + } + DbRowEdit::Insert { values } => { + if values.is_empty() { + return Ok(match driver { + DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"), + _ => format!("INSERT INTO {target} DEFAULT VALUES"), + }); + } + let cols: Vec = values.iter().map(|c| quote_ident(driver, &c.column)).collect(); + let vals: Vec = values.iter().map(|c| literal_or_null(driver, c.value.as_deref())).collect(); + Ok(format!("INSERT INTO {target} ({}) VALUES ({})", cols.join(", "), vals.join(", "))) + } + DbRowEdit::Delete { key } => { + if key.is_empty() { + return Err("rows can only be deleted in tables with a primary key".into()); + } + Ok(format!("DELETE FROM {target} WHERE {}", where_key(driver, key))) + } + } +} + +/// Rows: `sql` — the statements that created a SQLite table or view, then its indexes and triggers. +pub fn sqlite_ddl(schema: &str, name: &str) -> String { + format!( + "SELECT sql FROM {}.sqlite_master WHERE tbl_name = {} AND sql IS NOT NULL \ + ORDER BY CASE type WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 ELSE 0 END, name", + quote_ident(DbDriver::Sqlite, schema), + quote_literal(DbDriver::Sqlite, name) + ) +} + +/// Rows: `oid, relkind` of a PostgreSQL relation. +pub fn postgres_relation(schema: &str, name: &str) -> String { + format!( + "SELECT c.oid::text, c.relkind::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = {} AND c.relname = {}", + quote_literal(DbDriver::Postgres, schema), + quote_literal(DbDriver::Postgres, name) + ) +} + +/// Rows: `name, type, not null (0/1), default` of relation `oid`. +pub fn postgres_ddl_columns(oid: u32) -> String { + format!( + "SELECT a.attname, format_type(a.atttypid, a.atttypmod), CASE WHEN a.attnotnull THEN 1 ELSE 0 END, \ + pg_get_expr(d.adbin, d.adrelid) FROM pg_attribute a \ + LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum \ + WHERE a.attrelid = {oid} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum" + ) +} + +/// Rows: `name, definition` of the constraints of relation `oid` (primary key first). +pub fn postgres_ddl_constraints(oid: u32) -> String { + format!( + "SELECT conname, pg_get_constraintdef(oid, true) FROM pg_constraint WHERE conrelid = {oid} \ + ORDER BY CASE contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'c' THEN 2 WHEN 'f' THEN 3 ELSE 4 END, conname" + ) +} + +/// Rows: `CREATE INDEX …` of relation `oid`, except the indexes behind constraints. +pub fn postgres_ddl_indexes(oid: u32) -> String { + format!( + "SELECT pg_get_indexdef(i.indexrelid) FROM pg_index i WHERE i.indrelid = {oid} \ + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid AND c.conrelid = i.indrelid) \ + ORDER BY 1" + ) +} + +/// A column of [`postgres_table_ddl`]. +pub struct DdlColumn { + pub name: String, + pub data_type: String, + pub not_null: bool, + pub default: Option, +} + +/// `create table` of a PostgreSQL table from its catalog (the server has no `SHOW CREATE`). +pub fn postgres_table_ddl( + schema: &str, + table: &str, + columns: &[DdlColumn], + constraints: &[(String, String)], + indexes: &[String], +) -> String { + let d = DbDriver::Postgres; + let mut items: Vec = columns + .iter() + .map(|c| { + let mut s = format!(" {} {}", quote_ident(d, &c.name), c.data_type); + if let Some(v) = &c.default { + s.push_str(&format!(" default {v}")); + } + if c.not_null { + s.push_str(" not null"); + } + s + }) + .collect(); + items.extend(constraints.iter().map(|(name, def)| format!(" constraint {} {def}", quote_ident(d, name)))); + let mut out = format!("create table {}\n(\n{}\n);\n", qualified(d, schema, table), items.join(",\n")); + for i in indexes { + out.push_str(&format!("\n{i};\n")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cv(c: &str, v: Option<&str>) -> CellValue { + CellValue { column: c.into(), value: v.map(Into::into) } + } + + #[test] + fn quoting() { + assert_eq!(quote_ident(DbDriver::Postgres, "a\"b"), "\"a\"\"b\""); + assert_eq!(quote_ident(DbDriver::Mysql, "a`b"), "`a``b`"); + assert_eq!(quote_literal(DbDriver::Sqlite, "it's \\"), "'it''s \\'"); + assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'"); + } + + #[test] + fn pages() { + let sort = [SortKey { column: "id".into(), descending: true }]; + assert_eq!( + table_page(DbDriver::Postgres, "public", "users", Some("WHERE age > 3"), &sort, 100, 50), + "SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100" + ); + assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0"); + } + + #[test] + fn edits() { + let key = vec![cv("id", Some("1"))]; + assert_eq!( + edit_statement( + DbDriver::Sqlite, + "main", + "t", + &DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("O'Brien")), cv("note", None)] } + ) + .unwrap(), + "UPDATE \"main\".\"t\" SET \"name\" = 'O''Brien', \"note\" = NULL WHERE \"id\" = '1'" + ); + assert_eq!( + edit_statement(DbDriver::Mysql, "db", "t", &DbRowEdit::Insert { values: vec![cv("a", Some("x"))] }) + .unwrap(), + "INSERT INTO `db`.`t` (`a`) VALUES ('x')" + ); + assert_eq!( + edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![cv("k", None)] }).unwrap(), + "DELETE FROM \"s\".\"t\" WHERE \"k\" IS NULL" + ); + assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err()); + } + + #[test] + fn postgres_ddl() { + let cols = [ + DdlColumn { name: "id".into(), data_type: "integer".into(), not_null: true, default: None }, + DdlColumn { + name: "name".into(), + data_type: "character varying(40)".into(), + not_null: false, + default: Some("'x'::character varying".into()), + }, + ]; + assert_eq!( + postgres_table_ddl( + "public", + "t", + &cols, + &[("t_pkey".into(), "PRIMARY KEY (id)".into())], + &["CREATE INDEX t_name ON public.t USING btree (name)".into()] + ), + "create table \"public\".\"t\"\n(\n \"id\" integer not null,\n \"name\" character varying(40) default 'x'::character varying,\n constraint \"t_pkey\" PRIMARY KEY (id)\n);\n\nCREATE INDEX t_name ON public.t USING btree (name);\n" + ); + } +} diff --git a/crates/ide-db/src/tunnel.rs b/crates/ide-db/src/tunnel.rs new file mode 100644 index 00000000..3959e4da --- /dev/null +++ b/crates/ide-db/src/tunnel.rs @@ -0,0 +1,474 @@ +//! 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(UnknownHostKey), + Other(IdeError), +} + +impl From 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>, + /// Sessions of the `ProxyJump` hosts, kept alive under `session`. + _jumps: Vec>, + accept: JoinHandle<()>, +} + +impl Tunnel { + /// Connects and starts listening on a free local port. + pub async fn open(spec: TunnelSpec<'_>) -> Result { + 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 { + let hops = resolve_hops(spec.ssh, spec.ssh_config)?; + let mut sessions: Vec> = Vec::new(); + for (i, hop) in hops.iter().enumerate() { + let target = i == hops.len() - 1; + let mut handle = match sessions.last() { + None => connect::(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>, 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) -> IdeError { + IdeError::new(ErrorKind::Io, message) +} + +// --- hosts --------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Hop { + host: String, + port: u16, + user: String, + identity_files: Vec, +} + +impl Hop { + fn label(&self) -> String { + format!("{}@{}:{}", self.user, self.host, self.port) + } +} + +fn home() -> Option { + 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) { + 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::().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> { + 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 = 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>>, +} + +impl client::Handler for Client { + type Error = russh::Error; + + async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result { + 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(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, 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(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) -> Option { + handle.best_supported_rsa_hash().await.ok().flatten().flatten() +} + +async fn with_key(handle: &mut Handle, user: &str, key: keys::PrivateKey) -> IdeResult { + 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, 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, + 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 = 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 = 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 { + 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 jump@gw.example.com: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 = hops.iter().map(Hop::label).collect(); + assert_eq!(labels, ["jump@gw.example.com:2200", "edgeuser@edge.example.com:22", "ops@10.0.0.5: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(), "me@10.0.0.5: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::>(), ["u@db-bastion:22"]); + assert!(resolve_hops(&DbSsh::default(), None).is_err()); + } +} diff --git a/crates/ide-db/tests/fixtures/ssh/client b/crates/ide-db/tests/fixtures/ssh/client new file mode 100644 index 00000000..d9c70019 --- /dev/null +++ b/crates/ide-db/tests/fixtures/ssh/client @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAv8/hYRcxIJgEjL+hPXcAy8mrA333fDz6N2ZFFh3NBfQAAAJASX7F0El+x +dAAAAAtzc2gtZWQyNTUxOQAAACAv8/hYRcxIJgEjL+hPXcAy8mrA333fDz6N2ZFFh3NBfQ +AAAEBSLd2Q2WN97Ah63Y8wkaXU3cXmp7c2cF4ekACcsO6Iui/z+FhFzEgmASMv6E9dwDLy +asDffd8PPo3ZkUWHc0F9AAAABmNsaWVudAECAwQFBgc= +-----END OPENSSH PRIVATE KEY----- diff --git a/crates/ide-db/tests/fixtures/ssh/host b/crates/ide-db/tests/fixtures/ssh/host new file mode 100644 index 00000000..66daa5cc --- /dev/null +++ b/crates/ide-db/tests/fixtures/ssh/host @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA0RkZCwoM8h1swUPrQEmT0Bc3epzST7Rbe7U284ktdzgAAAIh+eWzjfnls +4wAAAAtzc2gtZWQyNTUxOQAAACA0RkZCwoM8h1swUPrQEmT0Bc3epzST7Rbe7U284ktdzg +AAAEBEtrWyErO0+FkfXRdm+DtPkooHpHfeFNzbnlkrozs83zRGRkLCgzyHWzBQ+tASZPQF +zd6nNJPtFt7tTbziS13OAAAABGhvc3QB +-----END OPENSSH PRIVATE KEY----- diff --git a/crates/ide-db/tests/fixtures/ssh/locked b/crates/ide-db/tests/fixtures/ssh/locked new file mode 100644 index 00000000..826902ef --- /dev/null +++ b/crates/ide-db/tests/fixtures/ssh/locked @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABChbqZuKr +F8Geuv2FT6GBoiAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIJq7vUgGWDSrABg4 +LGP0MNJ06Lx0KP8WBbTKXYcxOVj2AAAAkE2bir9ZcCMTtvRvj2j6CuJEl2S1I1159pYIlT +s5X5sDATTmrvI/dpc4igbri027uAH7lt63X8MAdCuUuWg8KbI/oHUua3Dh8s+59OFcfyeB ++QaICI6bVo6gQB8eHSgNBV1CeRUjKjpi5hIMBN4V2MWC+N+OdPSpPZoGNII4PaL1JP3wEw +glVEcquI7WpRuVSQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs new file mode 100644 index 00000000..3dcc60b9 --- /dev/null +++ b/crates/ide-db/tests/sqlite.rs @@ -0,0 +1,248 @@ +//! 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}; + +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")); + db.save_sources(vec![DataSource { + id: "s1".into(), + name: "app".into(), + driver: DbDriver::Sqlite, + file: Some("app.db".into()), + ..Default::default() + }]) + .await + .unwrap(); + let c = CancellationToken::new(); + for stmt in [ + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, note TEXT, avatar BLOB)", + "CREATE UNIQUE INDEX users_name ON users (name)", + "CREATE VIEW named AS SELECT name FROM users", + "INSERT INTO users (name, note, avatar) VALUES ('ann', NULL, x'CAFE'), ('bob', 'hi', NULL), ('cy', 'x', NULL)", + ] { + db.execute("s1", stmt, 100, &c).await.unwrap(); + } + (tmp, db) +} + +#[tokio::test] +async fn introspects_schemas_tables_columns_and_indexes() -> IdeResult<()> { + let (_tmp, db) = setup().await; + assert_eq!(db.schemas("s1").await?, vec!["main"]); + let tables = db.tables("s1", "main").await?; + let names: Vec<_> = tables.iter().map(|t| (t.name.as_str(), t.kind)).collect(); + assert_eq!(names, vec![("named", DbTableKind::View), ("users", DbTableKind::Table)]); + let d = db.table_details("s1", "main", "users").await?; + let cols: Vec<_> = d.columns.iter().map(|c| (c.name.as_str(), c.primary_key, c.nullable)).collect(); + assert_eq!(cols, vec![("id", true, true), ("name", false, false), ("note", false, true), ("avatar", false, true)]); + assert_eq!(d.indexes.len(), 1); + assert_eq!(d.indexes[0].name, "users_name"); + assert!(d.indexes[0].unique); + assert_eq!(d.indexes[0].columns, vec!["name"]); + let completion = db.completion("s1").await?; + assert_eq!(completion.default_schema, "main"); + let users = completion.tables.iter().find(|t| t.name == "users").unwrap(); + let cols: Vec<_> = users.columns.iter().map(|c| (c.name.as_str(), c.data_type.as_str(), c.primary_key)).collect(); + assert_eq!( + cols, + vec![("id", "INTEGER", true), ("name", "TEXT", false), ("note", "TEXT", false), ("avatar", "BLOB", false)] + ); + assert!(!users.columns[1].nullable); + // `INTEGER PRIMARY KEY` is the rowid: SQLite assigns it. + let defaults: Vec<_> = users.columns.iter().map(|c| c.has_default).collect(); + assert_eq!(defaults, vec![true, false, false, false]); + let named = completion.tables.iter().find(|t| t.name == "named").unwrap(); + assert_eq!(named.kind, DbTableKind::View); + Ok(()) +} + +#[tokio::test] +async fn completion_model_has_foreign_keys() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + for stmt in [ + "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id), status TEXT DEFAULT 'new')", + // Implicit reference to the primary key; composite key. + "CREATE TABLE notes (user_id INTEGER REFERENCES users, a INT, b INT, \ + FOREIGN KEY (a, b) REFERENCES pairs (x, y))", + "CREATE TABLE pairs (x INT, y INT, PRIMARY KEY (x, y))", + ] { + db.execute("s1", stmt, 1, &c).await?; + } + let m = db.completion("s1").await?; + let orders = m.tables.iter().find(|t| t.name == "orders").unwrap(); + let defaults: Vec<_> = orders.columns.iter().map(|c| (c.name.as_str(), c.has_default)).collect(); + assert_eq!(defaults, vec![("id", true), ("user_id", false), ("status", true)]); + let pairs = m.tables.iter().find(|t| t.name == "pairs").unwrap(); + assert!(pairs.columns.iter().all(|c| !c.has_default)); + let keys: Vec<_> = m + .foreign_keys + .iter() + .map(|k| (k.table.as_str(), k.columns.join(","), k.ref_table.as_str(), k.ref_columns.join(","))) + .collect(); + assert_eq!( + keys, + vec![ + ("notes", "a,b".into(), "pairs", "x,y".into()), + ("notes", "user_id".into(), "users", "id".into()), + ("orders", "user_id".into(), "users", "id".into()), + ] + ); + let d = db.table_details("s1", "main", "notes").await?; + let keys: Vec<_> = + d.foreign_keys.iter().map(|k| (k.columns.join(","), k.ref_table.as_str(), k.ref_columns.join(","))).collect(); + assert_eq!(keys, vec![("a,b".into(), "pairs", "x,y".into()), ("user_id".into(), "users", "id".into())]); + Ok(()) +} + +#[tokio::test] +async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + let r = db.execute("s1", "SELECT id, name, note, avatar FROM users ORDER BY id", 2, &c).await?; + assert_eq!(r.columns.iter().map(|c| c.name.as_str()).collect::>(), vec!["id", "name", "note", "avatar"]); + assert_eq!(r.rows.len(), 2); + assert!(r.has_more); + assert_eq!(r.rows[0], vec![Some("1".into()), Some("ann".into()), None, Some("0xCAFE".into())]); + assert_eq!(r.affected, None); + + let empty = db.execute("s1", "SELECT name FROM users WHERE id < 0", 10, &c).await?; + assert!(empty.rows.is_empty()); + assert_eq!(empty.columns.len(), 1, "columns of an empty result come from the prepared statement"); + + let upd = db.execute("s1", "UPDATE users SET note = 'n' WHERE id > 1", 10, &c).await?; + assert_eq!(upd.affected, Some(2.0)); + + let err = db.execute("s1", "SELECT nope FROM users", 10, &c).await.unwrap_err(); + assert!(err.message.contains("nope"), "{}", err.message); + Ok(()) +} + +#[tokio::test] +async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + let sort = [SortKey { column: "name".into(), descending: true }]; + let page = db.table_page("s1", "main", "users", Some("id > 0"), &sort, 0, 2, &c).await?; + assert_eq!(page.result.rows.len(), 2); + assert!(page.result.has_more); + assert_eq!(page.result.rows[0][1].as_deref(), Some("cy")); + let last = db.table_page("s1", "main", "users", None, &sort, 2, 2, &c).await?; + assert_eq!(last.result.rows.len(), 1); + assert!(!last.result.has_more); + + let key = |id: &str| vec![CellValue { column: "id".into(), value: Some(id.into()) }]; + let n = db + .apply_edits( + "s1", + "main", + "users", + &[ + DbRowEdit::Update { + key: key("1"), + values: vec![CellValue { column: "note".into(), value: Some("it's".into()) }], + }, + DbRowEdit::Delete { key: key("2") }, + DbRowEdit::Insert { values: vec![CellValue { column: "name".into(), value: Some("dee".into()) }] }, + ], + ) + .await?; + assert_eq!(n, 3); + let r = db.execute("s1", "SELECT name, note FROM users ORDER BY id", 10, &c).await?; + let rows: Vec<_> = r.rows.iter().map(|r| (r[0].clone().unwrap(), r[1].clone())).collect(); + assert_eq!(rows, vec![("ann".into(), Some("it's".into())), ("cy".into(), Some("x".into())), ("dee".into(), None)]); + + // A failing edit rolls back the whole batch. + let err = db + .apply_edits( + "s1", + "main", + "users", + &[ + DbRowEdit::Delete { key: key("1") }, + DbRowEdit::Insert { values: vec![CellValue { column: "name".into(), value: Some("cy".into()) }] }, + ], + ) + .await; + assert!(err.is_err()); + let count = db.execute("s1", "SELECT count(*) FROM users", 10, &c).await?; + assert_eq!(count.rows[0][0].as_deref(), Some("3")); + Ok(()) +} + +#[tokio::test] +async fn test_connection_and_disconnect() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let source = db.source("s1")?; + assert!(db.test(&source, None, None).await?.starts_with("SQLite ")); + assert!(db.is_connected("s1")); + db.disconnect("s1").await; + assert!(!db.is_connected("s1")); + let missing = DataSource { file: Some("missing/nope.db".into()), ..source }; + assert!(db.test(&missing, None, None).await.is_err()); + Ok(()) +} + +async fn count(db: &DbManager) -> String { + let r = db.execute("s1", "SELECT count(*) FROM users", 1, &CancellationToken::new()).await.unwrap(); + r.rows[0][0].clone().unwrap() +} + +#[tokio::test] +async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + db.set_manual_commit("s1", true).await; + db.execute("s1", "INSERT INTO users (name) VALUES ('dee')", 1, &c).await?; + // The session sees its own change. + assert_eq!(count(&db).await, "4"); + db.finish_transaction("s1", false).await?; + assert_eq!(count(&db).await, "3"); + + db.execute("s1", "INSERT INTO users (name) VALUES ('eve')", 1, &c).await?; + db.finish_transaction("s1", true).await?; + // Leaving manual mode rolls back what is still open. + db.execute("s1", "DELETE FROM users", 1, &c).await?; + db.set_manual_commit("s1", false).await; + assert_eq!(count(&db).await, "4"); + // Disconnecting with an open transaction does not hang. + db.set_manual_commit("s1", true).await; + db.execute("s1", "DELETE FROM users", 1, &c).await?; + db.disconnect("s1").await; + assert_eq!(count(&db).await, "4"); + Ok(()) +} + +#[tokio::test] +async fn targets_name_a_database_of_a_data_source() -> IdeResult<()> { + let (_tmp, db) = setup().await; + assert_eq!(ide_db::split_target("s1"), ("s1", None)); + assert_eq!(ide_db::split_target("s1:app_dev"), ("s1", Some("app_dev"))); + assert_eq!(ide_db::split_target("s1:a:b"), ("s1", Some("a:b"))); + assert_eq!(ide_db::split_target("s1:"), ("s1", None)); + assert_eq!(db.source("s1:other")?.database, "other"); + assert_eq!(db.source("s1:other")?.id, "s1"); + assert!(db.source("nope:other").is_err()); + // Only PostgreSQL lists databases. + assert!(db.databases("s1").await.is_err()); + db.disconnect_source("s1").await; + assert!(db.connected().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn ddl_of_tables_and_views() -> IdeResult<()> { + let (_tmp, db) = setup().await; + assert_eq!( + db.ddl("s1", "main", "users").await?, + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, note TEXT, avatar BLOB);\n\n\ + CREATE UNIQUE INDEX users_name ON users (name);\n" + ); + assert_eq!(db.ddl("s1", "main", "named").await?, "CREATE VIEW named AS SELECT name FROM users;\n"); + assert!(db.ddl("s1", "main", "nope").await.is_err()); + Ok(()) +} diff --git a/crates/ide-db/tests/ssh_tunnel.rs b/crates/ide-db/tests/ssh_tunnel.rs new file mode 100644 index 00000000..ccfa5189 --- /dev/null +++ b/crates/ide-db/tests/ssh_tunnel.rs @@ -0,0 +1,226 @@ +//! 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) -> Sshd { + self.clone() + } +} + +impl server::Handler for Sshd { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) + } + + async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result { + 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, + 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::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 { + 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) -> 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); +} diff --git a/crates/ide-docker/Cargo.toml b/crates/ide-docker/Cargo.toml new file mode 100644 index 00000000..e88de986 --- /dev/null +++ b/crates/ide-docker/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ide-docker" +description = "Docker integration for the Services tool window, driven through the docker CLI" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tracing.workspace = true +which = "8" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs new file mode 100644 index 00000000..63c6d359 --- /dev/null +++ b/crates/ide-docker/src/lib.rs @@ -0,0 +1,361 @@ +//! Docker for the Services tool window, driven through the `docker` CLI (like git +//! is driven through its CLI): listing with `--format '{{json .}}'`, container +//! lifecycle, logs and `docker events` streams, pulls and prunes. +//! +//! Every function blocks; callers run them off the UI / command threads. + +mod model; +mod process; + +pub use model::*; + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use process::Stream; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::Duration; + +/// Listing and lifecycle commands normally answer in well under a second; a +/// wedged daemon must not keep a worker thread forever. +const QUICK: Duration = Duration::from_secs(20); +/// Stop / restart wait for the container's grace period (10 s by default). +const LIFECYCLE: Duration = Duration::from_secs(90); + +/// Locates the docker executable once per process. GUI apps on macOS start with a +/// minimal `PATH`, so the usual install locations are probed after the lookup. +pub fn docker_executable() -> Option { + static DOCKER: OnceLock> = OnceLock::new(); + DOCKER + .get_or_init(|| { + if let Some(explicit) = std::env::var_os("OPENIDEA_DOCKER").map(PathBuf::from).filter(|p| p.is_file()) { + return Some(explicit); + } + if let Ok(found) = which::which("docker") { + return Some(found); + } + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from); + let mut candidates: Vec = if cfg!(windows) { + vec![PathBuf::from(r"C:\Program Files\Docker\Docker\resources\bin\docker.exe")] + } else { + [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + "/usr/bin/docker", + "/snap/bin/docker", + ] + .iter() + .map(PathBuf::from) + .collect() + }; + if let Some(home) = home.filter(|_| !cfg!(windows)) { + candidates.push(home.join(".orbstack/bin/docker")); + candidates.push(home.join(".rd/bin/docker")); + candidates.push(home.join(".docker/bin/docker")); + } + candidates.into_iter().find(|p| p.is_file()) + }) + .clone() +} + +fn binary() -> IdeResult { + 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 { + let mut cmd = Command::new(binary()?); + 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 { + let out = process::run(command(args)?, timeout, None)?; + if out.success { Ok(out.stdout) } else { Err(failure(args, &out.stderr, &out.stdout)) } +} + +fn failure(args: &[&str], stderr: &str, stdout: &str) -> IdeError { + let msg = [stderr, stdout].iter().map(|s| s.trim()).find(|s| !s.is_empty()).unwrap_or_default(); + let msg = msg.strip_prefix("Error response from daemon: ").unwrap_or(msg).to_owned(); + let msg = if msg.is_empty() { format!("docker {} failed", args.first().copied().unwrap_or_default()) } else { msg }; + let lower = msg.to_ascii_lowercase(); + if lower.contains("no such") || lower.contains("not found") { + IdeError::not_found(msg) + } else if lower.contains("cannot connect to the docker daemon") || lower.contains("is the docker daemon running") { + IdeError::unavailable(msg) + } else { + IdeError::internal(msg) + } +} + +/// Whether docker can be used right now. +pub fn availability() -> DockerAvailability { + let Ok(cmd) = command(&["version", "--format", "{{json .}}"]) else { + return DockerAvailability::NotInstalled; + }; + match process::run(cmd, Duration::from_secs(15), None) { + Ok(out) => parse_version(&out.stdout, &out.stderr, out.success), + Err(e) => DockerAvailability::DaemonDown { message: e.message, context: None }, + } +} + +/// Availability plus containers, images, volumes and networks (listed in parallel). +pub fn snapshot() -> DockerSnapshot { + let availability = availability(); + let executable = docker_executable().map(|p| ide_core::ipc_path::to_ipc(&p)); + let mut snap = DockerSnapshot { + availability, + executable, + containers: Vec::new(), + images: Vec::new(), + volumes: Vec::new(), + networks: Vec::new(), + errors: Vec::new(), + }; + 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 join = |h: std::thread::ScopedJoinHandle<'_, IdeResult>| { + h.join().unwrap_or_else(|_| Err(IdeError::internal("docker listing panicked"))) + }; + (join(c), join(i), join(v), join(n)) + }); + let mut take = |r: IdeResult, what: &str| match r { + Ok(text) => Some(text), + Err(e) => { + snap.errors.push(format!("{what}: {}", e.message)); + None + } + }; + if let Some(t) = take(containers, "Containers") { + snap.containers = parse_containers(&t); + } + if let Some(t) = take(images, "Images") { + snap.images = parse_images(&t); + } + if let Some(t) = take(volumes, "Volumes") { + snap.volumes = parse_volumes(&t); + } + if let Some(t) = take(networks, "Networks") { + snap.networks = parse_networks(&t); + } + snap +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ContainerOp { + Start, + Stop, + Restart, + Pause, + Unpause, + Kill, +} + +impl ContainerOp { + fn verb(self) -> &'static str { + match self { + Self::Start => "start", + Self::Stop => "stop", + Self::Restart => "restart", + Self::Pause => "pause", + Self::Unpause => "unpause", + Self::Kill => "kill", + } + } +} + +/// Starts / stops / … the containers `ids`. +pub fn container_op(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) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DockerObject { + Container, + Image, + Volume, + Network, +} + +impl DockerObject { + fn noun(self) -> &'static str { + match self { + Self::Container => "container", + Self::Image => "image", + Self::Volume => "volume", + Self::Network => "network", + } + } +} + +/// `docker rm` (`force`: stop running containers / untag used images first). +pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> { + if ids.is_empty() { + return Ok(()); + } + let mut args = vec![object.noun(), "rm"]; + if force && matches!(object, DockerObject::Container | DockerObject::Image | DockerObject::Volume) { + args.push("--force"); + } + args.extend(ids.iter().map(String::as_str)); + run(&args, LIFECYCLE).map(drop) +} + +/// `docker inspect` of one object, pretty-printed. +pub fn inspect(object: DockerObject, id: &str) -> IdeResult { + let out = run(&["inspect", "--type", object.noun(), id], QUICK)?; + match serde_json::from_str::(&out) { + // A one-element array for a single id: show the object itself. + Ok(serde_json::Value::Array(mut items)) if items.len() == 1 => { + Ok(serde_json::to_string_pretty(&items.remove(0)).unwrap_or(out)) + } + Ok(v) => Ok(serde_json::to_string_pretty(&v).unwrap_or(out)), + Err(_) => Ok(out), + } +} + +/// Streams a container's logs (`tail` last lines, then follows while `follow`) +/// until it ends or `cancel` fires. +pub fn logs( + id: &str, + tail: u32, + follow: bool, + timestamps: bool, + cancel: &CancellationToken, + on_line: &dyn Fn(DockerLogLine), +) -> IdeResult<()> { + let tail = tail.to_string(); + let mut args = vec!["logs", "--tail", &tail]; + if follow { + args.push("--follow"); + } + if timestamps { + args.push("--timestamps"); + } + args.push(id); + let (ok, stderr) = process::stream(command(&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<()> { + let args = ["events", "--format", "{{json .}}"]; + let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, line| { + if stream == Stream::Stdout + && let Some(e) = parse_event(&line) + { + on_event(e); + } + })?; + if ok { Ok(()) } else { Err(failure(&args, &stderr.join("\n"), "")) } +} + +/// 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<()> { + on_output(format!("docker {}", args.join(" "))); + let (ok, stderr) = process::stream(command(args)?, cancel, &mut |_, line| { + if !line.trim().is_empty() { + on_output(line); + } + })?; + if ok { Ok(()) } else { Err(failure(args, &stderr.join("\n"), "")) } +} + +/// `docker pull `. +pub fn pull(reference: &str, 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) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PruneTarget { + Containers, + /// Dangling images only. + Images, + /// Every image no container uses. + AllImages, + Volumes, + Networks, + /// Stopped containers, unused networks, dangling images and build cache. + System, +} + +/// `docker prune --force`. +pub fn prune(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"], + PruneTarget::AllImages => &["image", "prune", "--all", "--force"], + PruneTarget::Volumes => &["volume", "prune", "--force"], + PruneTarget::Networks => &["network", "prune", "--force"], + PruneTarget::System => &["system", "prune", "--force"], + }; + run_streaming(args, cancel, on_output) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ComposeOp { + Start, + Stop, + Restart, + /// Stops and removes the project's containers and networks. + Down, +} + +/// Runs `docker compose -p ` for a project found through container +/// 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( + project: &str, + files: &[String], + working_dir: Option<&str>, + op: ComposeOp, + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult<()> { + let mut args: Vec<&str> = vec!["compose", "--project-name", project]; + let existing: Vec<&String> = files.iter().filter(|f| Path::new(f).is_file()).collect(); + if !existing.is_empty() && existing.len() == files.len() { + for f in existing { + args.push("--file"); + args.push(f); + } + if let Some(dir) = working_dir.filter(|d| Path::new(d).is_dir()) { + args.push("--project-directory"); + args.push(dir); + } + } + args.push(match op { + ComposeOp::Start => "start", + ComposeOp::Stop => "stop", + ComposeOp::Restart => "restart", + ComposeOp::Down => "down", + }); + run_streaming(&args, cancel, on_output) +} diff --git a/crates/ide-docker/src/model.rs b/crates/ide-docker/src/model.rs new file mode 100644 index 00000000..d803ef7a --- /dev/null +++ b/crates/ide-docker/src/model.rs @@ -0,0 +1,461 @@ +//! IPC types and parsers for the docker CLI's `--format '{{json .}}'` output +//! (one JSON object per line). + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; + +/// Whether docker can be used, and why not. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum DockerAvailability { + Ready { + client_version: String, + server_version: String, + /// Active docker context (`default`, `orbstack`, `desktop-linux`…). + context: Option, + }, + /// No `docker` executable was found. + NotInstalled, + /// The CLI is there but cannot reach a daemon (not started, wrong context…). + DaemonDown { message: String, context: Option }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerContainer { + pub id: String, + pub name: String, + pub image: String, + pub command: String, + /// `running`, `exited`, `paused`, `created`, `restarting`, `removing`, `dead`. + pub state: String, + /// Human status, e.g. `Up 2 hours (healthy)`. + pub status: String, + pub ports: String, + pub created_at: String, + /// `com.docker.compose.project` label. + pub compose_project: Option, + pub compose_service: Option, + /// `com.docker.compose.project.working_dir` label. + pub compose_working_dir: Option, + /// `com.docker.compose.project.config_files` label (comma-separated in docker). + pub compose_files: Vec, +} + +impl DockerContainer { + pub fn running(&self) -> bool { + matches!(self.state.as_str(), "running" | "restarting" | "paused") + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerImage { + pub id: String, + /// `` for dangling images. + pub repository: String, + pub tag: String, + pub size: String, + pub created_since: String, +} + +impl DockerImage { + /// `repo:tag`, or the id for untagged images. + pub fn reference(&self) -> String { + if self.repository == "" || self.repository.is_empty() { + short_id(&self.id).to_owned() + } else if self.tag == "" || self.tag.is_empty() { + self.repository.clone() + } else { + format!("{}:{}", self.repository, self.tag) + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerVolume { + pub name: String, + pub driver: String, + pub mountpoint: String, + pub compose_project: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerNetwork { + pub id: String, + pub name: String, + pub driver: String, + pub scope: String, + pub compose_project: Option, +} + +/// Everything the Services tree shows, fetched in one go. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerSnapshot { + pub availability: DockerAvailability, + /// The docker executable in use, e.g. for `docker exec -it` in a terminal. + pub executable: Option, + pub containers: Vec, + pub images: Vec, + pub volumes: Vec, + pub networks: Vec, + /// Lists that failed although the daemon answered (shown, not fatal). + pub errors: Vec, +} + +/// One relevant `docker events` entry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerEvent { + /// `container`, `image`, `volume`, `network`. + pub object: String, + pub action: String, + pub id: String, + pub name: Option, +} + +/// A line of `docker logs` output. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DockerLogLine { + pub stderr: bool, + pub text: String, +} + +/// The first 12 hex digits of an id (`sha256:` prefix dropped), as docker prints them. +pub fn short_id(id: &str) -> &str { + let id = id.strip_prefix("sha256:").unwrap_or(id); + &id[..id.len().min(12)] +} + +/// Parses docker's `Labels` column (`a=b,c=d`). Values may themselves contain +/// commas (compose's `config_files`), so a segment without `=` continues the +/// previous value. +pub fn parse_labels(raw: &str) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + let mut last: Option = None; + for seg in raw.split(',') { + match seg.split_once('=') { + Some((k, v)) if !k.is_empty() && !k.contains(['/', ' ']) => { + out.insert(k.to_owned(), v.to_owned()); + last = Some(k.to_owned()); + } + _ => { + if let Some(v) = last.as_ref().and_then(|k| out.get_mut(k)) { + v.push(','); + v.push_str(seg); + } + } + } + } + out +} + +const COMPOSE_PROJECT: &str = "com.docker.compose.project"; +const COMPOSE_SERVICE: &str = "com.docker.compose.service"; +const COMPOSE_DIR: &str = "com.docker.compose.project.working_dir"; +const COMPOSE_FILES: &str = "com.docker.compose.project.config_files"; + +fn lines Deserialize<'de>>(text: &str) -> impl Iterator + '_ { + text.lines().filter(|l| l.trim_start().starts_with('{')).filter_map(|l| match serde_json::from_str::(l) { + Ok(v) => Some(v), + Err(e) => { + tracing::debug!("skipping unparsable docker line: {e}"); + None + } + }) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawContainer { + #[serde(rename = "ID")] + id: String, + names: String, + image: String, + command: String, + state: String, + status: String, + ports: String, + created_at: String, + labels: String, +} + +/// `docker ps -a --no-trunc --format '{{json .}}'`. +pub fn parse_containers(text: &str) -> Vec { + let mut out: Vec = lines::(text) + .map(|r| { + let mut labels = parse_labels(&r.labels); + let files = labels + .remove(COMPOSE_FILES) + .map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()) + .unwrap_or_default(); + // Older docker versions derive `State` from nothing: fall back on the status text. + let state = if r.state.is_empty() { + if r.status.starts_with("Up") { "running".into() } else { "exited".into() } + } else { + r.state.to_ascii_lowercase() + }; + DockerContainer { + name: r.names.split(',').next().unwrap_or_default().trim_start_matches('/').to_owned(), + id: r.id, + image: r.image, + command: r.command.trim_matches('"').to_owned(), + state, + status: r.status, + ports: r.ports, + created_at: r.created_at, + compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), + compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()), + compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()), + compose_files: files, + } + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawImage { + #[serde(rename = "ID")] + id: String, + repository: String, + tag: String, + size: String, + created_since: String, +} + +/// `docker image ls --no-trunc --format '{{json .}}'`. +pub fn parse_images(text: &str) -> Vec { + let mut out: Vec = lines::(text) + .map(|r| DockerImage { + id: r.id, + repository: r.repository, + tag: r.tag, + size: r.size, + created_since: r.created_since, + }) + .collect(); + // Tagged images by name, dangling ones last. + out.sort_by(|a, b| { + (a.repository == "", &a.repository, &a.tag).cmp(&(b.repository == "", &b.repository, &b.tag)) + }); + out +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawVolume { + name: String, + driver: String, + mountpoint: String, + labels: String, +} + +/// `docker volume ls --format '{{json .}}'`. +pub fn parse_volumes(text: &str) -> Vec { + let mut out: Vec = lines::(text) + .map(|r| DockerVolume { + compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), + name: r.name, + driver: r.driver, + mountpoint: r.mountpoint, + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawNetwork { + #[serde(rename = "ID")] + id: String, + name: String, + driver: String, + scope: String, + labels: String, +} + +/// `docker network ls --no-trunc --format '{{json .}}'`. +pub fn parse_networks(text: &str) -> Vec { + let mut out: Vec = lines::(text) + .map(|r| DockerNetwork { + compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()), + id: r.id, + name: r.name, + driver: r.driver, + scope: r.scope, + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawVersion { + #[serde(rename = "Client")] + client: Option, + #[serde(rename = "Server")] + server: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawVersionPart { + version: String, + context: Option, +} + +/// Availability from `docker version --format '{{json .}}'` (stdout, stderr, exit status). +/// Without a daemon docker still prints the client half (with `"Server": null`) +/// and explains the problem on stderr. +pub fn parse_version(stdout: &str, stderr: &str, success: bool) -> DockerAvailability { + let parsed: RawVersion = stdout + .lines() + .find(|l| l.trim_start().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) + .unwrap_or_default(); + let context = parsed.client.as_ref().and_then(|c| c.context.clone()).filter(|c| !c.is_empty()); + match (&parsed.server, success) { + (Some(server), true) if !server.version.is_empty() => DockerAvailability::Ready { + client_version: parsed.client.map(|c| c.version).unwrap_or_default(), + server_version: server.version.clone(), + context, + }, + _ => { + let message = stderr + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("Cannot connect to the Docker daemon") + .to_owned(); + DockerAvailability::DaemonDown { message, context } + } + } +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawEvent { + #[serde(rename = "Type")] + kind: String, + #[serde(rename = "Action")] + action: String, + #[serde(rename = "Actor")] + actor: RawActor, + /// Old-style field (`docker events` before API 1.22 had only these). + id: String, + status: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "PascalCase", default)] +struct RawActor { + #[serde(rename = "ID")] + id: String, + attributes: BTreeMap, +} + +/// One `docker events --format '{{json .}}'` line, or `None` for events that do +/// not change what the Services view shows (exec sessions, attach, resize…). +pub fn parse_event(line: &str) -> Option { + let raw: RawEvent = serde_json::from_str(line.trim()).ok()?; + let action = if raw.action.is_empty() { raw.status } else { raw.action }; + let object = if raw.kind.is_empty() { "container".to_owned() } else { raw.kind }; + if !matches!(object.as_str(), "container" | "image" | "volume" | "network") { + return None; + } + let verb = action.split(':').next().unwrap_or_default().trim(); + let noisy = verb.starts_with("exec_") + || matches!( + verb, + "attach" | "detach" | "resize" | "top" | "commit" | "copy" | "archive-path" | "extract-to-dir" | "export" + ); + if noisy || verb.is_empty() { + return None; + } + let id = if raw.actor.id.is_empty() { raw.id } else { raw.actor.id }; + Some(DockerEvent { name: raw.actor.attributes.get("name").cloned(), object, action: verb.to_owned(), id }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_keep_commas_inside_values() { + let l = parse_labels( + "com.docker.compose.project=demo,com.docker.compose.project.config_files=/a/x.yml,/a/y.yml,k=v", + ); + assert_eq!(l["com.docker.compose.project"], "demo"); + assert_eq!(l["com.docker.compose.project.config_files"], "/a/x.yml,/a/y.yml"); + assert_eq!(l["k"], "v"); + assert!(parse_labels("").is_empty()); + } + + #[test] + fn short_ids() { + assert_eq!(short_id("sha256:0123456789abcdef"), "0123456789ab"); + assert_eq!(short_id("abc"), "abc"); + } + + #[test] + fn image_reference() { + let img = |repo: &str, tag: &str| DockerImage { + id: "sha256:feedfacecafe0000".into(), + repository: repo.into(), + tag: tag.into(), + size: String::new(), + created_since: String::new(), + }; + assert_eq!(img("postgres", "16").reference(), "postgres:16"); + assert_eq!(img("", "").reference(), "feedfacecafe"); + assert_eq!(img("local/app", "").reference(), "local/app"); + } + + #[test] + fn version_without_daemon() { + let out = r#"{"Client":{"Version":"24.0.2","Context":"orbstack"},"Server":null}"#; + let err = "Cannot connect to the Docker daemon at unix:///x.sock. Is the docker daemon running?\n"; + assert_eq!( + parse_version(out, err, false), + DockerAvailability::DaemonDown { + message: "Cannot connect to the Docker daemon at unix:///x.sock. Is the docker daemon running?".into(), + context: Some("orbstack".into()) + } + ); + let ok = r#"{"Client":{"Version":"27.1.1","Context":"default"},"Server":{"Version":"27.1.1"}}"#; + assert!( + matches!(parse_version(ok, "", true), DockerAvailability::Ready { ref server_version, .. } if server_version == "27.1.1") + ); + } + + #[test] + fn events_skip_exec_noise() { + let start = r#"{"status":"start","id":"c1","from":"nginx","Type":"container","Action":"start","Actor":{"ID":"c1","Attributes":{"name":"web"}},"scope":"local","time":1}"#; + assert_eq!( + parse_event(start), + Some(DockerEvent { + object: "container".into(), + action: "start".into(), + id: "c1".into(), + name: Some("web".into()) + }) + ); + let exec = r#"{"Type":"container","Action":"exec_start: sh","Actor":{"ID":"c1","Attributes":{}}}"#; + assert_eq!(parse_event(exec), None); + let health = r#"{"Type":"container","Action":"health_status: healthy","Actor":{"ID":"c1","Attributes":{}}}"#; + assert_eq!(parse_event(health).map(|e| e.action), Some("health_status".into())); + let daemon = r#"{"Type":"daemon","Action":"reload","Actor":{"ID":"d","Attributes":{}}}"#; + assert_eq!(parse_event(daemon), None); + assert_eq!(parse_event("not json"), None); + } +} diff --git a/crates/ide-docker/src/process.rs b/crates/ide-docker/src/process.rs new file mode 100644 index 00000000..76870106 --- /dev/null +++ b/crates/ide-docker/src/process.rs @@ -0,0 +1,185 @@ +//! Child processes of the docker CLI: bounded runs and cancellable line streams. + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +pub(crate) struct Output { + pub stdout: String, + pub stderr: String, + pub success: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Stream { + Stdout, + Stderr, +} + +fn prepare(cmd: &mut Command) { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } +} + +fn spawn(cmd: &mut Command) -> IdeResult { + cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}"))) +} + +/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose). +pub(crate) fn kill(child: &mut Child) { + #[cfg(unix)] + { + let pid = child.id() as libc::pid_t; + // SAFETY: plain syscall; the child leads its own process group (`prepare`). + unsafe { + libc::kill(-pid, libc::SIGTERM); + } + std::thread::sleep(Duration::from_millis(20)); + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + }) +} + +/// 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 { + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let out = child.stdout.take().map(read_all); + let err = child.stderr.take().map(read_all); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_some_and(CancellationToken::is_cancelled) { + kill(&mut child); + return Err(IdeError::cancelled()); + } + if Instant::now() >= deadline { + kill(&mut child); + return Err(IdeError::unavailable("docker did not respond in time")); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok(Output { + stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + success: status.success(), + }) +} + +enum Event { + Line(Stream, String), + Closed, +} + +fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender, stream: Stream) { + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + let mut pending: Vec = Vec::new(); + let flush = |pending: &mut Vec| { + if !pending.is_empty() { + let line = String::from_utf8_lossy(pending).trim_end().to_string(); + pending.clear(); + let _ = tx.send(Event::Line(stream, line)); + } + }; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + for &b in &buf[..n] { + // `\r` too: pull / build progress rewrites its line in place. + if b == b'\n' || b == b'\r' { + flush(&mut pending); + } else { + pending.push(b); + } + } + } + } + } + flush(&mut pending); + let _ = tx.send(Event::Closed); + }); +} + +/// Runs `cmd`, handing every output line to `on_line` until it exits or `cancel` +/// fires. Returns whether it exited successfully plus the last stderr lines. +pub(crate) fn stream( + mut cmd: Command, + cancel: &CancellationToken, + on_line: &mut dyn FnMut(Stream, String), +) -> IdeResult<(bool, Vec)> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let (tx, rx) = mpsc::channel::(); + let mut open = 0; + if let Some(out) = child.stdout.take() { + spawn_reader(out, tx.clone(), Stream::Stdout); + open += 1; + } + if let Some(err) = child.stderr.take() { + spawn_reader(err, tx.clone(), Stream::Stderr); + open += 1; + } + drop(tx); + let mut stderr_tail: Vec = Vec::new(); + while open > 0 { + if cancel.is_cancelled() { + kill(&mut child); + return Err(IdeError::cancelled()); + } + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(Event::Line(stream, line)) => { + if stream == Stream::Stderr && !line.trim().is_empty() { + stderr_tail.push(line.clone()); + if stderr_tail.len() > 20 { + stderr_tail.remove(0); + } + } + on_line(stream, line); + } + Ok(Event::Closed) => open -= 1, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_cancelled() { + kill(&mut child); + return Err(IdeError::cancelled()); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok((status.success(), stderr_tail)) +} diff --git a/crates/ide-docker/tests/fixtures/images.jsonl b/crates/ide-docker/tests/fixtures/images.jsonl new file mode 100644 index 00000000..e626381c --- /dev/null +++ b/crates/ide-docker/tests/fixtures/images.jsonl @@ -0,0 +1,3 @@ +{"Containers":"N/A","CreatedAt":"2026-09-20 08:00:00 +0200 CEST","CreatedSince":"4 weeks ago","Digest":"","ID":"sha256:aaaaaaaaaaaabbbbbbbbbbbbccccccccccccddddddddddddeeeeeeeeeeeeffff","Repository":"","SharedSize":"N/A","Size":"120MB","Tag":"","UniqueSize":"N/A","VirtualSize":"120MB"} +{"Containers":"N/A","CreatedAt":"2026-09-01 08:00:00 +0200 CEST","CreatedSince":"7 weeks ago","Digest":"","ID":"sha256:1111111111112222222222223333333333334444444444445555555555556666","Repository":"postgres","SharedSize":"N/A","Size":"431MB","Tag":"16","UniqueSize":"N/A","VirtualSize":"431.4MB"} +{"Containers":"N/A","CreatedAt":"2026-09-02 08:00:00 +0200 CEST","CreatedSince":"7 weeks ago","Digest":"","ID":"sha256:7777777777778888888888889999999999990000000000001111111111112222","Repository":"nginx","SharedSize":"N/A","Size":"192MB","Tag":"1.27","UniqueSize":"N/A","VirtualSize":"192MB"} diff --git a/crates/ide-docker/tests/fixtures/networks.jsonl b/crates/ide-docker/tests/fixtures/networks.jsonl new file mode 100644 index 00000000..6e2d4dd6 --- /dev/null +++ b/crates/ide-docker/tests/fixtures/networks.jsonl @@ -0,0 +1,3 @@ +{"CreatedAt":"2026-10-01 09:12:40.1 +0200 CEST","Driver":"bridge","ID":"f00dbabe0000111122223333444455556666777788889999aaaabbbbccccdddd","IPv6":"false","Internal":"false","Labels":"com.docker.compose.network=default,com.docker.compose.project=shop","Name":"shop_default","Scope":"local"} +{"CreatedAt":"2026-09-01 08:00:00 +0200 CEST","Driver":"bridge","ID":"0123012301230123012301230123012301230123012301230123012301230123","IPv6":"false","Internal":"false","Labels":"","Name":"bridge","Scope":"local"} +{"CreatedAt":"2026-09-01 08:00:00 +0200 CEST","Driver":"host","ID":"4567456745674567456745674567456745674567456745674567456745674567","IPv6":"false","Internal":"false","Labels":"","Name":"host","Scope":"local"} diff --git a/crates/ide-docker/tests/fixtures/ps.jsonl b/crates/ide-docker/tests/fixtures/ps.jsonl new file mode 100644 index 00000000..f51378cd --- /dev/null +++ b/crates/ide-docker/tests/fixtures/ps.jsonl @@ -0,0 +1,3 @@ +{"Command":"\"docker-entrypoint.sh postgres\"","CreatedAt":"2026-10-01 09:12:44 +0200 CEST","ID":"4f2b8c1d9e0a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c","Image":"postgres:16","Labels":"com.docker.compose.config-hash=abc,com.docker.compose.project=shop,com.docker.compose.project.config_files=/work/shop/compose.yaml,/work/shop/compose.override.yaml,com.docker.compose.project.working_dir=/work/shop,com.docker.compose.service=db","LocalVolumes":"1","Mounts":"shop_pgdata","Names":"shop-db-1","Networks":"shop_default","Ports":"0.0.0.0:5432->5432/tcp","RunningFor":"3 weeks ago","Size":"0B","State":"running","Status":"Up 2 hours (healthy)"} +{"Command":"\"/docker-entrypoint.sh nginx -g 'daemon off;'\"","CreatedAt":"2026-10-02 10:00:00 +0200 CEST","ID":"9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b","Image":"nginx:1.27","Labels":"maintainer=NGINX Docker Maintainers ","LocalVolumes":"0","Mounts":"","Names":"/web","Networks":"bridge","Ports":"","RunningFor":"2 weeks ago","Size":"0B","State":"exited","Status":"Exited (0) 3 days ago"} +not json: a warning line docker sometimes prints diff --git a/crates/ide-docker/tests/fixtures/volumes.jsonl b/crates/ide-docker/tests/fixtures/volumes.jsonl new file mode 100644 index 00000000..4b6e7fa9 --- /dev/null +++ b/crates/ide-docker/tests/fixtures/volumes.jsonl @@ -0,0 +1,2 @@ +{"Availability":"N/A","Driver":"local","Group":"N/A","Labels":"com.docker.compose.project=shop,com.docker.compose.version=2.29.1,com.docker.compose.volume=pgdata","Links":"N/A","Mountpoint":"/var/lib/docker/volumes/shop_pgdata/_data","Name":"shop_pgdata","Scope":"local","Size":"N/A","Status":"N/A"} +{"Availability":"N/A","Driver":"local","Group":"N/A","Labels":"","Links":"N/A","Mountpoint":"/var/lib/docker/volumes/0a1b/_data","Name":"0a1b","Scope":"local","Size":"N/A","Status":"N/A"} diff --git a/crates/ide-docker/tests/parse.rs b/crates/ide-docker/tests/parse.rs new file mode 100644 index 00000000..e5f17438 --- /dev/null +++ b/crates/ide-docker/tests/parse.rs @@ -0,0 +1,52 @@ +//! Parsing of recorded `docker … --format '{{json .}}'` output. + +use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes}; + +const PS: &str = include_str!("fixtures/ps.jsonl"); +const IMAGES: &str = include_str!("fixtures/images.jsonl"); +const VOLUMES: &str = include_str!("fixtures/volumes.jsonl"); +const NETWORKS: &str = include_str!("fixtures/networks.jsonl"); + +#[test] +fn containers_with_compose_labels() { + let c = parse_containers(PS); + assert_eq!(c.len(), 2, "non-JSON lines are skipped"); + let db = &c[0]; + assert_eq!(db.name, "shop-db-1"); + assert_eq!(db.state, "running"); + assert!(db.running()); + assert_eq!(db.status, "Up 2 hours (healthy)"); + assert_eq!(db.command, "docker-entrypoint.sh postgres"); + assert_eq!(db.compose_project.as_deref(), Some("shop")); + assert_eq!(db.compose_service.as_deref(), Some("db")); + assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop")); + assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]); + assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp"); + + let web = &c[1]; + assert_eq!(web.name, "web", "leading slash of legacy names is dropped"); + assert!(!web.running()); + assert_eq!(web.compose_project, None); +} + +#[test] +fn images_sorted_with_dangling_last() { + let i = parse_images(IMAGES); + let refs: Vec = i.iter().map(|i| i.reference()).collect(); + assert_eq!(refs, ["nginx:1.27", "postgres:16", "aaaaaaaaaaaa"]); + assert_eq!(i[1].size, "431MB"); + assert_eq!(i[1].created_since, "7 weeks ago"); +} + +#[test] +fn volumes_and_networks() { + let v = parse_volumes(VOLUMES); + assert_eq!(v.iter().map(|v| v.name.as_str()).collect::>(), ["0a1b", "shop_pgdata"]); + assert_eq!(v[1].compose_project.as_deref(), Some("shop")); + assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data"); + + let n = parse_networks(NETWORKS); + assert_eq!(n.iter().map(|n| n.name.as_str()).collect::>(), ["bridge", "host", "shop_default"]); + assert_eq!(n[2].compose_project.as_deref(), Some("shop")); + assert_eq!(n[1].driver, "host"); +} diff --git a/crates/ide-github/Cargo.toml b/crates/ide-github/Cargo.toml new file mode 100644 index 00000000..0e33a8e8 --- /dev/null +++ b/crates/ide-github/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ide-github" +description = "GitHub pull requests through the gh CLI" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-vcs.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tracing.workspace = true +which = "8" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/ide-github/src/lib.rs b/crates/ide-github/src/lib.rs new file mode 100644 index 00000000..53ee99f3 --- /dev/null +++ b/crates/ide-github/src/lib.rs @@ -0,0 +1,331 @@ +//! GitHub pull requests through the `gh` CLI. Authentication is gh's own +//! (`gh auth login`); the repository is resolved by gh from the project's remotes. +//! +//! Every function blocks; callers run them off the UI / command threads. + +mod model; +mod process; + +pub use model::*; + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_vcs::Repo; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::Duration; + +/// API calls normally answer within a few seconds. +const QUICK: Duration = Duration::from_secs(60); + +/// Locates the gh executable once per process (GUI apps on macOS have a minimal `PATH`). +pub fn gh_executable() -> Option { + static GH: OnceLock> = OnceLock::new(); + GH.get_or_init(|| { + if let Some(explicit) = std::env::var_os("OPENIDEA_GH").map(PathBuf::from).filter(|p| p.is_file()) { + return Some(explicit); + } + if let Ok(found) = which::which("gh") { + return Some(found); + } + let candidates: &[&str] = if cfg!(windows) { + &[r"C:\Program Files\GitHub CLI\gh.exe"] + } else { + &["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh", "/snap/bin/gh"] + }; + candidates.iter().map(PathBuf::from).find(|p| p.is_file()) + }) + .clone() +} + +fn command(root: &Path, args: &[&str]) -> IdeResult { + let exe = + gh_executable().ok_or_else(|| IdeError::unavailable("gh executable not found; install the GitHub CLI"))?; + let mut cmd = Command::new(exe); + cmd.current_dir(root) + .args(args) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_NO_UPDATE_NOTIFIER", "1") + .env("GH_PAGER", "cat") + .env("NO_COLOR", "1") + .env("CLICOLOR", "0") + .env("GIT_TERMINAL_PROMPT", "0"); + tracing::debug!(?args, "gh"); + Ok(cmd) +} + +fn failure(stderr: &str, stdout: &str) -> IdeError { + let msg = [stderr, stdout].iter().map(|s| s.trim()).find(|s| !s.is_empty()).unwrap_or("gh failed").to_owned(); + match classify_failure(&msg) { + GhAvailability::NotLoggedIn { message } | GhAvailability::NotGitHub { message } => { + IdeError::unavailable(message) + } + _ => { + if msg.to_ascii_lowercase().contains("could not resolve to a pullrequest") || msg.contains("HTTP 404") { + IdeError::not_found(msg) + } else { + IdeError::internal(msg) + } + } + } +} + +fn run(root: &Path, args: &[&str]) -> IdeResult { + let out = process::run(command(root, args)?, QUICK, None)?; + if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) } +} + +fn run_streaming( + root: &Path, + args: &[&str], + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult { + let mut stdout = String::new(); + let (ok, stderr) = process::stream(command(root, args)?, cancel, &mut |stream, line| { + if stream == process::Stream::Stdout { + stdout.push_str(&line); + stdout.push('\n'); + } + if !line.trim().is_empty() { + on_output(line); + } + })?; + if ok { Ok(stdout) } else { Err(failure(&stderr.join("\n"), &stdout)) } +} + +fn parse(r: serde_json::Result) -> IdeResult { + r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}"))) +} + +/// Whether gh is installed and signed in, and which GitHub repository `root` maps to. +pub fn availability(root: &Path) -> GhAvailability { + if gh_executable().is_none() { + return GhAvailability::NotInstalled; + } + let (repo, login) = std::thread::scope(|s| { + let repo = s.spawn(|| { + let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?; + process::run(cmd, QUICK, None) + }); + let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"])); + (repo.join(), login.join()) + }); + let login = login.ok().and_then(Result::ok).map(|l| l.trim().to_owned()).filter(|l| !l.is_empty()); + match repo { + Ok(Ok(out)) if out.success => match parse_repo(&out.stdout) { + Ok(repo) => GhAvailability::Ready { repo, login }, + Err(e) => GhAvailability::Error { message: format!("unexpected gh output: {e}") }, + }, + Ok(Ok(out)) => classify_failure(if out.stderr.trim().is_empty() { &out.stdout } else { &out.stderr }), + Ok(Err(e)) => GhAvailability::Error { message: e.message }, + Err(_) => GhAvailability::Error { message: "gh check panicked".into() }, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PrStateFilter { + Open, + Closed, + Merged, + All, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrListQuery { + pub state: PrStateFilter, + pub author_me: bool, + pub review_requested: bool, + pub assignee_me: bool, + /// Free text / GitHub search qualifiers. + pub search: String, + pub limit: u32, +} + +/// `gh pr list` arguments for `q`. +pub fn list_args(q: &PrListQuery) -> Vec { + let mut args: Vec = ["pr", "list", "--json", LIST_FIELDS].iter().map(|s| s.to_string()).collect(); + args.push("--limit".into()); + args.push(q.limit.clamp(1, 500).to_string()); + args.push("--state".into()); + args.push( + match q.state { + PrStateFilter::Open => "open", + PrStateFilter::Closed => "closed", + PrStateFilter::Merged => "merged", + PrStateFilter::All => "all", + } + .into(), + ); + if q.author_me { + args.push("--author".into()); + args.push("@me".into()); + } + if q.assignee_me { + args.push("--assignee".into()); + args.push("@me".into()); + } + let mut search: Vec<&str> = Vec::new(); + if q.review_requested { + search.push("review-requested:@me"); + } + if !q.search.trim().is_empty() { + search.push(q.search.trim()); + } + if !search.is_empty() { + args.push("--search".into()); + args.push(search.join(" ")); + } + args +} + +pub fn list(root: &Path, q: &PrListQuery) -> IdeResult> { + let args = list_args(q); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + parse(parse_list(&run(root, &refs)?)) +} + +pub fn view(root: &Path, number: u32) -> IdeResult { + let n = number.to_string(); + parse(parse_view(&run(root, &["pr", "view", &n, "--json", VIEW_FIELDS])?)) +} + +/// Inline review comments of a pull request (every page). +pub fn review_comments(root: &Path, number: u32) -> IdeResult> { + let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments?per_page=100"); + parse(parse_review_comments(&run(root, &["api", "--paginate", &path])?)) +} + +/// `gh pr checkout `: creates / updates a local branch for the pull request and switches to it. +pub fn checkout(root: &Path, number: u32, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let n = number.to_string(); + run_streaming(root, &["pr", "checkout", &n], cancel, on_output).map(drop) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CreatePullRequest { + pub title: String, + pub body: String, + pub base: String, + pub draft: bool, + /// Push the current branch (setting its upstream) before creating. + pub push: bool, +} + +/// Creates a pull request from the current branch; returns its URL. +pub fn create( + root: &Path, + req: &CreatePullRequest, + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult { + if req.title.trim().is_empty() { + return Err(IdeError::invalid("the title must not be empty")); + } + if req.base.trim().is_empty() || req.base.starts_with('-') { + return Err(IdeError::invalid("choose a base branch")); + } + if req.push { + let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?; + repo.push(false, true, cancel, on_output)?; + } + let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()]; + if req.draft { + args.push("--draft"); + } + let out = run_streaming(root, &args, cancel, on_output)?; + Ok(out.lines().map(str::trim).rfind(|l| l.starts_with("http")).unwrap_or_default().to_owned()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ReviewEvent { + Approve, + RequestChanges, + Comment, +} + +/// Submits a review (`gh pr review`). Request changes and comment need a body. +pub fn submit_review(root: &Path, number: u32, event: ReviewEvent, body: &str) -> IdeResult<()> { + if !matches!(event, ReviewEvent::Approve) && body.trim().is_empty() { + return Err(IdeError::invalid("write a review comment first")); + } + let n = number.to_string(); + let flag = match event { + ReviewEvent::Approve => "--approve", + ReviewEvent::RequestChanges => "--request-changes", + ReviewEvent::Comment => "--comment", + }; + let mut args = vec!["pr", "review", &n, flag]; + if !body.trim().is_empty() { + args.push("--body"); + args.push(body); + } + run(root, &args).map(drop) +} + +/// Adds a conversation comment. +pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> { + if body.trim().is_empty() { + return Err(IdeError::invalid("the comment is empty")); + } + let n = number.to_string(); + run(root, &["pr", "comment", &n, "--body", body]).map(drop) +} + +/// Revisions to diff a pull request's files with, present in the local repository. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrDiffRevisions { + /// Merge base of base and head (what GitHub's "Files changed" compares with). + pub base: String, + pub head: String, + /// Changed files with rename information. + pub files: Vec, + /// Worktree root the file paths are relative to. + pub root: ide_core::IpcPath, +} + +/// Makes sure the pull request's head and base commits are available locally +/// (fetching `refs/pull//head` and the base branch from the matching remote), +/// then returns the revisions to compare. +#[allow(clippy::too_many_arguments)] +pub fn prepare_diff( + root: &Path, + number: u32, + head_oid: &str, + base_oid: &str, + base_ref: &str, + name_with_owner: &str, + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult { + let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?; + if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) { + let remotes = repo.remotes()?; + let remote = remote_for(&remotes, name_with_owner) + .or_else(|| remotes.iter().find(|r| r.name == "origin")) + .or(remotes.first()) + .ok_or_else(|| IdeError::invalid("the repository has no remotes"))?; + let mut refspecs = vec![format!("refs/pull/{number}/head")]; + if !base_ref.is_empty() { + refspecs.push(format!("refs/heads/{base_ref}")); + } + repo.fetch_refspecs(&remote.name, &refspecs, cancel, on_output)?; + if !repo.has_commit(head_oid) { + return Err(IdeError::not_found(format!("commit {head_oid} of #{number} could not be fetched"))); + } + } + let base = if repo.has_commit(base_oid) { + repo.merge_base(base_oid, head_oid)?.unwrap_or_else(|| base_oid.to_owned()) + } else { + format!("{head_oid}~1") + }; + let files = repo.diff_files(&base, head_oid)?; + Ok(PrDiffRevisions { base, head: head_oid.to_owned(), files, root: ide_core::IpcPath(repo.root().to_path_buf()) }) +} diff --git a/crates/ide-github/src/model.rs b/crates/ide-github/src/model.rs new file mode 100644 index 00000000..e2b4b9aa --- /dev/null +++ b/crates/ide-github/src/model.rs @@ -0,0 +1,641 @@ +//! IPC types for pull requests and their parsers from `gh … --json` output. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Whether pull requests can be shown for the project, and why not. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum GhAvailability { + Ready { + repo: GhRepo, + /// The signed-in user. + login: Option, + }, + /// No `gh` executable was found. + NotInstalled, + /// `gh auth login` has not been run (or the token expired). + NotLoggedIn { + message: String, + }, + /// The project has no GitHub remote (or is not a git repository). + NotGitHub { + message: String, + }, + Error { + message: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GhRepo { + /// `owner/name`. + pub name_with_owner: String, + pub url: String, + pub default_branch: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum PrState { + Open, + Closed, + Merged, +} + +impl PrState { + fn parse(s: &str) -> Self { + match s.to_ascii_uppercase().as_str() { + "MERGED" => Self::Merged, + "CLOSED" => Self::Closed, + _ => Self::Open, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrLabel { + pub name: String, + /// Hex colour without `#`, as GitHub stores it. + pub color: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestSummary { + pub number: u32, + pub title: String, + pub author: String, + pub state: PrState, + pub is_draft: bool, + pub head_ref: String, + pub base_ref: String, + pub created_at: String, + pub updated_at: String, + pub labels: Vec, + /// `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or `None`. + pub review_decision: Option, + pub url: String, + pub additions: u32, + pub deletions: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum CheckStatus { + Pending, + Success, + Failure, + Cancelled, + Skipped, + Neutral, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrCheck { + pub name: String, + /// Workflow of a check run (GitHub Actions), if any. + pub workflow: Option, + pub status: CheckStatus, + pub url: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrReviewer { + pub login: String, + /// `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`, `DISMISSED`, or `REQUESTED` (no review yet). + pub state: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrCommit { + pub oid: String, + pub headline: String, + pub body: String, + pub author: String, + pub date: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrFile { + pub path: String, + pub additions: u32, + pub deletions: u32, + /// `ADDED`, `MODIFIED`, `DELETED`, `RENAMED`, `COPIED`, `CHANGED`. + pub change_type: String, +} + +/// A conversation comment or a submitted review (the timeline). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrTimelineItem { + pub author: String, + pub body: String, + pub created_at: String, + /// `None` for comments; the review state for reviews. + pub review_state: Option, + pub url: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestDetails { + pub summary: PullRequestSummary, + pub body: String, + pub head_oid: String, + pub base_oid: String, + /// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`. + pub mergeable: String, + pub is_cross_repository: bool, + pub merged_at: Option, + pub closed_at: Option, + pub checks: Vec, + pub reviewers: Vec, + pub assignees: Vec, + pub commits: Vec, + pub files: Vec, + /// Comments and reviews, oldest first. + pub timeline: Vec, +} + +/// An inline review comment on a diff line. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PrReviewComment { + pub id: u64, + pub in_reply_to: Option, + pub path: String, + /// 1-based line in the current diff; `None` when the comment is outdated. + pub line: Option, + pub original_line: Option, + /// `LEFT` (base) or `RIGHT` (head). + pub side: String, + pub body: String, + pub author: String, + pub created_at: String, + pub url: String, + pub diff_hunk: String, +} + +// ---- raw gh shapes ---- + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawActor { + login: String, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawLabel { + name: String, + color: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawSummary { + number: u32, + title: String, + author: Option, + state: String, + is_draft: bool, + head_ref_name: String, + base_ref_name: String, + created_at: String, + updated_at: String, + labels: Vec, + review_decision: Option, + url: String, + additions: u32, + deletions: u32, +} + +impl RawSummary { + fn into_summary(self) -> PullRequestSummary { + PullRequestSummary { + number: self.number, + title: self.title, + author: self.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()), + state: PrState::parse(&self.state), + is_draft: self.is_draft, + head_ref: self.head_ref_name, + base_ref: self.base_ref_name, + created_at: self.created_at, + updated_at: self.updated_at, + labels: self.labels.into_iter().map(|l| PrLabel { name: l.name, color: l.color }).collect(), + review_decision: self.review_decision.filter(|d| !d.is_empty()), + url: self.url, + additions: self.additions, + deletions: self.deletions, + } + } +} + +/// Fields requested from `gh pr list`. +pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions"; + +/// Fields requested from `gh pr view`. +pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments"; + +pub fn parse_list(json: &str) -> serde_json::Result> { + let raw: Vec = serde_json::from_str(json)?; + Ok(raw.into_iter().map(RawSummary::into_summary).collect()) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawCheck { + #[serde(rename = "__typename")] + typename: String, + // CheckRun + name: String, + status: String, + conclusion: String, + details_url: String, + workflow_name: String, + // StatusContext + context: String, + state: String, + target_url: String, +} + +fn check_status(c: &RawCheck) -> CheckStatus { + if c.typename == "StatusContext" || (!c.context.is_empty() && c.name.is_empty()) { + return match c.state.as_str() { + "SUCCESS" => CheckStatus::Success, + "FAILURE" | "ERROR" => CheckStatus::Failure, + _ => CheckStatus::Pending, + }; + } + if c.status != "COMPLETED" { + return CheckStatus::Pending; + } + match c.conclusion.as_str() { + "SUCCESS" => CheckStatus::Success, + "FAILURE" | "TIMED_OUT" | "STARTUP_FAILURE" | "ACTION_REQUIRED" => CheckStatus::Failure, + "CANCELLED" => CheckStatus::Cancelled, + "SKIPPED" => CheckStatus::Skipped, + _ => CheckStatus::Neutral, + } +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawRequest { + login: Option, + /// Team requests carry a name / slug instead of a login. + name: Option, + slug: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawReview { + author: Option, + body: String, + state: String, + submitted_at: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawComment { + author: Option, + body: String, + created_at: String, + url: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawCommit { + oid: String, + message_headline: String, + message_body: String, + committed_date: String, + authors: Vec, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawCommitAuthor { + login: String, + name: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawFile { + path: String, + additions: u32, + deletions: u32, + change_type: String, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawDetails { + #[serde(flatten)] + summary: RawSummary, + body: String, + head_ref_oid: String, + base_ref_oid: String, + mergeable: String, + is_cross_repository: bool, + merged_at: Option, + closed_at: Option, + status_check_rollup: Vec, + review_requests: Vec, + reviews: Vec, + assignees: Vec, + commits: Vec, + files: Vec, + comments: Vec, +} + +/// The reviewers panel: each reviewer's latest significant review (a later plain +/// comment does not hide an approval), then requested reviewers who have not +/// reviewed yet. The author's own comments are left out. +fn reviewers(author: &str, reviews: &[RawReview], requests: &[RawRequest]) -> Vec { + let mut out: Vec = Vec::new(); + for r in reviews { + let Some(login) = r.author.as_ref().map(|a| a.login.as_str()).filter(|l| !l.is_empty() && *l != author) else { + continue; + }; + if r.state == "PENDING" { + continue; + } + match out.iter_mut().find(|x| x.login == login) { + Some(x) => { + if r.state != "COMMENTED" || x.state == "COMMENTED" { + x.state = r.state.clone(); + } + } + None => out.push(PrReviewer { login: login.to_owned(), state: r.state.clone() }), + } + } + for q in requests { + let Some(login) = q.login.clone().or_else(|| q.slug.clone()).or_else(|| q.name.clone()) else { continue }; + // A re-requested review supersedes the earlier one. + match out.iter_mut().find(|x| x.login == login) { + Some(x) => x.state = "REQUESTED".into(), + None => out.push(PrReviewer { login, state: "REQUESTED".into() }), + } + } + out +} + +pub fn parse_view(json: &str) -> serde_json::Result { + let raw: RawDetails = serde_json::from_str(json)?; + let author = raw.summary.author.as_ref().map(|a| a.login.clone()).unwrap_or_default(); + let reviewers = reviewers(&author, &raw.reviews, &raw.review_requests); + let mut timeline: Vec = raw + .comments + .into_iter() + .map(|c| PrTimelineItem { + author: c.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()), + body: c.body, + created_at: c.created_at, + review_state: None, + url: Some(c.url).filter(|u| !u.is_empty()), + }) + .chain( + raw.reviews + .into_iter() + // Empty "commented" reviews only carry inline comments, listed separately. + .filter(|r| r.state != "PENDING" && !(r.state == "COMMENTED" && r.body.trim().is_empty())) + .map(|r| PrTimelineItem { + author: r.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()), + body: r.body, + created_at: r.submitted_at, + review_state: Some(r.state), + url: None, + }), + ) + .collect(); + timeline.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + let mut checks: Vec = raw + .status_check_rollup + .iter() + .map(|c| PrCheck { + name: if c.name.is_empty() { c.context.clone() } else { c.name.clone() }, + workflow: Some(c.workflow_name.clone()).filter(|w| !w.is_empty()), + status: check_status(c), + url: [&c.details_url, &c.target_url].into_iter().find(|u| !u.is_empty()).cloned(), + }) + .collect(); + // Failures first, then running, then the rest (GitHub's merge box order). + checks.sort_by_key(|c| match c.status { + CheckStatus::Failure => 0, + CheckStatus::Pending => 1, + _ => 2, + }); + Ok(PullRequestDetails { + body: raw.body, + head_oid: raw.head_ref_oid, + base_oid: raw.base_ref_oid, + mergeable: raw.mergeable, + is_cross_repository: raw.is_cross_repository, + merged_at: raw.merged_at.filter(|s| !s.is_empty()), + closed_at: raw.closed_at.filter(|s| !s.is_empty()), + checks, + reviewers, + assignees: raw.assignees.into_iter().map(|a| a.login).collect(), + commits: raw + .commits + .into_iter() + .map(|c| PrCommit { + author: c + .authors + .first() + .map(|a| if a.login.is_empty() { a.name.clone() } else { a.login.clone() }) + .unwrap_or_default(), + oid: c.oid, + headline: c.message_headline, + body: c.message_body, + date: c.committed_date, + }) + .collect(), + files: raw + .files + .into_iter() + .map(|f| PrFile { + path: f.path, + additions: f.additions, + deletions: f.deletions, + change_type: f.change_type, + }) + .collect(), + timeline, + summary: raw.summary.into_summary(), + }) +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawReviewComment { + id: u64, + in_reply_to_id: Option, + path: String, + line: Option, + original_line: Option, + side: Option, + body: String, + user: Option, + created_at: String, + html_url: String, + diff_hunk: String, +} + +/// `gh api repos/{owner}/{repo}/pulls//comments --paginate`: one JSON array per page, +/// concatenated (or an array of pages with `--slurp`). +pub fn parse_review_comments(text: &str) -> serde_json::Result> { + let mut raw: Vec = Vec::new(); + for value in serde_json::Deserializer::from_str(text).into_iter::() { + let value = value?; + let pages = match value { + serde_json::Value::Array(items) if items.iter().all(|i| i.is_array()) && !items.is_empty() => items, + other => vec![other], + }; + for page in pages { + raw.extend(serde_json::from_value::>(page)?); + } + } + let mut out: Vec = raw + .into_iter() + .map(|c| PrReviewComment { + id: c.id, + in_reply_to: c.in_reply_to_id, + path: c.path, + line: c.line, + original_line: c.original_line, + side: c.side.unwrap_or_else(|| "RIGHT".into()), + body: c.body, + author: c.user.map(|u| u.login).unwrap_or_else(|| "ghost".into()), + created_at: c.created_at, + url: c.html_url, + diff_hunk: c.diff_hunk, + }) + .collect(); + out.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(out) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct RawRepo { + name_with_owner: String, + url: String, + default_branch_ref: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawBranchRef { + name: String, +} + +/// `gh repo view --json nameWithOwner,url,defaultBranchRef`. +pub fn parse_repo(json: &str) -> serde_json::Result { + let raw: RawRepo = serde_json::from_str(json)?; + Ok(GhRepo { + name_with_owner: raw.name_with_owner, + url: raw.url, + default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()), + }) +} + +/// What a failed gh command's message means for the Pull Requests view. +pub fn classify_failure(message: &str) -> GhAvailability { + let lower = message.to_ascii_lowercase(); + let message = message.trim().to_owned(); + if lower.contains("gh auth login") + || lower.contains("not logged in") + || lower.contains("authentication required") + || lower.contains("bad credentials") + || lower.contains("http 401") + { + GhAvailability::NotLoggedIn { message } + } else if lower.contains("none of the git remotes") + || lower.contains("no git remotes") + || lower.contains("not a git repository") + || lower.contains("could not determine base repo") + || lower.contains("unable to determine base repository") + { + GhAvailability::NotGitHub { message } + } else { + GhAvailability::Error { message } + } +} + +/// The remote whose URL points at `owner/name` (any GitHub URL form), if any. +pub fn remote_for<'a>(remotes: &'a [ide_vcs::Remote], name_with_owner: &str) -> Option<&'a ide_vcs::Remote> { + let want = name_with_owner.to_ascii_lowercase(); + remotes.iter().find(|r| { + let url = r.url.trim_end_matches('/').to_ascii_lowercase(); + let url = url.strip_suffix(".git").unwrap_or(&url); + url.ends_with(&format!("/{want}")) || url.ends_with(&format!(":{want}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_failures() { + assert!(matches!( + classify_failure("To get started with GitHub CLI, please run: gh auth login"), + GhAvailability::NotLoggedIn { .. } + )); + assert!(matches!( + classify_failure("none of the git remotes configured for this repository point to a known GitHub host."), + GhAvailability::NotGitHub { .. } + )); + assert!(matches!(classify_failure("HTTP 502: bad gateway"), GhAvailability::Error { .. })); + } + + #[test] + fn finds_remote_by_repo() { + let remotes = vec![ + ide_vcs::Remote { name: "origin".into(), url: "git@github.com:me/App.git".into() }, + ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() }, + ]; + assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin")); + assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream")); + assert_eq!(remote_for(&remotes, "x/app"), None); + } + + #[test] + fn reviewers_keep_significant_state() { + let rv = |who: &str, state: &str| RawReview { + author: Some(RawActor { login: who.into() }), + state: state.into(), + ..Default::default() + }; + let reviews = [ + rv("ann", "APPROVED"), + rv("ann", "COMMENTED"), + rv("bob", "COMMENTED"), + rv("me", "COMMENTED"), + rv("bob", "CHANGES_REQUESTED"), + ]; + let requests = [ + RawRequest { login: Some("cat".into()), ..Default::default() }, + RawRequest { slug: Some("core".into()), ..Default::default() }, + ]; + let got = reviewers("me", &reviews, &requests); + let pairs: Vec<(&str, &str)> = got.iter().map(|r| (r.login.as_str(), r.state.as_str())).collect(); + assert_eq!( + pairs, + [("ann", "APPROVED"), ("bob", "CHANGES_REQUESTED"), ("cat", "REQUESTED"), ("core", "REQUESTED")] + ); + } +} diff --git a/crates/ide-github/src/process.rs b/crates/ide-github/src/process.rs new file mode 100644 index 00000000..63aae7e7 --- /dev/null +++ b/crates/ide-github/src/process.rs @@ -0,0 +1,185 @@ +//! Child processes of the gh CLI: bounded runs and cancellable line streams. + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +pub(crate) struct Output { + pub stdout: String, + pub stderr: String, + pub success: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Stream { + Stdout, + Stderr, +} + +fn prepare(cmd: &mut Command) { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); + } +} + +fn spawn(cmd: &mut Command) -> IdeResult { + cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}"))) +} + +/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers). +pub(crate) fn kill(child: &mut Child) { + #[cfg(unix)] + { + let pid = child.id() as libc::pid_t; + // SAFETY: plain syscall; the child leads its own process group (`prepare`). + unsafe { + libc::kill(-pid, libc::SIGTERM); + } + std::thread::sleep(Duration::from_millis(20)); + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + }) +} + +/// 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 { + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let out = child.stdout.take().map(read_all); + let err = child.stderr.take().map(read_all); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_some_and(CancellationToken::is_cancelled) { + kill(&mut child); + return Err(IdeError::cancelled()); + } + if Instant::now() >= deadline { + kill(&mut child); + return Err(IdeError::unavailable("gh did not respond in time")); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok(Output { + stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + success: status.success(), + }) +} + +enum Event { + Line(Stream, String), + Closed, +} + +fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender, stream: Stream) { + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + let mut pending: Vec = Vec::new(); + let flush = |pending: &mut Vec| { + if !pending.is_empty() { + let line = String::from_utf8_lossy(pending).trim_end().to_string(); + pending.clear(); + let _ = tx.send(Event::Line(stream, line)); + } + }; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + for &b in &buf[..n] { + // `\r` too: git progress rewrites its line in place. + if b == b'\n' || b == b'\r' { + flush(&mut pending); + } else { + pending.push(b); + } + } + } + } + } + flush(&mut pending); + let _ = tx.send(Event::Closed); + }); +} + +/// Runs `cmd`, handing every output line to `on_line` until it exits or `cancel` +/// fires. Returns whether it exited successfully plus the last stderr lines. +pub(crate) fn stream( + mut cmd: Command, + cancel: &CancellationToken, + on_line: &mut dyn FnMut(Stream, String), +) -> IdeResult<(bool, Vec)> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let (tx, rx) = mpsc::channel::(); + let mut open = 0; + if let Some(out) = child.stdout.take() { + spawn_reader(out, tx.clone(), Stream::Stdout); + open += 1; + } + if let Some(err) = child.stderr.take() { + spawn_reader(err, tx.clone(), Stream::Stderr); + open += 1; + } + drop(tx); + let mut stderr_tail: Vec = Vec::new(); + while open > 0 { + if cancel.is_cancelled() { + kill(&mut child); + return Err(IdeError::cancelled()); + } + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(Event::Line(stream, line)) => { + if stream == Stream::Stderr && !line.trim().is_empty() { + stderr_tail.push(line.clone()); + if stderr_tail.len() > 20 { + stderr_tail.remove(0); + } + } + on_line(stream, line); + } + Ok(Event::Closed) => open -= 1, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_cancelled() { + kill(&mut child); + return Err(IdeError::cancelled()); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok((status.success(), stderr_tail)) +} diff --git a/crates/ide-github/tests/fixtures/pr_list.json b/crates/ide-github/tests/fixtures/pr_list.json new file mode 100644 index 00000000..591d3fd9 --- /dev/null +++ b/crates/ide-github/tests/fixtures/pr_list.json @@ -0,0 +1 @@ +[{"additions":0,"author":{"id":"MDQ6VXNlcjQ3Mzk0MjAw","is_bot":false,"login":"BagToad","name":"Kynan Ware"},"baseRefName":"trunk","createdAt":"2026-10-20T19:36:59Z","deletions":0,"headRefName":"bagtoad/merge-v2.102.1","isDraft":false,"labels":[],"number":14770,"reviewDecision":"APPROVED","state":"MERGED","title":"Merge v2.102.1 release branch into trunk","updatedAt":"2026-10-20T19:47:14Z","url":"https://github.com/cli/cli/pull/14770"},{"additions":42,"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","createdAt":"2026-10-20T16:40:41Z","deletions":48,"headRefName":"babakks/refresh-token-bump-deps","isDraft":true,"labels":[{"id":"LA_1","name":"dependencies","description":"","color":"0366d6"}],"number":14768,"reviewDecision":"","state":"OPEN","title":"Refreshable tokens: bump cli/oauth to v1.3.0","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}] diff --git a/crates/ide-github/tests/fixtures/pr_view.json b/crates/ide-github/tests/fixtures/pr_view.json new file mode 100644 index 00000000..12a35510 --- /dev/null +++ b/crates/ide-github/tests/fixtures/pr_view.json @@ -0,0 +1 @@ +{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"babakks@github.com","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"} diff --git a/crates/ide-github/tests/fixtures/review_comments.json b/crates/ide-github/tests/fixtures/review_comments.json new file mode 100644 index 00000000..3043d25d --- /dev/null +++ b/crates/ide-github/tests/fixtures/review_comments.json @@ -0,0 +1 @@ +[{"body":"Optional: move the column-width correction into a separate PR.","commit_id":"6ec1ba54542baca6cc5932582634104c83f36a28","created_at":"2026-10-21T09:46:00Z","diff_hunk":"@@ -675,7 +675,8 @@ func statusRun(opts *StatusOptions) error {\n \thalfWidth := (fullWidth / 2) - 2","html_url":"https://github.com/cli/cli/pull/14768#discussion_r4335329893","id":4335329893,"in_reply_to_id":null,"line":679,"original_line":679,"path":"pkg/cmd/status/status.go","side":"RIGHT","start_line":null,"subject_type":"line","user":{"login":"Copilot","id":1}},{"body":"Done.","commit_id":"6ec1ba54542baca6cc5932582634104c83f36a28","created_at":"2026-10-21T10:00:00Z","diff_hunk":"@@ -675,7 +675,8 @@","html_url":"https://github.com/cli/cli/pull/14768#discussion_r4335329999","id":4335329999,"in_reply_to_id":4335329893,"line":679,"original_line":679,"path":"pkg/cmd/status/status.go","side":"RIGHT","start_line":null,"subject_type":"line","user":{"login":"babakks","id":2}}][{"body":"This was removed.","commit_id":"831b9343fa77c2f11f521a49fd01da2d2d007b8e","created_at":"2026-10-21T11:00:00Z","diff_hunk":"@@ -1,3 +1,3 @@","html_url":"https://github.com/cli/cli/pull/14768#discussion_r1","id":1,"in_reply_to_id":null,"line":null,"original_line":2,"path":"go.mod","side":"LEFT","start_line":null,"subject_type":"line","user":{"login":"williammartin","id":3}}] diff --git a/crates/ide-github/tests/parse.rs b/crates/ide-github/tests/parse.rs new file mode 100644 index 00000000..407af242 --- /dev/null +++ b/crates/ide-github/tests/parse.rs @@ -0,0 +1,89 @@ +//! Parsing of recorded `gh` JSON output. + +use ide_github::*; + +#[test] +fn pr_list() { + let prs = parse_list(include_str!("fixtures/pr_list.json")).unwrap(); + assert_eq!(prs.len(), 2); + assert_eq!(prs[0].number, 14770); + assert_eq!(prs[0].state, PrState::Merged); + assert_eq!(prs[0].author, "BagToad"); + assert_eq!(prs[0].review_decision.as_deref(), Some("APPROVED")); + assert_eq!(prs[1].labels, [PrLabel { name: "dependencies".into(), color: "0366d6".into() }]); + assert!(prs[1].is_draft); + assert_eq!(prs[1].state, PrState::Open); + assert_eq!(prs[1].review_decision, None, "empty decision means none"); +} + +#[test] +fn pr_view() { + let pr = parse_view(include_str!("fixtures/pr_view.json")).unwrap(); + assert_eq!(pr.summary.number, 14768); + assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps"); + assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28"); + assert_eq!(pr.files.len(), 2); + assert_eq!(pr.files[0].change_type, "MODIFIED"); + assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0"); + assert_eq!(pr.commits[0].author, "babakks"); + let statuses: Vec<(&str, CheckStatus)> = pr.checks.iter().map(|c| (c.name.as_str(), c.status)).collect(); + assert_eq!( + statuses, + [ + ("lint", CheckStatus::Failure), + ("build (windows)", CheckStatus::Pending), + ("ci/legacy", CheckStatus::Pending), + ("build (ubuntu)", CheckStatus::Success), + ("docs", CheckStatus::Skipped) + ] + ); + assert_eq!(pr.checks[3].workflow.as_deref(), Some("Tests")); + let reviewers: Vec<(&str, &str)> = pr.reviewers.iter().map(|r| (r.login.as_str(), r.state.as_str())).collect(); + assert_eq!( + reviewers, + [("copilot-pull-request-reviewer", "COMMENTED"), ("williammartin", "APPROVED"), ("niik", "REQUESTED")] + ); + // The empty COMMENTED review is left out; the comment and approval are in date order. + let timeline: Vec<(&str, Option<&str>)> = + pr.timeline.iter().map(|t| (t.author.as_str(), t.review_state.as_deref())).collect(); + assert_eq!(timeline, [("babakks", None), ("williammartin", Some("APPROVED"))]); + assert_eq!(pr.merged_at.as_deref(), Some("2026-10-21T12:29:58Z")); +} + +#[test] +fn review_comments_across_pages() { + let c = parse_review_comments(include_str!("fixtures/review_comments.json")).unwrap(); + assert_eq!(c.len(), 3); + assert_eq!(c[0].path, "pkg/cmd/status/status.go"); + assert_eq!(c[0].line, Some(679)); + assert_eq!(c[0].side, "RIGHT"); + assert_eq!(c[0].author, "Copilot"); + assert_eq!(c[1].in_reply_to, Some(4335329893)); + assert_eq!(c[2].line, None, "outdated comment"); + assert_eq!(c[2].side, "LEFT"); +} + +#[test] +fn repo_view() { + let r = parse_repo(r#"{"defaultBranchRef":{"name":"main"},"nameWithOwner":"l0ng-ai/openidea","url":"https://github.com/l0ng-ai/openidea"}"#) + .unwrap(); + assert_eq!(r.name_with_owner, "l0ng-ai/openidea"); + assert_eq!(r.default_branch.as_deref(), Some("main")); +} + +#[test] +fn list_arguments() { + let q = PrListQuery { + state: PrStateFilter::Merged, + author_me: true, + review_requested: true, + assignee_me: false, + search: " fix login ".into(), + limit: 1000, + }; + let args = list_args(&q); + let joined = args.join(" "); + assert!(joined.starts_with("pr list --json number,title")); + assert!(joined.contains("--limit 500 --state merged --author @me")); + assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login")); +} diff --git a/crates/ide-http/Cargo.toml b/crates/ide-http/Cargo.toml new file mode 100644 index 00000000..c3b022ec --- /dev/null +++ b/crates/ide-http/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ide-http" +description = "HTTP Client: .http request files, environments, execution and response history" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio.workspace = true +tracing.workspace = true +parking_lot.workspace = true +reqwest.workspace = true +rustls.workspace = true +rustls-platform-verifier.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["net"] } +tempfile.workspace = true diff --git a/crates/ide-http/src/env.rs b/crates/ide-http/src/env.rs new file mode 100644 index 00000000..84ae7cc5 --- /dev/null +++ b/crates/ide-http/src/env.rs @@ -0,0 +1,265 @@ +//! Environments (`http-client.env.json`, `http-client.private.env.json`) and +//! `{{variable}}` substitution. +//! +//! ```json +//! { "$shared": { "version": "v1" }, "dev": { "host": "localhost:8080" } } +//! ``` +//! +//! Lookup order for `{{name}}`: file variables (`@name = …`), the private +//! environment, the public environment, private `$shared`, public `$shared`. +//! Dynamic variables: `$uuid` / `$random.uuid`, `$timestamp`, `$isoTimestamp`, +//! `$randomInt` / `$random.integer`. + +use ide_core::{IdeError, IdeResult}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +pub const PUBLIC_FILE: &str = "http-client.env.json"; +pub const PRIVATE_FILE: &str = "http-client.private.env.json"; +const SHARED: &str = "$shared"; + +type EnvMap = BTreeMap>; + +/// The environment files that apply to a request file. +#[derive(Debug, Default, Clone)] +pub struct Environments { + public: EnvMap, + private: EnvMap, +} + +/// Folders searched for env files: the request file's folder and its +/// ancestors up to (and including) `root`; `root` itself when the file is outside it. +fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + if let Some(mut dir) = file.and_then(Path::parent) { + loop { + dirs.push(dir.to_path_buf()); + if root.is_none_or(|r| dir == r || !dir.starts_with(r)) { + break; + } + match dir.parent() { + Some(p) => dir = p, + None => break, + } + } + } + if let Some(r) = root + && !dirs.iter().any(|d| d == r) + { + dirs.push(r.to_path_buf()); + } + dirs +} + +fn parse_env(text: &str) -> IdeResult { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?; + let mut out = EnvMap::new(); + let Some(envs) = value.as_object() else { return Ok(out) }; + for (env, vars) in envs { + let Some(vars) = vars.as_object() else { continue }; + let mut map = BTreeMap::new(); + for (k, v) in vars { + let s = match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + // Objects configure SSL / auth in IDEA; not variables. + _ => continue, + }; + map.insert(k.clone(), s); + } + out.insert(env.clone(), map); + } + Ok(out) +} + +fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult { + for d in dirs { + let p = d.join(name); + if let Ok(text) = std::fs::read_to_string(&p) { + return parse_env(&text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message))); + } + } + Ok(EnvMap::new()) +} + +impl Environments { + pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult { + let dirs = search_dirs(file, root); + Ok(Self { public: read_first(&dirs, PUBLIC_FILE)?, private: read_first(&dirs, PRIVATE_FILE)? }) + } + + pub fn from_json(public: &str, private: &str) -> IdeResult { + let p = |s: &str| if s.trim().is_empty() { Ok(EnvMap::new()) } else { parse_env(s) }; + Ok(Self { public: p(public)?, private: p(private)? }) + } + + /// Environment names (excluding `$shared`), sorted. + pub fn names(&self) -> Vec { + let set: BTreeSet<&String> = self.public.keys().chain(self.private.keys()).filter(|k| *k != SHARED).collect(); + set.into_iter().cloned().collect() + } + + /// Variables of `env` merged in lookup order (first wins). + pub fn variables(&self, env: Option<&str>) -> HashMap { + let mut out = HashMap::new(); + let mut layer = |m: Option<&BTreeMap>| { + for (k, v) in m.into_iter().flatten() { + out.entry(k.clone()).or_insert_with(|| v.clone()); + } + }; + if let Some(env) = env { + layer(self.private.get(env)); + layer(self.public.get(env)); + } + layer(self.private.get(SHARED)); + layer(self.public.get(SHARED)); + out + } +} + +fn dynamic(name: &str) -> Option { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + match name { + "$uuid" | "$random.uuid" => Some(uuid::Uuid::new_v4().to_string()), + "$timestamp" => Some(now.as_secs().to_string()), + "$isoTimestamp" => Some(iso8601(now.as_secs())), + "$randomInt" | "$random.integer" | "$random.integer()" => { + Some((uuid::Uuid::new_v4().as_u128() % 1000).to_string()) + } + _ => None, + } +} + +/// `secs` since the epoch as `YYYY-MM-DDTHH:MM:SSZ` (UTC). +fn iso8601(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + // Civil-from-days (Howard Hinnant). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) +} + +/// Resolves `{{name}}` references. File variables may reference other +/// variables; unresolved names are collected in `missing` and left as written. +pub struct Resolver<'a> { + pub file_vars: &'a [(String, String)], + pub env: HashMap, +} + +impl Resolver<'_> { + fn lookup(&self, name: &str, depth: u32, missing: &mut BTreeSet) -> Option { + if let Some((_, v)) = self.file_vars.iter().rev().find(|(k, _)| k == name) { + return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() }); + } + if let Some(v) = self.env.get(name) { + return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() }); + } + dynamic(name) + } + + fn substitute_inner(&self, text: &str, depth: u32, missing: &mut BTreeSet) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("{{") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find("}}") else { + out.push_str(&rest[start..]); + return out; + }; + let name = after[..end].trim(); + match self.lookup(name, depth, missing) { + Some(v) => out.push_str(&v), + None => { + missing.insert(name.to_owned()); + out.push_str(&rest[start..start + 2 + end + 2]); + } + } + rest = &after[end + 2..]; + } + out.push_str(rest); + out + } + + pub fn substitute(&self, text: &str, missing: &mut BTreeSet) -> String { + self.substitute_inner(text, 0, missing) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_private_public_and_shared() { + let envs = Environments::from_json( + r#"{"$shared":{"v":"1","host":"shared"},"dev":{"host":"localhost","port":8080,"ssl":{"x":1}},"prod":{}}"#, + r#"{"dev":{"token":"secret"},"$shared":{"v":"2"}}"#, + ) + .unwrap(); + assert_eq!(envs.names(), vec!["dev", "prod"]); + let vars = envs.variables(Some("dev")); + assert_eq!(vars["host"], "localhost"); + assert_eq!(vars["port"], "8080"); + assert_eq!(vars["token"], "secret"); + assert_eq!(vars["v"], "2"); + assert!(!vars.contains_key("ssl")); + assert_eq!(envs.variables(None)["host"], "shared"); + } + + #[test] + fn substitutes_file_env_and_dynamic_variables() { + let file_vars = vec![("base".to_owned(), "https://{{host}}".to_owned())]; + let env = HashMap::from([("host".to_owned(), "example.com".to_owned())]); + let r = Resolver { file_vars: &file_vars, env }; + let mut missing = BTreeSet::new(); + assert_eq!(r.substitute("{{ base }}/a?x={{nope}}", &mut missing), "https://example.com/a?x={{nope}}"); + assert_eq!(missing.into_iter().collect::>(), vec!["nope"]); + let mut missing = BTreeSet::new(); + let id = r.substitute("{{$uuid}}", &mut missing); + assert_eq!(id.len(), 36); + assert!(missing.is_empty()); + assert_eq!(r.substitute("{{unterminated", &mut missing), "{{unterminated"); + } + + #[test] + fn self_reference_terminates() { + let file_vars = vec![("a".to_owned(), "{{a}}".to_owned())]; + let r = Resolver { file_vars: &file_vars, env: HashMap::new() }; + let mut missing = BTreeSet::new(); + assert_eq!(r.substitute("{{a}}", &mut missing), "{{a}}"); + } + + #[test] + fn iso_timestamps() { + assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z"); + } + + #[test] + fn finds_env_files_in_ancestors_up_to_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("api/v1")).unwrap(); + std::fs::write(root.join(PUBLIC_FILE), r#"{"dev":{"host":"root"}}"#).unwrap(); + std::fs::write(root.join("api").join(PRIVATE_FILE), r#"{"dev":{"token":"t"}}"#).unwrap(); + let envs = Environments::load(Some(&root.join("api/v1/a.http")), Some(root)).unwrap(); + let vars = envs.variables(Some("dev")); + assert_eq!(vars["host"], "root"); + assert_eq!(vars["token"], "t"); + // A scratch file outside the project still sees the project's environments. + let other = tempfile::tempdir().unwrap(); + let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap(); + assert_eq!(envs.names(), vec!["dev"]); + } +} diff --git a/crates/ide-http/src/exec.rs b/crates/ide-http/src/exec.rs new file mode 100644 index 00000000..4a0c1453 --- /dev/null +++ b/crates/ide-http/src/exec.rs @@ -0,0 +1,314 @@ +//! Preparing (substitution, body includes) and executing requests. + +use crate::env::{Environments, Resolver}; +use crate::parse::{BodyPart, RawRequest}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::{Arc, OnceLock}; +use std::time::Instant; + +/// Bodies above this are cut (the full size is still reported). +pub const MAX_BODY: usize = 16 * 1024 * 1024; + +/// A request ready to send: variables substituted, includes read. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PreparedRequest { + pub name: Option, + pub method: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HttpResponse { + pub status: u16, + pub status_text: String, + pub version: String, + pub headers: Vec<(String, String)>, + pub content_type: Option, + /// Body as text (lossy UTF-8); `None` for binary bodies. + pub body: Option, + /// Size in bytes as received (after decompression). + pub size: f64, + pub truncated: bool, + /// Request start → response headers. + pub ttfb_ms: f64, + /// Request start → body fully read. + pub total_ms: f64, +} + +/// Substitutes variables and reads `< file` includes (relative to the request +/// file's folder). Fails on unresolved variables. +pub fn prepare( + raw: &RawRequest, + file_vars: &[(String, String)], + envs: &Environments, + env: Option<&str>, + base_dir: Option<&Path>, +) -> IdeResult { + let resolver = Resolver { file_vars, env: envs.variables(env) }; + let mut missing = BTreeSet::new(); + let url = resolver.substitute(&raw.block.url, &mut missing); + let headers: Vec<(String, String)> = + raw.headers.iter().map(|(k, v)| (k.clone(), resolver.substitute(v, &mut missing))).collect(); + let mut body_lines: Vec = Vec::new(); + for part in &raw.body { + match part { + BodyPart::Text(t) => body_lines.push(resolver.substitute(t, &mut missing)), + BodyPart::File(p) => { + let p = resolver.substitute(p, &mut missing); + let path = match base_dir { + Some(d) if Path::new(&p).is_relative() => d.join(&p), + _ => p.clone().into(), + }; + let bytes = std::fs::read(&path) + .map_err(|e| IdeError::not_found(format!("cannot read body file {}: {e}", path.display())))?; + body_lines.push(String::from_utf8_lossy(&bytes).trim_end_matches(['\r', '\n']).to_owned()); + } + } + } + if !missing.is_empty() { + let names: Vec<_> = missing.into_iter().collect(); + let hint = if env.is_none() { " (no environment selected)" } else { "" }; + return Err(IdeError::invalid(format!("unresolved variable(s): {}{hint}", names.join(", ")))); + } + let url = if url.starts_with('/') { + // `GET /path` with a `Host` header. + match headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("host")) { + Some((_, host)) => format!("http://{host}{url}"), + None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))), + } + } else if !url.contains("://") { + format!("http://{url}") + } else { + url + }; + Ok(PreparedRequest { + name: raw.block.name.clone(), + method: raw.block.method.clone(), + url, + headers, + body: (!body_lines.is_empty()).then(|| body_lines.join("\n")), + }) +} + +fn client() -> IdeResult { + static CLIENT: OnceLock> = OnceLock::new(); + CLIENT + .get_or_init(|| { + use rustls_platform_verifier::BuilderVerifierExt; + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let tls = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| e.to_string())? + .with_platform_verifier() + .map_err(|e| e.to_string())? + .with_no_client_auth(); + reqwest::Client::builder() + .tls_backend_preconfigured(tls) + .connect_timeout(std::time::Duration::from_secs(30)) + .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| e.to_string()) + }) + .clone() + .map_err(|e| IdeError::internal(format!("HTTP client: {e}"))) +} + +fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool { + if let Some(ct) = content_type { + let ct = ct.to_ascii_lowercase(); + if ct.starts_with("text/") + || ["json", "xml", "html", "javascript", "x-www-form-urlencoded", "yaml", "csv", "graphql"] + .iter() + .any(|k| ct.contains(k)) + { + return true; + } + if ct.starts_with("image/") + || ct.starts_with("audio/") + || ct.starts_with("video/") + || ct.contains("octet-stream") + { + return false; + } + } + // Valid UTF-8 up to a possibly cut last character. + match std::str::from_utf8(&bytes[..bytes.len().min(4096)]) { + Ok(_) => true, + Err(e) => e.error_len().is_none(), + } +} + +/// Sends `req`; cancelled when `cancel` fires. +pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeResult { + let client = client()?; + let method = reqwest::Method::from_bytes(req.method.as_bytes()) + .map_err(|_| IdeError::invalid(format!("invalid method {}", req.method)))?; + let mut builder = client.request(method, &req.url); + for (k, v) in &req.headers { + builder = builder.header(k, v); + } + if let Some(body) = &req.body { + builder = builder.body(body.clone()); + } + let request = builder.build().map_err(|e| IdeError::invalid(format!("invalid request: {e}")))?; + let started = Instant::now(); + let send = client.execute(request); + let mut response = tokio::select! { + r = send => r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + let ttfb = started.elapsed(); + let status = response.status(); + let version = format!("{:?}", response.version()); + let headers: Vec<(String, String)> = response + .headers() + .iter() + .map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned())) + .collect(); + let content_type = + response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).map(str::to_owned); + let mut bytes = Vec::new(); + let mut size = 0usize; + loop { + let chunk = tokio::select! { + c = response.chunk() => c.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + let Some(chunk) = chunk else { break }; + size += chunk.len(); + if bytes.len() < MAX_BODY { + let take = chunk.len().min(MAX_BODY - bytes.len()); + bytes.extend_from_slice(&chunk[..take]); + } + } + let total = started.elapsed(); + let body = looks_textual(content_type.as_deref(), &bytes).then(|| String::from_utf8_lossy(&bytes).into_owned()); + Ok(HttpResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or("").to_owned(), + version, + headers, + content_type, + body, + size: size as f64, + truncated: size > bytes.len(), + ttfb_ms: ttfb.as_secs_f64() * 1000.0, + total_ms: total.as_secs_f64() * 1000.0, + }) +} + +/// reqwest errors with their causes ("error sending request: … connection refused"). +fn describe(e: &reqwest::Error) -> String { + use std::error::Error; + let mut msg = e.to_string(); + let mut source = e.source(); + while let Some(s) = source { + let s_msg = s.to_string(); + if !msg.contains(&s_msg) { + msg.push_str(": "); + msg.push_str(&s_msg); + } + source = s.source(); + } + msg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::parse; + + #[test] + fn prepares_with_env_and_includes() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("body.json"), "{\"id\": \"{{id}}\"}\n").unwrap(); + let f = parse("@id = 7\nPOST {{host}}/items\nAuthorization: Bearer {{token}}\n\n< ./body.json\n"); + let envs = Environments::from_json(r#"{"dev":{"host":"https://api.test","token":"abc"}}"#, "").unwrap(); + let p = prepare(&f.requests[0], &f.variables, &envs, Some("dev"), Some(tmp.path())).unwrap(); + assert_eq!(p.url, "https://api.test/items"); + assert_eq!(p.headers, vec![("Authorization".into(), "Bearer abc".into())]); + // Included files are inserted verbatim (no substitution inside them). + assert_eq!(p.body.as_deref(), Some("{\"id\": \"{{id}}\"}")); + } + + #[test] + fn reports_unresolved_variables() { + let f = parse("GET https://{{host}}/x\n"); + let err = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap_err(); + assert!(err.message.contains("host"), "{}", err.message); + } + + #[test] + fn relative_urls_use_the_host_header() { + let f = parse("GET /status\nHost: localhost:9000\n"); + let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap(); + assert_eq!(p.url, "http://localhost:9000/status"); + } + + #[tokio::test] + async fn executes_against_a_local_server() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let n = s.read(&mut buf).await.unwrap(); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + assert!(req.starts_with("POST /echo HTTP/1.1"), "{req}"); + let body = "{\"ok\":true}"; + let resp = format!( + "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-A: b\r\n\r\n{body}", + body.len() + ); + s.write_all(resp.as_bytes()).await.unwrap(); + }); + let req = PreparedRequest { + name: None, + method: "POST".into(), + url: format!("http://{addr}/echo"), + headers: vec![("Content-Type".into(), "application/json".into())], + body: Some("{}".into()), + }; + let r = execute(&req, &CancellationToken::new()).await.unwrap(); + assert_eq!(r.status, 201); + assert_eq!(r.status_text, "Created"); + assert_eq!(r.body.as_deref(), Some("{\"ok\":true}")); + assert!(r.headers.iter().any(|(k, v)| k == "x-a" && v == "b")); + assert!(r.total_ms >= r.ttfb_ms); + } + + #[tokio::test] + async fn cancels() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + // Accepts but never answers. + tokio::spawn(async move { + let _conn = listener.accept().await; + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + }); + let req = PreparedRequest { + name: None, + method: "GET".into(), + url: format!("http://{addr}/"), + headers: vec![], + body: None, + }; + let cancel = CancellationToken::new(); + let c = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + c.cancel(); + }); + let err = execute(&req, &cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + } +} diff --git a/crates/ide-http/src/history.rs b/crates/ide-http/src/history.rs new file mode 100644 index 00000000..1ecf0a18 --- /dev/null +++ b/crates/ide-http/src/history.rs @@ -0,0 +1,155 @@ +//! Response history: every exchange is saved under the project's data folder +//! (IDEA keeps them in `.idea/httpRequests`), newest first, capped. + +use crate::exec::{HttpResponse, PreparedRequest}; +use ide_core::{IdeError, IdeResult, IpcPath}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +pub const MAX_ENTRIES: usize = 100; +const INDEX: &str = "index.json"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "HttpHistoryEntry")] +pub struct HistoryEntry { + pub id: String, + /// Unix epoch, ms. + pub time_ms: f64, + pub name: Option, + pub method: String, + pub url: String, + /// `None` when the request failed. + pub status: Option, + pub total_ms: Option, + /// The `.http` file the request came from. + pub file: Option, + pub error: Option, +} + +/// One request with its outcome. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HttpExchange { + pub entry: HistoryEntry, + pub request: PreparedRequest, + pub response: Option, +} + +pub struct History { + dir: PathBuf, + lock: Mutex<()>, +} + +impl History { + pub fn new(dir: PathBuf) -> Self { + Self { dir, lock: Mutex::new(()) } + } + + fn read_index(&self) -> Vec { + std::fs::read_to_string(self.dir.join(INDEX)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn file(&self, id: &str) -> IdeResult { + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(IdeError::invalid("invalid history id")); + } + Ok(self.dir.join(format!("{id}.json"))) + } + + pub fn list(&self) -> Vec { + let _g = self.lock.lock(); + self.read_index() + } + + pub fn get(&self, id: &str) -> IdeResult { + let text = std::fs::read_to_string(self.file(id)?)?; + serde_json::from_str(&text).map_err(|e| IdeError::internal(format!("corrupt history entry: {e}"))) + } + + /// Saves `exchange`, dropping the oldest entries beyond [`MAX_ENTRIES`]. + pub fn add(&self, exchange: &HttpExchange) -> IdeResult<()> { + let _g = self.lock.lock(); + std::fs::create_dir_all(&self.dir)?; + let json = serde_json::to_string(exchange).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(self.file(&exchange.entry.id)?, json)?; + let mut index = self.read_index(); + index.insert(0, exchange.entry.clone()); + for old in index.drain(MAX_ENTRIES.min(index.len())..) { + if let Ok(p) = self.file(&old.id) { + let _ = std::fs::remove_file(p); + } + } + self.write_index(&index) + } + + fn write_index(&self, index: &[HistoryEntry]) -> IdeResult<()> { + let json = serde_json::to_string_pretty(index).map_err(|e| IdeError::internal(e.to_string()))?; + std::fs::write(self.dir.join(INDEX), json)?; + Ok(()) + } + + pub fn clear(&self) -> IdeResult<()> { + let _g = self.lock.lock(); + for e in self.read_index() { + if let Ok(p) = self.file(&e.id) { + let _ = std::fs::remove_file(p); + } + } + if self.dir.exists() { + self.write_index(&[])?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exchange(id: &str) -> HttpExchange { + HttpExchange { + entry: HistoryEntry { + id: id.into(), + time_ms: 1.0, + name: None, + method: "GET".into(), + url: "http://x".into(), + status: Some(200), + total_ms: Some(3.0), + file: None, + error: None, + }, + request: PreparedRequest { + name: None, + method: "GET".into(), + url: "http://x".into(), + headers: vec![], + body: None, + }, + response: None, + } + } + + #[test] + fn stores_newest_first_and_caps() { + let tmp = tempfile::tempdir().unwrap(); + let h = History::new(tmp.path().join("h")); + for i in 0..MAX_ENTRIES + 3 { + h.add(&exchange(&format!("e{i}"))).unwrap(); + } + let list = h.list(); + assert_eq!(list.len(), MAX_ENTRIES); + assert_eq!(list[0].id, format!("e{}", MAX_ENTRIES + 2)); + assert!(h.get("e0").is_err()); + assert_eq!(h.get("e5").unwrap().entry.id, "e5"); + assert!(h.get("../x").is_err()); + h.clear().unwrap(); + assert!(h.list().is_empty()); + } +} diff --git a/crates/ide-http/src/lib.rs b/crates/ide-http/src/lib.rs new file mode 100644 index 00000000..cc09d42a --- /dev/null +++ b/crates/ide-http/src/lib.rs @@ -0,0 +1,74 @@ +//! HTTP Client: IntelliJ-style `.http` request files. +//! +//! [`parse`] splits a file into requests, [`env`] resolves `{{variables}}` from +//! `http-client(.private).env.json`, [`exec`] sends a request (cancellable, +//! timed) and [`history`] keeps the responses. + +pub mod env; +pub mod exec; +pub mod history; +pub mod parse; + +pub use env::Environments; +pub use exec::{HttpResponse, PreparedRequest}; +pub use history::{History, HistoryEntry, HttpExchange}; +pub use parse::HttpRequestBlock; + +use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath}; +use std::path::Path; + +/// Where a request comes from and how to resolve it. +pub struct RunInput<'a> { + pub text: &'a str, + pub index: u32, + /// The request file (`None` for an unsaved buffer). + pub file: Option<&'a Path>, + pub root: Option<&'a Path>, + pub env: Option<&'a str>, +} + +/// Prepares and sends request `index` of `text`. Preparation errors (bad +/// variables, missing includes) fail; transport errors are part of the exchange. +pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult { + let parsed = parse::parse(input.text); + let raw = parsed + .requests + .get(input.index as usize) + .ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?; + let envs = Environments::load(input.file, input.root)?; + let request = exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))?; + let time_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or_default(); + let result = exec::execute(&request, cancel).await; + if let Err(e) = &result + && e.kind == ide_core::error::ErrorKind::Cancelled + { + return Err(e.clone()); + } + let (response, error) = match result { + Ok(r) => (Some(r), None), + Err(e) => (None, Some(e.message)), + }; + Ok(HttpExchange { + entry: HistoryEntry { + id: uuid::Uuid::new_v4().simple().to_string(), + time_ms, + name: request.name.clone(), + method: request.method.clone(), + url: request.url.clone(), + status: response.as_ref().map(|r| r.status), + total_ms: response.as_ref().map(|r| r.total_ms), + file: input.file.map(IpcPath::from), + error, + }, + request, + response, + }) +} + +/// Environment names available to `file`. +pub fn environments(file: Option<&Path>, root: Option<&Path>) -> IdeResult> { + Ok(Environments::load(file, root)?.names()) +} diff --git a/crates/ide-http/src/parse.rs b/crates/ide-http/src/parse.rs new file mode 100644 index 00000000..6fe3dfcc --- /dev/null +++ b/crates/ide-http/src/parse.rs @@ -0,0 +1,343 @@ +//! Parser for IntelliJ-style `.http` / `.rest` request files. +//! +//! ```text +//! @host = example.com # file variable +//! ### Get user # separator; the rest of the line names the request +//! # @name getUser # explicit name +//! GET https://{{host}}/users/1 +//! Accept: application/json +//! +//! { "body": true } +//! < ./payload.json # body include +//! ``` +//! +//! Response handlers (`> {% … %}`, `> file.js`), response redirects (`>> file`) +//! and references to previous responses (`<> file`) are recognised and skipped. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +pub const METHODS: &[&str] = &["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT"]; + +/// One request of a file, as shown by the gutter and the Run All list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HttpRequestBlock { + /// 0-based index among the file's requests. + pub index: u32, + pub name: Option, + pub method: String, + /// URL as written (variables not substituted). + pub url: String, + /// 0-based line of the request line. + pub line: u32, + /// First and last line (0-based, inclusive) of the block, separator excluded. + pub start_line: u32, + pub end_line: u32, +} + +/// A body line: literal text or `< path` (file contents). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BodyPart { + Text(String), + File(String), +} + +/// A request with every part still unsubstituted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawRequest { + pub block: HttpRequestBlock, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +/// Everything parsed from one file. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HttpFile { + /// `@name = value` file variables, in order (later wins). + pub variables: Vec<(String, String)>, + pub requests: Vec, +} + +fn is_separator(line: &str) -> bool { + line.trim_start().starts_with("###") +} + +fn is_comment(line: &str) -> bool { + let t = line.trim_start(); + t.starts_with('#') || t.starts_with("//") +} + +/// `# @name foo` / `// @name foo` / `# @name=foo`. +fn name_tag(line: &str) -> Option { + let t = line.trim_start(); + let rest = t.strip_prefix('#').or_else(|| t.strip_prefix("//"))?.trim_start(); + let rest = rest.strip_prefix("@name")?; + let name = rest.trim_start_matches([' ', '\t', '=']).trim(); + (!name.is_empty() && (rest.starts_with([' ', '\t', '=']))).then(|| name.to_owned()) +} + +/// `@name = value` (file variable). +fn file_variable(line: &str) -> Option<(String, String)> { + let rest = line.trim_start().strip_prefix('@')?; + let (name, value) = rest.split_once('=')?; + let name = name.trim(); + if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') { + return None; + } + Some((name.to_owned(), value.trim().to_owned())) +} + +/// `METHOD url [HTTP/x]` or a bare URL (GET). +fn request_line(line: &str) -> Option<(String, String)> { + let t = line.trim(); + if t.is_empty() { + return None; + } + let (first, rest) = match t.split_once(char::is_whitespace) { + Some((a, b)) => (a, b.trim()), + None => (t, ""), + }; + let (method, target) = if METHODS.contains(&first) { + if rest.is_empty() { + return None; + } + (first.to_owned(), rest) + } else if looks_like_url(t) { + ("GET".to_owned(), t) + } else { + return None; + }; + // Drop a trailing protocol version. + let url = match target.rsplit_once(char::is_whitespace) { + Some((u, v)) if v.starts_with("HTTP/") => u.trim(), + _ => target, + }; + Some((method, url.to_owned())) +} + +fn looks_like_url(t: &str) -> bool { + t.starts_with("http://") || t.starts_with("https://") || t.starts_with("{{") || t.starts_with('/') +} + +fn header(line: &str) -> Option<(String, String)> { + let (name, value) = line.split_once(':')?; + let name = name.trim(); + if name.is_empty() || name.contains(char::is_whitespace) { + return None; + } + Some((name.to_owned(), value.trim().to_owned())) +} + +/// Splits `text` into blocks separated by `###` lines: `(first line, lines)`. +fn blocks(text: &str) -> Vec<(u32, Option, Vec<&str>)> { + let mut out = Vec::new(); + let mut start = 0u32; + let mut title: Option = None; + let mut cur: Vec<&str> = Vec::new(); + for (i, raw) in text.split('\n').enumerate() { + let line = raw.strip_suffix('\r').unwrap_or(raw); + if is_separator(line) { + out.push((start, title.take(), std::mem::take(&mut cur))); + start = i as u32 + 1; + let t = line.trim_start().trim_start_matches('#').trim(); + title = (!t.is_empty()).then(|| t.to_owned()); + } else { + cur.push(line); + } + } + out.push((start, title, cur)); + out +} + +pub fn parse(text: &str) -> HttpFile { + let mut file = HttpFile::default(); + for (start, title, lines) in blocks(text) { + let mut name = title; + let mut i = 0; + // Preamble: comments, names, variables, blank lines. + let mut request = None; + while i < lines.len() { + let line = lines[i]; + if let Some(n) = name_tag(line) { + name = Some(n); + } else if let Some(v) = file_variable(line) { + file.variables.push(v); + } else if line.trim().is_empty() || is_comment(line) { + } else if let Some(r) = request_line(line) { + request = Some((i, r)); + i += 1; + break; + } + i += 1; + } + let Some((req_idx, (method, mut url))) = request else { continue }; + // Multi-line query: indented continuation lines. + while i < lines.len() { + let l = lines[i]; + let t = l.trim(); + if l.starts_with([' ', '\t']) && (t.starts_with('?') || t.starts_with('&') || t.starts_with('/')) { + url.push_str(t); + i += 1; + } else { + break; + } + } + let mut headers = Vec::new(); + while i < lines.len() { + let l = lines[i]; + if l.trim().is_empty() { + i += 1; + break; + } + if !is_comment(l) + && let Some(h) = header(l) + { + headers.push(h); + } + i += 1; + } + let body = body_parts(&lines[i.min(lines.len())..]); + // Trailing blank lines are not part of the block's visible extent. + let mut last = lines.len(); + while last > req_idx + 1 && lines[last - 1].trim().is_empty() { + last -= 1; + } + let index = file.requests.len() as u32; + file.requests.push(RawRequest { + block: HttpRequestBlock { + index, + name, + method, + url, + line: start + req_idx as u32, + start_line: start, + end_line: start + last.saturating_sub(1) as u32, + }, + headers, + body, + }); + } + file +} + +fn body_parts(lines: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut in_handler = false; + for &l in lines { + let t = l.trim_start(); + if in_handler { + if t.trim_end().ends_with("%}") { + in_handler = false; + } + continue; + } + if t.starts_with("> {%") { + in_handler = !t.trim_end().ends_with("%}"); + continue; + } + if t.starts_with("<>") || t.starts_with(">>") || t.starts_with("> ") { + continue; + } + if let Some(path) = t.strip_prefix("< ") { + out.push(BodyPart::File(path.trim().to_owned())); + } else { + out.push(BodyPart::Text(l.to_owned())); + } + } + while matches!(out.last(), Some(BodyPart::Text(t)) if t.trim().is_empty()) { + out.pop(); + } + while matches!(out.first(), Some(BodyPart::Text(t)) if t.trim().is_empty()) { + out.remove(0); + } + out +} + +/// The blocks only (gutter, Run All). +pub fn requests(text: &str) -> Vec { + parse(text).requests.into_iter().map(|r| r.block).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"@host = example.com +### Get user +# a comment +GET https://{{host}}/users/1 HTTP/1.1 +Accept: application/json +# not a header +X-Trace: 1 + +### +# @name create +POST https://{{host}}/users + ?page=1 + &size=2 +Content-Type: application/json + +{ + "a": 1 +} +< ./extra.json + +> {% + client.log(1) +%} + +### bare +https://example.com/health +"#; + + #[test] + fn parses_requests_names_and_blocks() { + let f = parse(SAMPLE); + assert_eq!(f.variables, vec![("host".into(), "example.com".into())]); + assert_eq!(f.requests.len(), 3); + let a = &f.requests[0]; + assert_eq!(a.block.name.as_deref(), Some("Get user")); + assert_eq!(a.block.method, "GET"); + assert_eq!(a.block.url, "https://{{host}}/users/1"); + assert_eq!(a.block.line, 3); + assert_eq!(a.headers, vec![("Accept".into(), "application/json".into()), ("X-Trace".into(), "1".into())]); + assert!(a.body.is_empty()); + + let b = &f.requests[1]; + assert_eq!(b.block.name.as_deref(), Some("create")); + assert_eq!(b.block.url, "https://{{host}}/users?page=1&size=2"); + assert_eq!(b.block.line, 10); + assert_eq!( + b.body, + vec![ + BodyPart::Text("{".into()), + BodyPart::Text(" \"a\": 1".into()), + BodyPart::Text("}".into()), + BodyPart::File("./extra.json".into()), + ] + ); + + let c = &f.requests[2]; + assert_eq!(c.block.method, "GET"); + assert_eq!(c.block.name.as_deref(), Some("bare")); + assert_eq!(c.block.url, "https://example.com/health"); + assert_eq!(c.block.start_line, 25); + assert_eq!(c.block.end_line, 25); + } + + #[test] + fn ignores_blocks_without_a_request_and_crlf() { + let f = parse("### only comments\r\n# nothing\r\n###\r\nDELETE /x\r\nA: b\r\n"); + assert_eq!(f.requests.len(), 1); + assert_eq!(f.requests[0].block.method, "DELETE"); + assert_eq!(f.requests[0].block.index, 0); + assert_eq!(f.requests[0].headers, vec![("A".into(), "b".into())]); + } + + #[test] + fn plain_text_is_not_a_request() { + assert!(parse("hello world\n").requests.is_empty()); + assert!(parse("GET\n").requests.is_empty()); + } +} diff --git a/crates/ide-index/Cargo.toml b/crates/ide-index/Cargo.toml new file mode 100644 index 00000000..ba89d06e --- /dev/null +++ b/crates/ide-index/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ide-index" +description = "Background project index: files and symbols, persisted in redb, fuzzy queried with nucleo" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +dunce.workspace = true +ide-core.workspace = true +ide-language.workspace = true +serde.workspace = true +specta.workspace = true +redb.workspace = true +nucleo-matcher.workspace = true +rayon.workspace = true +parking_lot.workspace = true +blake3.workspace = true +tracing.workspace = true +ignore.workspace = true +globset.workspace = true +postcard = { version = "1.1", default-features = false, features = ["use-std"] } + +[dev-dependencies] +tempfile.workspace = true +criterion = { version = "0.5", default-features = false } + +[[bench]] +name = "index" +harness = false diff --git a/crates/ide-index/benches/index.rs b/crates/ide-index/benches/index.rs new file mode 100644 index 00000000..d888e2c4 --- /dev/null +++ b/crates/ide-index/benches/index.rs @@ -0,0 +1,29 @@ +//! `cargo bench -p ide-index`: fuzzy file / symbol queries over a synthetic +//! 100k-file project (Search Everywhere budget: first results < 50 ms). + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use ide_index::SymbolFilter; +use ide_index::bench_support::SyntheticIndex; + +fn bench(c: &mut Criterion) { + let index = SyntheticIndex::new(100_000, 10); + assert_eq!(index.file_count(), 100_000); + + let mut g = c.benchmark_group("index_100k"); + g.sample_size(20); + for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] { + g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len()))); + } + for q in ["OCo", "invoiceRepo", "renderView", "q"] { + g.bench_function(format!("find_symbols/{q}"), |b| { + b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len())) + }); + } + g.bench_function("find_symbols_types/UsSe", |b| { + b.iter(|| black_box(index.find_symbols("UsSe", SymbolFilter::Types, 50).len())) + }); + g.finish(); +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/crates/ide-index/src/data.rs b/crates/ide-index/src/data.rs new file mode 100644 index 00000000..4dddf826 --- /dev/null +++ b/crates/ide-index/src/data.rs @@ -0,0 +1,258 @@ +//! In-memory index: one slot per file, symbols stored with their file. + +use ide_core::{Range, SymbolKind}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Bit set of the (lower-cased) ASCII letters/digits/punctuation present in a +/// string; a cheap pre-filter before fuzzy matching (query mask ⊆ haystack mask). +pub(crate) fn char_mask(s: &str) -> u64 { + let mut m = 0u64; + for b in s.bytes() { + m |= mask_bit(b); + } + m +} + +#[inline] +pub(crate) fn mask_bit(b: u8) -> u64 { + match b.to_ascii_lowercase() { + c @ b'a'..=b'z' => 1 << (c - b'a'), + c @ b'0'..=b'9' => 1 << (26 + c - b'0'), + b'_' => 1 << 36, + b'-' => 1 << 37, + b'.' => 1 << 38, + b'/' => 1 << 39, + b'$' => 1 << 40, + b':' => 1 << 41, + b'~' => 1 << 42, + b'#' => 1 << 43, + b if b >= 0x80 => 1 << 63, + _ => 0, + } +} + +/// Persisted per-file record (postcard in redb, keyed by the relative path). +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct FileRecord { + pub mtime: u64, + pub size: u64, + pub hash: [u8; 32], + pub symbols: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct StoredSymbol { + pub name: String, + pub kind: SymbolKind, + pub container: Option, + pub range: Range, +} + +pub(crate) struct Sym { + pub name: Box, + pub kind: SymbolKind, + pub container: Option>, + pub range: Range, + pub mask: u64, +} + +pub(crate) struct FileEntry { + pub path: PathBuf, + /// `/`-separated path relative to the root, as displayed. + pub rel: Box, + /// Byte offset of the file name inside `rel`. + pub name_start: u32, + /// Char offset of the file name inside `rel`. + pub name_start_chars: u32, + pub rel_mask: u64, + pub name_mask: u64, + pub mtime: u64, + pub size: u64, + pub hash: [u8; 32], + pub symbols: Box<[Sym]>, +} + +impl FileEntry { + pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self { + let name_start = rel.rfind('/').map_or(0, |i| i + 1); + let name = &rel[name_start..]; + let symbols = record + .symbols + .into_iter() + .map(|s| Sym { + mask: char_mask(&s.name), + name: s.name.into_boxed_str(), + kind: s.kind, + container: s.container.map(String::into_boxed_str), + range: s.range, + }) + .collect(); + Self { + name_start_chars: rel[..name_start].chars().count() as u32, + name_mask: char_mask(name), + rel_mask: char_mask(&rel), + name_start: name_start as u32, + path, + rel: rel.into_boxed_str(), + mtime: record.mtime, + size: record.size, + hash: record.hash, + symbols, + } + } + + pub(crate) fn name(&self) -> &str { + &self.rel[self.name_start as usize..] + } + + pub(crate) fn dir(&self) -> &str { + &self.rel[..self.name_start as usize] + } + + pub(crate) fn record(&self) -> FileRecord { + FileRecord { + mtime: self.mtime, + size: self.size, + hash: self.hash, + symbols: self + .symbols + .iter() + .map(|s| StoredSymbol { + name: s.name.to_string(), + kind: s.kind, + container: s.container.as_deref().map(str::to_owned), + range: s.range, + }) + .collect(), + } + } +} + +#[derive(Default)] +pub(crate) struct IndexData { + pub files: Vec>, + by_path: HashMap, + free: Vec, + pub file_count: u32, + pub symbol_count: u32, +} + +impl IndexData { + pub(crate) fn get(&self, path: &Path) -> Option<&FileEntry> { + self.by_path.get(path).and_then(|&i| self.files[i as usize].as_ref()) + } + + pub(crate) fn slot(&self, path: &Path) -> Option { + self.by_path.get(path).copied() + } + + pub(crate) fn insert(&mut self, entry: FileEntry) { + let symbols = entry.symbols.len() as u32; + if let Some(&slot) = self.by_path.get(&entry.path) { + let old = self.files[slot as usize].replace(entry); + self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32); + self.symbol_count += symbols; + return; + } + let slot = match self.free.pop() { + Some(slot) => { + self.files[slot as usize] = Some(entry); + slot + } + None => { + self.files.push(Some(entry)); + (self.files.len() - 1) as u32 + } + }; + let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default(); + self.by_path.insert(path, slot); + self.file_count += 1; + self.symbol_count += symbols; + } + + pub(crate) fn remove(&mut self, path: &Path) -> Option { + let slot = self.by_path.remove(path)?; + let old = self.files[slot as usize].take()?; + self.free.push(slot); + self.file_count -= 1; + self.symbol_count -= old.symbols.len() as u32; + Some(old) + } + + /// Removes every file at or below `dir`; returns their relative paths. + pub(crate) fn remove_tree(&mut self, dir: &Path) -> Vec { + let doomed: Vec = self.by_path.keys().filter(|p| p.starts_with(dir)).cloned().collect(); + doomed.iter().filter_map(|p| self.remove(p)).map(|e| e.rel.into_string()).collect() + } + + /// Releases spare capacity (after large deletions). + pub(crate) fn shrink(&mut self) { + while matches!(self.files.last(), Some(None)) { + self.files.pop(); + let last = self.files.len() as u32; + self.free.retain(|&s| s != last); + } + self.files.shrink_to_fit(); + self.free.shrink_to_fit(); + self.by_path.shrink_to_fit(); + } + + pub(crate) fn paths(&self) -> impl Iterator { + self.by_path.keys() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(path: &str, symbols: usize) -> FileEntry { + let record = FileRecord { + symbols: (0..symbols) + .map(|i| StoredSymbol { + name: format!("s{i}"), + kind: SymbolKind::Function, + container: None, + range: Range::default(), + }) + .collect(), + ..Default::default() + }; + FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record) + } + + #[test] + fn insert_replace_remove_keep_counts() { + let mut d = IndexData::default(); + d.insert(entry("a/b.rs", 2)); + d.insert(entry("c.rs", 1)); + assert_eq!((d.file_count, d.symbol_count), (2, 3)); + d.insert(entry("a/b.rs", 5)); + assert_eq!((d.file_count, d.symbol_count), (2, 6)); + assert!(d.remove(Path::new("/r/c.rs")).is_some()); + assert_eq!((d.file_count, d.symbol_count), (1, 5)); + d.insert(entry("d.rs", 0)); + assert_eq!(d.files.len(), 2, "slot reused"); + assert_eq!(d.remove_tree(Path::new("/r/a")), vec!["a/b.rs".to_owned()]); + assert_eq!((d.file_count, d.symbol_count), (1, 0)); + let e = d.get(Path::new("/r/d.rs")).unwrap(); + assert_eq!((e.name(), e.dir()), ("d.rs", "")); + d.insert(entry("e.rs", 1)); + d.insert(entry("f.rs", 1)); + d.remove(Path::new("/r/e.rs")); + d.remove(Path::new("/r/f.rs")); + d.shrink(); + assert_eq!(d.files.len(), 2, "trailing free slot dropped"); + assert_eq!(d.free, vec![0]); + d.insert(entry("g.rs", 1)); + assert_eq!((d.file_count, d.files.len()), (2, 2)); + } + + #[test] + fn masks() { + assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab")); + assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z")); + } +} diff --git a/crates/ide-index/src/filter.rs b/crates/ide-index/src/filter.rs new file mode 100644 index 00000000..3259ff79 --- /dev/null +++ b/crates/ide-index/src/filter.rs @@ -0,0 +1,223 @@ +//! Which paths belong in the index: `.gitignore`/`.ignore` rules (via the +//! `ignore` crate) plus the user's `files.exclude` globs. + +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use ignore::{WalkBuilder, WalkState}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::UNIX_EPOCH; + +/// VCS metadata directories are never indexed. +const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj", ".DS_Store"]; + +pub(crate) struct PathFilter { + root: PathBuf, + excludes: GlobSet, + /// Root-level ignore files, for paths reported by the watcher (the walker + /// applies nested ignore files itself). + gitignore: Gitignore, +} + +#[derive(Clone, Debug)] +pub(crate) struct Found { + pub path: PathBuf, + pub mtime: u64, + pub size: u64, +} + +pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 { + meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64) +} + +impl PathFilter { + pub(crate) fn new(root: &Path, excludes: &[String]) -> Self { + let mut set = GlobSetBuilder::new(); + for raw in excludes { + let p = raw.trim().trim_end_matches('/'); + let anchored = p.starts_with('/'); + let p = p.trim_start_matches("./").trim_start_matches('/'); + if p.is_empty() { + continue; + } + let mut patterns = vec![p.to_owned()]; + if !anchored && !p.contains('/') { + patterns.push(format!("**/{p}")); + } + for pat in patterns { + match GlobBuilder::new(&pat).literal_separator(true).build() { + Ok(g) => { + set.add(g); + } + Err(e) => tracing::warn!("invalid exclude glob {raw:?}: {e}"), + } + } + } + let excludes = set.build().unwrap_or_else(|_| GlobSet::empty()); + let mut gi = GitignoreBuilder::new(root); + for f in [".gitignore", ".ignore", ".git/info/exclude"] { + let path = root.join(f); + if path.is_file() + && let Some(e) = gi.add(&path) + { + tracing::debug!("{}: {e}", path.display()); + } + } + let gitignore = gi.build().unwrap_or_else(|_| Gitignore::empty()); + Self { root: root.to_owned(), excludes, gitignore } + } + + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// `/`-separated path relative to the root. + pub(crate) fn rel(&self, path: &Path) -> Option { + let rel = path.strip_prefix(&self.root).ok()?; + let mut out = String::new(); + for c in rel.components() { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&c.as_os_str().to_string_lossy()); + } + (!out.is_empty()).then_some(out) + } + + fn excluded_rel(&self, rel: &str) -> bool { + self.excludes.is_match(rel) + } + + fn skip_name(name: &str) -> bool { + ALWAYS_SKIP.contains(&name) + } + + /// Whether a path reported by the watcher should be ignored (outside the + /// root, inside an excluded/ignored directory, or ignored itself). + pub(crate) fn is_excluded(&self, path: &Path, is_dir: bool) -> bool { + let Some(rel) = self.rel(path) else { return true }; + let mut prefix = String::new(); + for seg in rel.split('/') { + if Self::skip_name(seg) { + return true; + } + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(seg); + if self.excluded_rel(&prefix) { + return true; + } + } + self.gitignore.matched_path_or_any_parents(path, is_dir).is_ignore() + } + + /// All indexable files at or below `dir`, walked in parallel. + /// Stops early (returning what was found so far) once `cancel` is set. + pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec { + let (tx, rx) = mpsc::channel::>(); + let root = self.root.clone(); + let excludes = self.excludes.clone(); + let walker = WalkBuilder::new(dir) + .hidden(false) + .parents(true) + .ignore(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .require_git(false) + .follow_links(false) + .filter_entry(move |e| { + let name = e.file_name().to_string_lossy(); + if Self::skip_name(&name) { + return false; + } + match e.path().strip_prefix(&root) { + Ok(rel) if !rel.as_os_str().is_empty() => { + let rel = rel.to_string_lossy(); + let rel = if std::path::MAIN_SEPARATOR == '/' { rel } else { rel.replace('\\', "/").into() }; + !excludes.is_match(rel.as_ref()) + } + _ => true, + } + }) + .build_parallel(); + walker.run(|| { + let mut batch = Batch { tx: tx.clone(), items: Vec::with_capacity(256) }; + Box::new(move |entry| { + if cancel.load(Ordering::Relaxed) { + return WalkState::Quit; + } + let Ok(entry) = entry else { return WalkState::Continue }; + if entry.file_type().is_some_and(|t| t.is_file()) + && let Ok(meta) = entry.metadata() + { + batch.items.push(Found { path: entry.into_path(), mtime: mtime_of(&meta), size: meta.len() }); + if batch.items.len() >= 256 && batch.tx.send(std::mem::take(&mut batch.items)).is_err() { + return WalkState::Quit; + } + } + WalkState::Continue + }) + }); + drop(tx); + rx.into_iter().flatten().collect() + } +} + +/// Per-walker-thread buffer; the remainder is sent when the walker drops the visitor. +struct Batch { + tx: mpsc::Sender>, + items: Vec, +} + +impl Drop for Batch { + fn drop(&mut self) { + if !self.items.is_empty() { + let _ = self.tx.send(std::mem::take(&mut self.items)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch(root: &Path, rel: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, "x").unwrap(); + } + + #[test] + fn walk_honours_gitignore_and_excludes() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + for f in [ + "src/a.rs", + "src/deep/b.rs", + "node_modules/x/index.js", + "target/debug/out.rs", + "build/gen.rs", + ".git/config", + ".github/workflows/ci.yml", + "docs/skip.md", + ] { + touch(&root, f); + } + std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); + let filter = PathFilter::new(&root, &["**/node_modules".into(), "target".into(), "docs/**".into()]); + let mut found: Vec = + filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect(); + found.sort(); + assert_eq!(found, vec![".github/workflows/ci.yml", ".gitignore", "src/a.rs", "src/deep/b.rs"]); + + assert!(filter.is_excluded(&root.join("node_modules/y.js"), false)); + assert!(filter.is_excluded(&root.join("a/target/z.rs"), false)); + assert!(filter.is_excluded(&root.join("build/new.rs"), false)); + assert!(filter.is_excluded(&root.join(".git/HEAD"), false)); + assert!(filter.is_excluded(Path::new("/elsewhere/x.rs"), false)); + assert!(!filter.is_excluded(&root.join("src/new.rs"), false)); + } +} diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs new file mode 100644 index 00000000..874407e9 --- /dev/null +++ b/crates/ide-index/src/lib.rs @@ -0,0 +1,285 @@ +//! Background project index (files + symbols). +//! +//! Indexing never blocks the UI: queries answer from whatever is indexed so far +//! and `IdeEvent::IndexState` tells the UI when it is complete ("dumb mode"). + +use ide_core::{IdeResult, MatchIndices, Range, SharedSink, SymbolKind}; +use ide_language::{LanguageRegistry, Syntax}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::thread::JoinHandle; +use std::time::Duration; + +mod data; +mod filter; +mod matching; +mod store; +mod worker; + +pub use worker::MAX_SYMBOL_FILE; +use worker::{Job, MAX_RECENT, Shared}; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileMatch { + pub path: ide_core::IpcPath, + /// Path relative to the project root, as displayed. + pub rel_path: String, + pub score: u32, + /// Matched char indices within `rel_path`. + pub indices: MatchIndices, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SymbolMatch { + pub name: String, + pub kind: SymbolKind, + pub container: Option, + pub path: ide_core::IpcPath, + pub rel_path: String, + pub range: Range, + pub score: u32, + /// Matched char indices within `name`. + pub indices: MatchIndices, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SymbolFilter { + All, + /// Go to Class (⌘O): classes, interfaces, structs, enums, traits, type aliases. + Types, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct IndexStatus { + pub indexing: bool, + pub files: u32, + pub symbols: u32, +} + +pub struct ProjectIndex { + shared: Arc, + worker: Mutex>>, +} + +impl ProjectIndex { + /// Opens (or creates) the persistent index at `cache_dir` and starts a + /// background full scan that skips files whose content hash is unchanged. + pub fn open( + root: &Path, + cache_dir: &Path, + excludes: Vec, + languages: Arc, + syntax: Arc, + sink: SharedSink, + ) -> IdeResult> { + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned()); + if !root.is_dir() { + return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display()))); + } + let store = store::Store::open(cache_dir, &root, &syntax.fingerprint()); + let filter = filter::PathFilter::new(&root, &excludes); + let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store)); + let worker = { + let shared = shared.clone(); + std::thread::Builder::new() + .name("openidea-index".into()) + .spawn(move || shared.run()) + .map_err(|e| ide_core::IdeError::internal(format!("cannot start indexer: {e}")))? + }; + Ok(Arc::new(Self { shared, worker: Mutex::new(Some(worker)) })) + } + + pub fn status(&self) -> IndexStatus { + let d = self.shared.data.read(); + IndexStatus { + indexing: self.shared.indexing.load(Ordering::SeqCst), + files: d.file_count, + symbols: d.symbol_count, + } + } + + /// Fuzzy file search. IDEA-like ranking: file-name matches beat directory + /// matches; `/` in the query matches path segments; recently opened files boost. + pub fn find_files(&self, query: &str, limit: usize) -> Vec { + let recent = self.shared.recent.lock().clone(); + let d = self.shared.data.read(); + matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit) + } + + /// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`). + pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec { + let recent = self.shared.recent.lock().clone(); + let d = self.shared.data.read(); + matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit) + } + + /// Incrementally re-indexes changed paths (called from the vfs watcher). + /// Cheap: the work is queued for the indexing thread. + pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) { + let changed: Vec = changed.iter().filter_map(|p| self.normalize(p)).collect(); + let removed: Vec = removed.iter().filter_map(|p| self.normalize(p)).collect(); + if !removed.is_empty() { + self.shared.enqueue(Job::Removed(removed)); + } + if !changed.is_empty() { + self.shared.enqueue(Job::Changed(changed)); + } + } + + /// Boost for "recent files" ranking. + /// Records a recently opened file; a virtual document's URI (library + /// class) is kept verbatim, like IDEA lists library classes in Recent Files. + pub fn mark_recent(&self, path: &Path) { + let path = match path.to_str() { + Some(s) if ide_core::text::is_virtual_uri(s) => path.to_owned(), + _ => match self.normalize(path) { + Some(p) => p, + None => return, + }, + }; + { + let mut r = self.shared.recent.lock(); + r.retain(|p| p != &path); + r.insert(0, path); + r.truncate(MAX_RECENT); + } + self.shared.enqueue(Job::SaveRecent); + } + + /// Recently opened files, most recent first. + pub fn recent_files(&self) -> Vec { + self.shared.recent.lock().clone() + } + + /// Blocks until the initial scan and every queued update has been applied + /// (tests, benchmarks, headless tools). Returns `false` on timeout. + pub fn wait_idle(&self, timeout: Duration) -> bool { + self.shared.wait_idle(timeout) + } + + /// Releases spare memory and compacts the on-disk cache ("Clear caches"). + /// Blocking; call off the UI thread. + pub fn compact(&self) { + self.shared.data.write().shrink(); + if let Some(store) = &self.shared.store + && let Err(e) = store.compact() + { + tracing::debug!("index compact: {e}"); + } + } + + pub fn root(&self) -> &Path { + self.shared.filter.root() + } + + /// Absolute path inside the root, or `None` for paths outside the project / excluded. + fn normalize(&self, path: &Path) -> Option { + let root = self.shared.filter.root(); + let path = if path.is_absolute() { path.to_owned() } else { root.join(path) }; + let path = if path.starts_with(root) { + path + } else { + // Watchers / the UI may report non-canonical spellings (`/tmp` vs `/private/tmp`). + match dunce::canonicalize(&path) { + Ok(p) => p, + Err(_) => { + let parent = dunce::canonicalize(path.parent()?).ok()?; + parent.join(path.file_name()?) + } + } + }; + (path.starts_with(root) && path != root).then_some(path) + } +} + +impl Drop for ProjectIndex { + fn drop(&mut self) { + self.shared.cancel(); + if let Some(handle) = self.worker.lock().take() { + let _ = handle.join(); + } + } +} + +#[cfg(test)] +mod tests; + +/// Synthetic in-memory index for benchmarks (`benches/index.rs`); not a stable API. +#[doc(hidden)] +pub mod bench_support { + use super::*; + use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol}; + + pub struct SyntheticIndex { + data: IndexData, + recent: Vec, + } + + const DIRS: &[&str] = &["src", "lib", "app", "core", "ui", "server", "client", "util", "test", "model"]; + const WORDS: &[&str] = &[ + "order", "user", "account", "payment", "invoice", "session", "cache", "event", "report", "search", "index", + "query", "parser", "token", "render", "layout", "widget", "store", "action", "service", + ]; + const SUFFIX: &[&str] = &["Controller", "Service", "Repository", "Factory", "Handler", "View", "Model", "Util"]; + + impl SyntheticIndex { + /// `files` files spread over nested directories, each with `symbols_per_file` symbols. + pub fn new(files: usize, symbols_per_file: usize) -> Self { + let mut data = IndexData::default(); + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n as u64) as usize + }; + for i in 0..files { + let depth = 1 + next(4); + let mut rel = String::new(); + for _ in 0..depth { + rel.push_str(DIRS[next(DIRS.len())]); + rel.push('/'); + } + let w = WORDS[next(WORDS.len())]; + let s = SUFFIX[next(SUFFIX.len())]; + rel.push_str(&format!("{w}{s}{i}.rs")); + let symbols = (0..symbols_per_file) + .map(|k| StoredSymbol { + name: format!("{}{}{k}", WORDS[next(WORDS.len())], SUFFIX[next(SUFFIX.len())]), + kind: if k % 3 == 0 { SymbolKind::Class } else { SymbolKind::Method }, + container: (k % 3 != 0).then(|| format!("{w}{s}")), + range: Range::default(), + }) + .collect(); + let record = FileRecord { symbols, ..Default::default() }; + data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record)); + } + let recent = data.paths().take(MAX_RECENT).cloned().collect(); + Self { data, recent } + } + + pub fn file_count(&self) -> u32 { + self.data.file_count + } + + pub fn symbol_count(&self) -> u32 { + self.data.symbol_count + } + + pub fn find_files(&self, query: &str, limit: usize) -> Vec { + matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit) + } + + pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec { + matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit) + } + } +} diff --git a/crates/ide-index/src/matching.rs b/crates/ide-index/src/matching.rs new file mode 100644 index 00000000..8d066717 --- /dev/null +++ b/crates/ide-index/src/matching.rs @@ -0,0 +1,441 @@ +//! IDEA-like fuzzy ranking over the in-memory index. +//! +//! Two phases: a parallel scan computes a cheap score for every candidate +//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best +//! few; the survivors then get match indices, CamelHump and recency boosts and +//! are re-sorted. + +use crate::data::{FileEntry, IndexData, char_mask}; +use crate::{FileMatch, SymbolFilter, SymbolMatch}; +use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String}; +use rayon::prelude::*; +use std::cell::RefCell; +use std::cmp::Ordering; +use std::path::PathBuf; + +const TIER_NAME: u32 = 1 << 20; +const EXACT: u32 = 4000; +const EXACT_STEM: u32 = 3000; +const PREFIX: u32 = 600; +const CASE_EXACT: u32 = 200; +const HUMP: u32 = 12; +const FIRST_AT_START: u32 = 40; +const RECENT_MAX: u32 = 900; +const TYPE_LIKE: u32 = 20; + +thread_local! { + static MATCHER: RefCell = RefCell::new(Matcher::new(Config::DEFAULT.match_paths())); +} + +fn with_matcher(f: impl FnOnce(&mut Matcher) -> R) -> R { + MATCHER.with(|m| f(&mut m.borrow_mut())) +} + +struct Needle { + /// Lower-cased query text. + lower: String, + original: String, + utf32: Utf32String, + mask: u64, +} + +impl Needle { + fn new(s: &str) -> Self { + let lower = s.to_lowercase(); + Self { mask: char_mask(&lower), utf32: Utf32String::from(lower.as_str()), lower, original: s.to_owned() } + } + + fn is_empty(&self) -> bool { + self.lower.is_empty() + } + + fn score(&self, m: &mut Matcher, hay: &str, buf: &mut Vec) -> Option { + m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from) + } + + fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec, out: &mut Vec) -> Option { + out.clear(); + let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from); + out.sort_unstable(); + out.dedup(); + s + } + + /// Exact / prefix boosts against a candidate name. + fn name_boost(&self, name: &str) -> u32 { + let (q, n) = (self.lower.as_bytes(), name.as_bytes()); + let mut b = 0; + if n.eq_ignore_ascii_case(q) { + b += EXACT; + } else if let Some(dot) = name.find('.') + && name.as_bytes()[..dot].eq_ignore_ascii_case(q) + { + b += EXACT_STEM; + } else if n.len() >= q.len() && n[..q.len()].eq_ignore_ascii_case(q) { + b += PREFIX; + } + if b > 0 && name.as_bytes().starts_with(self.original.as_bytes()) { + b += CASE_EXACT; + } + b + } +} + +/// Word starts in `s` (char indices): start, after a separator, lower→Upper, letter→digit. +fn hump_bonus(s: &str, indices: &[u32]) -> u32 { + if indices.is_empty() { + return 0; + } + let chars: Vec = s.chars().collect(); + let is_start = |i: usize| { + if i == 0 { + return true; + } + let (p, c) = (chars[i - 1], chars[i]); + !p.is_alphanumeric() || (p.is_lowercase() && c.is_uppercase()) || (!p.is_ascii_digit() && c.is_ascii_digit()) + }; + let humps = indices.iter().filter(|&&i| (i as usize) < chars.len() && is_start(i as usize)).count() as u32; + let first = if is_start(indices[0] as usize) { FIRST_AT_START } else { 0 }; + humps * HUMP + first +} + +#[derive(Clone, Copy)] +struct Cand { + slot: u32, + sym: u32, + score: u32, +} + +fn keep_best(mut v: Vec, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec { + if v.len() > k { + v.select_nth_unstable_by(k, |a, b| b.score.cmp(&a.score).then_with(|| tiebreak(a, b))); + v.truncate(k); + } + v +} + +fn scan( + data: &IndexData, + pool: usize, + tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync), + per_file: impl Fn(u32, &FileEntry, &mut Vec) + Sync, +) -> Vec { + data.files + .par_iter() + .enumerate() + .with_min_len(256) + .fold(Vec::new, |mut acc, (slot, e)| { + if let Some(e) = e { + per_file(slot as u32, e, &mut acc); + if acc.len() >= pool * 2 { + acc = keep_best(acc, pool, tiebreak); + } + } + acc + }) + .reduce(Vec::new, |mut a, b| { + a.extend(b); + if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a } + }) +} + +pub(crate) struct Recents<'a> { + pub paths: &'a [PathBuf], +} + +impl Recents<'_> { + fn rank(&self, path: &std::path::Path) -> Option { + self.paths.iter().position(|p| p == path) + } + + fn bonus(&self, path: &std::path::Path) -> u32 { + self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12)) + } +} + +/// `Foo.kt:12:3` -> `Foo.kt`; `\` -> `/`; strips leading `./` and `/`. +fn normalize_file_query(q: &str) -> String { + let mut q = q.trim().replace('\\', "/"); + while let Some((head, tail)) = q.rsplit_once(':') + && !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + { + q = head.to_owned(); + } + let q = q.trim_start_matches("./").trim_start_matches('/'); + q.chars().filter(|c| !c.is_whitespace()).collect() +} + +enum FileQuery { + Name(Needle), + Path { dir: Needle, name: Needle }, +} + +pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + let q = normalize_file_query(query); + if q.is_empty() { + return recents + .paths + .iter() + .filter_map(|p| data.get(p)) + .take(limit) + .enumerate() + .map(|(i, e)| FileMatch { + path: e.path.clone().into(), + rel_path: e.rel.to_string(), + score: (RECENT_MAX - i as u32).max(1), + indices: Vec::new(), + }) + .collect(); + } + let fq = match q.rsplit_once('/') { + Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) }, + None => FileQuery::Name(Needle::new(&q)), + }; + let tiebreak = |a: &Cand, b: &Cand| { + let (ea, eb) = (file(data, a.slot), file(data, b.slot)); + ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel)) + }; + let pool = limit * 4 + 64; + let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| { + let mut buf = Vec::new(); + if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) { + acc.push(Cand { slot, sym: u32::MAX, score }); + } + }); + // Recent files always compete for the final ranking. + for p in recents.paths { + if let Some(e) = data.get(p) { + let Some(slot) = data.slot(p) else { continue }; + if !cands.iter().any(|c| c.slot == slot) { + let mut buf = Vec::new(); + if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) { + cands.push(Cand { slot, sym: u32::MAX, score }); + } + } + } + } + let mut out: Vec<(u32, &FileEntry, Vec)> = with_matcher(|m| { + let mut buf = Vec::new(); + cands + .iter() + .filter_map(|c| { + let e = file(data, c.slot); + let indices = file_indices(m, &fq, e, &mut buf)?; + let display_bonus = match &fq { + FileQuery::Name(_) if c.score >= TIER_NAME => { + let name_idx: Vec = indices.iter().map(|i| i - e.name_start_chars).collect(); + hump_bonus(e.name(), &name_idx) + } + _ => hump_bonus(&e.rel, &indices), + }; + Some((c.score + display_bonus + recents.bonus(&e.path), e, indices)) + }) + .collect() + }); + out.sort_by(|a, b| { + b.0.cmp(&a.0).then_with(|| a.1.rel.len().cmp(&b.1.rel.len())).then_with(|| a.1.rel.cmp(&b.1.rel)) + }); + out.truncate(limit); + out.into_iter() + .map(|(score, e, indices)| FileMatch { + path: e.path.clone().into(), + rel_path: e.rel.to_string(), + score, + indices, + }) + .collect() +} + +fn file(data: &IndexData, slot: u32) -> &FileEntry { + data.files[slot as usize].as_ref().expect("candidate slots are live") +} + +fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec) -> Option { + match q { + FileQuery::Name(n) => { + if n.mask & e.name_mask == n.mask + && let Some(s) = n.score(m, e.name(), buf) + { + return Some(TIER_NAME + s * 4 + n.name_boost(e.name())); + } + if n.mask & e.rel_mask == n.mask { + return n.score(m, &e.rel, buf); + } + None + } + FileQuery::Path { dir, name } => { + let name_score = if name.is_empty() { + 0 + } else { + if name.mask & e.name_mask != name.mask { + return None; + } + name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name()) + }; + let dir_str = e.dir(); + if dir.mask & char_mask(dir_str) != dir.mask { + return None; + } + let dir_score = dir.score(m, dir_str, buf)?; + Some(TIER_NAME + name_score + dir_score) + } + } +} + +fn file_indices(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec) -> Option> { + let mut idx = Vec::new(); + match q { + FileQuery::Name(n) => { + if n.indices(m, e.name(), buf, &mut idx).is_some() { + return Some(idx.into_iter().map(|i| i + e.name_start_chars).collect()); + } + n.indices(m, &e.rel, buf, &mut idx)?; + Some(idx) + } + FileQuery::Path { dir, name } => { + dir.indices(m, e.dir(), buf, &mut idx)?; + let mut out = idx.clone(); + if !name.is_empty() { + name.indices(m, e.name(), buf, &mut idx)?; + out.extend(idx.iter().map(|i| i + e.name_start_chars)); + } + Some(out) + } + } +} + +fn symbol_score( + m: &mut Matcher, + name: &Needle, + container: Option<&Needle>, + s: &crate::data::Sym, + buf: &mut Vec, +) -> Option { + if name.mask & s.mask != name.mask { + return None; + } + let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 }; + score += name.name_boost(&s.name); + if let Some(cq) = container { + let c = s.container.as_deref()?; + if cq.mask & char_mask(c) != cq.mask { + return None; + } + score += cq.score(m, c, buf)? + cq.name_boost(c) / 4; + } + if s.kind.is_type_like() { + score += TYPE_LIKE; + } + Some(score) +} + +/// `Foo.bar`, `Foo::bar`, `Foo#bar` -> (container `Foo`, name `bar`). +fn split_symbol_query(q: &str) -> (Option<&str>, &str) { + for sep in ["::", "#", "."] { + if let Some((c, n)) = q.rsplit_once(sep) + && !c.is_empty() + { + return (Some(c.rsplit(['.', ':', '#']).next().unwrap_or(c)), n); + } + } + (None, q) +} + +pub(crate) fn find_symbols( + data: &IndexData, + recents: &Recents, + query: &str, + filter: SymbolFilter, + limit: usize, +) -> Vec { + let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect(); + if q.is_empty() || limit == 0 { + return Vec::new(); + } + let (container_q, name_q) = split_symbol_query(&q); + let name = Needle::new(name_q); + let container = container_q.map(Needle::new); + let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize]; + let tiebreak = |a: &Cand, b: &Cand| { + let (sa, sb) = (sym(a), sym(b)); + sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel)) + }; + let pool = limit * 4 + 64; + let cands = scan(data, pool, &tiebreak, |slot, e, acc| { + if e.symbols.is_empty() { + return; + } + with_matcher(|m| { + let mut buf = Vec::new(); + for (i, s) in e.symbols.iter().enumerate() { + if filter == SymbolFilter::Types && !s.kind.is_type_like() { + continue; + } + if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) { + acc.push(Cand { slot, sym: i as u32, score }); + } + } + }); + }); + let mut out: Vec<(u32, Cand, Vec)> = with_matcher(|m| { + let mut buf = Vec::new(); + cands + .into_iter() + .filter_map(|c| { + let s = sym(&c); + let e = file(data, c.slot); + let mut idx = Vec::new(); + if !name.is_empty() { + name.indices(m, &s.name, &mut buf, &mut idx)?; + } + let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4; + Some((c.score + bonus, c, idx)) + }) + .collect() + }); + out.sort_by(|a, b| { + b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range)) + }); + out.truncate(limit); + out.into_iter() + .map(|(score, c, indices)| { + let e = file(data, c.slot); + let s = &e.symbols[c.sym as usize]; + SymbolMatch { + name: s.name.to_string(), + kind: s.kind, + container: s.container.as_deref().map(str::to_owned), + path: e.path.clone().into(), + rel_path: e.rel.to_string(), + range: s.range, + score, + indices, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_queries() { + assert_eq!(normalize_file_query(" ./src\\main.rs:12:4 "), "src/main.rs"); + assert_eq!(normalize_file_query("/a b"), "ab"); + assert_eq!(split_symbol_query("Order.save"), (Some("Order"), "save")); + assert_eq!(split_symbol_query("a::b::C::new"), (Some("C"), "new")); + assert_eq!(split_symbol_query("save"), (None, "save")); + assert_eq!(split_symbol_query(".save"), (None, ".save")); + } + + #[test] + fn humps() { + assert!(hump_bonus("OrderController", &[0, 5, 6]) > hump_bonus("OrderController", &[0, 3, 6])); + assert_eq!(hump_bonus("abc", &[]), 0); + } +} diff --git a/crates/ide-index/src/store.rs b/crates/ide-index/src/store.rs new file mode 100644 index 00000000..7419c709 --- /dev/null +++ b/crates/ide-index/src/store.rs @@ -0,0 +1,186 @@ +//! Persistence of file records in redb, so a restart only re-parses changed files. + +use crate::data::FileRecord; +use parking_lot::RwLock; +use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError}; +use std::path::Path; + +const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files"); +const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); +/// Bump when [`FileRecord`] or its encoding changes. +const SCHEMA: &str = "openidea-index-2"; + +type Result = std::result::Result; + +fn err(e: impl std::fmt::Display) -> String { + e.to_string() +} + +pub(crate) struct Store { + /// Write-locked only for [`Store::compact`]. + db: RwLock, +} + +impl Store { + /// Opens (or creates) `cache_dir/index.redb`. Records written by another + /// schema / extractor version or for another root are discarded. Returns + /// `None` when the database cannot be used (e.g. locked by another window); + /// the index then runs in memory only. + pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option { + if let Err(e) = std::fs::create_dir_all(cache_dir) { + tracing::warn!("index cache {}: {e}", cache_dir.display()); + return None; + } + let file = cache_dir.join("index.redb"); + let db = match Database::create(&file) { + Ok(db) => db, + Err(redb::DatabaseError::DatabaseAlreadyOpen) => { + tracing::warn!("index cache {} is in use; indexing in memory only", file.display()); + return None; + } + Err(e) => { + tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display()); + let _ = std::fs::remove_file(&file); + Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()? + } + }; + let store = Self { db: RwLock::new(db) }; + let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display()); + match store.meta("stamp") { + Ok(Some(v)) if v == stamp.as_bytes() => {} + _ => { + if let Err(e) = store.reset(&stamp) { + tracing::warn!("index cache reset failed: {e}"); + return None; + } + } + } + Some(store) + } + + fn meta(&self, key: &str) -> Result>> { + let txn = self.db.read().begin_read().map_err(err)?; + let table = match txn.open_table(META) { + Ok(t) => t, + Err(TableError::TableDoesNotExist(_)) => return Ok(None), + Err(e) => return Err(err(e)), + }; + Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec())) + } + + fn reset(&self, stamp: &str) -> Result<()> { + let txn = self.db.read().begin_write().map_err(err)?; + match txn.delete_table(FILES) { + Ok(_) | Err(TableError::TableDoesNotExist(_)) => {} + Err(e) => return Err(err(e)), + } + { + let mut meta = txn.open_table(META).map_err(err)?; + meta.insert("stamp", stamp.as_bytes()).map_err(err)?; + txn.open_table(FILES).map_err(err)?; + } + txn.commit().map_err(err) + } + + /// All persisted records; undecodable entries are skipped (and re-indexed). + pub(crate) fn load(&self) -> Result> { + let txn = self.db.read().begin_read().map_err(err)?; + let table = match txn.open_table(FILES) { + Ok(t) => t, + Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()), + Err(e) => return Err(err(e)), + }; + let mut out = Vec::new(); + for item in table.iter().map_err(err)? { + let (k, v) = item.map_err(err)?; + if let Ok(rec) = postcard::from_bytes::(v.value()) { + out.push((k.value().to_owned(), rec)); + } + } + Ok(out) + } + + /// Applies upserts and deletions in one transaction. Non-durable commits + /// are cheap (no fsync) and become durable with the next durable one. + pub(crate) fn apply(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) -> Result<()> { + let mut txn = self.db.read().begin_write().map_err(err)?; + if !durable { + txn.set_durability(Durability::None).map_err(err)?; + } + { + let mut table = txn.open_table(FILES).map_err(err)?; + let mut buf = Vec::new(); + for (rel, rec) in puts { + buf.clear(); + let bytes = postcard::to_extend(rec, std::mem::take(&mut buf)).map_err(err)?; + table.insert(rel.as_str(), bytes.as_slice()).map_err(err)?; + buf = bytes; + } + for rel in deletes { + table.remove(rel.as_str()).map_err(err)?; + } + } + txn.commit().map_err(err) + } + + /// Reclaims free pages in the database file; `true` if anything was compacted. + pub(crate) fn compact(&self) -> Result { + self.db.write().compact().map_err(err) + } + + pub(crate) fn load_recent(&self) -> Vec { + self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default() + } + + pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> { + let bytes = postcard::to_allocvec(recent).map_err(err)?; + self.set_meta("recent", &bytes) + } + + fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> { + let txn = self.db.read().begin_write().map_err(err)?; + txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?; + txn.commit().map_err(err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_and_reset_on_new_fingerprint() { + let dir = tempfile::tempdir().unwrap(); + let root = Path::new("/proj"); + { + let s = Store::open(dir.path(), root, "fp1").unwrap(); + let rec = FileRecord { mtime: 7, size: 3, ..Default::default() }; + s.apply(&[("a.rs".into(), rec.clone()), ("b.rs".into(), rec)], &[], false).unwrap(); + s.apply(&[], &["b.rs".into()], true).unwrap(); + s.save_recent(&["a.rs".into()]).unwrap(); + } + { + let s = Store::open(dir.path(), root, "fp1").unwrap(); + let all = s.load().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!((all[0].0.as_str(), all[0].1.mtime), ("a.rs", 7)); + assert_eq!(s.load_recent(), vec!["a.rs".to_owned()]); + } + let s = Store::open(dir.path(), root, "fp2").unwrap(); + assert!(s.load().unwrap().is_empty()); + let recs: Vec<(String, FileRecord)> = (0..500).map(|i| (format!("f{i}.rs"), FileRecord::default())).collect(); + s.apply(&recs, &[], true).unwrap(); + let dels: Vec = recs.iter().map(|(k, _)| k.clone()).collect(); + s.apply(&[], &dels, true).unwrap(); + s.compact().unwrap(); + assert!(s.load().unwrap().is_empty()); + } + + #[test] + fn corrupt_file_is_rebuilt() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.redb"), b"garbage, not a database").unwrap(); + let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap(); + assert!(s.load().unwrap().is_empty()); + } +} diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs new file mode 100644 index 00000000..c815fcbb --- /dev/null +++ b/crates/ide-index/src/tests.rs @@ -0,0 +1,383 @@ +use super::*; +use ide_core::{IdeEvent, NullSink}; +use std::sync::atomic::Ordering; + +const WAIT: Duration = Duration::from_secs(30); + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + cache: PathBuf, +} + +impl Fixture { + fn new(files: &[(&str, &str)]) -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + let root = dunce::canonicalize(root).unwrap(); + let cache = dir.path().join("cache"); + let f = Self { _dir: dir, root, cache }; + for (rel, text) in files { + f.write(rel, text); + } + f + } + + fn write(&self, rel: &str, text: &str) -> PathBuf { + let p = self.root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, text).unwrap(); + p + } + + fn open_with(&self, sink: SharedSink) -> Arc { + let languages = Arc::new(LanguageRegistry::load(None).unwrap()); + let syntax = Arc::new(Syntax::new()); + let idx = ProjectIndex::open( + &self.root, + &self.cache, + vec!["**/node_modules".into(), "**/target".into()], + languages, + syntax, + sink, + ) + .unwrap(); + assert!(idx.wait_idle(WAIT), "indexing did not finish"); + idx + } + + fn open(&self) -> Arc { + self.open_with(Arc::new(NullSink)) + } +} + +fn files(idx: &ProjectIndex, q: &str) -> Vec { + idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect() +} + +fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec { + idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect() +} + +fn sample() -> Fixture { + Fixture::new(&[ + ("src/main.rs", "fn main() {}\nstruct Config { port: u16 }\nimpl Config { fn load() -> Self { todo!() } }\n"), + ("src/order/OrderController.java", "class OrderController {\n void save() {}\n void cancel() {}\n}\n"), + ("src/order/Order.java", "class Order { int id; }\n"), + ("src/other/OtherCollection.ts", "export class OtherCollection {\n add(x: number) {}\n}\n"), + ("src/ui/Button.tsx", "export function Button() { return + + + + )} + + ); +} diff --git a/src/editor/core/view/GotoLineDialog.tsx b/src/editor/core/view/GotoLineDialog.tsx new file mode 100644 index 00000000..12d44492 --- /dev/null +++ b/src/editor/core/view/GotoLineDialog.tsx @@ -0,0 +1,61 @@ +/** ⌘L: "Go to Line:Column" (1-based `line[:col]`). */ +import { useState, type FormEvent } from "react"; +import { Dialog, Input } from "@/ui"; +import { editorController, useEditorStatus, useGotoLine } from "../controller"; +import styles from "./EditorArea.module.css"; + +export function parseLineCol(text: string): { line: number; col: number } | null { + const m = /^\s*(\d+)\s*(?:[:,]\s*(\d+))?\s*$/.exec(text); + if (!m) return null; + const line = Number(m[1]); + const col = m[2] ? Number(m[2]) : 1; + if (line < 1 || col < 1) return null; + return { line: line - 1, col: col - 1 }; +} + +export function GotoLineDialog() { + const open = useGotoLine((s) => s.open); + if (!open) return null; + return ; +} + +function GotoLineInner() { + const status = useEditorStatus((s) => s.status); + const [value, setValue] = useState(() => (status ? `${status.line + 1}:${status.col + 1}` : "")); + const parsed = parseLineCol(value); + const close = () => useGotoLine.setState({ open: false }); + const submit = (e?: FormEvent) => { + e?.preventDefault(); + if (!parsed || !status) return; + close(); + void editorController().openFile(status.path, { selection: parsed, focus: true }); + }; + return ( + submit() }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+ setValue(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + aria-label="Line and column" + placeholder="line[:column]" + /> + + {status ? `Current position ${status.line + 1}:${status.col + 1}` : "No active editor"} + +
+
+ ); +} diff --git a/src/editor/core/view/NavBarPopup.module.css b/src/editor/core/view/NavBarPopup.module.css new file mode 100644 index 00000000..fff146b3 --- /dev/null +++ b/src/editor/core/view/NavBarPopup.module.css @@ -0,0 +1,79 @@ +.popup { + max-width: min(90vw, 900px); + padding: 0; +} +.root { + outline: none; + display: flex; + flex-direction: column; +} +.bar { + display: flex; + align-items: center; + gap: 0; + padding: 3px 4px; + overflow-x: auto; + white-space: nowrap; +} +.segmentWrap { + display: inline-flex; + align-items: center; + flex: none; +} +.sep { + color: var(--fg-faint); + margin: 0 1px; +} +.segment { + display: inline-flex; + align-items: center; + gap: 5px; + height: var(--h-control-small); + padding: 0 5px; + border: none; + border-radius: var(--radius-small); + background: transparent; + color: var(--fg); + font: inherit; + font-size: var(--fs-ui); + cursor: default; +} +.segment:hover { + background: var(--bg-hover); +} +.active, +.active:hover { + background: var(--bg-list-selected); + color: var(--fg-strong); +} +.list { + align-self: flex-start; + min-width: 200px; + max-height: 360px; + overflow-y: auto; + margin: 0 4px 4px; + padding: 4px; + border-top: 1px solid var(--border-popup); +} +.row { + display: flex; + align-items: center; + gap: 6px; + height: var(--h-list-row); + padding: 0 6px; + border-radius: var(--radius-row); + color: var(--fg); + white-space: nowrap; +} +.rowActive { + background: var(--bg-list-selected); + color: var(--fg-strong); +} +.rowLabel { + overflow: hidden; + text-overflow: ellipsis; +} +.empty { + padding: 4px 6px; + color: var(--fg-muted); +} diff --git a/src/editor/core/view/NavBarPopup.tsx b/src/editor/core/view/NavBarPopup.tsx new file mode 100644 index 00000000..a08a6fe0 --- /dev/null +++ b/src/editor/core/view/NavBarPopup.tsx @@ -0,0 +1,170 @@ +/** + * Jump to Navigation Bar (⌘↑): IDEA's floating navigation bar over the + * editor. ←/→ move between segments, ↓ / Enter open the segment's children + * (folder contents, file / symbol members); Enter on a child opens it (a + * folder becomes the next segment), Escape closes the list, then the bar. + */ +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import clsx from "clsx"; +import { create } from "zustand"; +import { call, commands, isTauri, type OutlineItem } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { Icon, Popup } from "@/ui"; +import { editorController, useEditorStatus } from "../controller"; +import { useOutline } from "../model/document"; +import { symbolChain } from "./outline"; +import { dirChildren, iconOf, navSegments, stepSegment, symbolChildren, type NavChild, type NavSegment } from "./navBar"; +import styles from "./NavBarPopup.module.css"; + +export const useNavBarPopup = create<{ open: boolean; seq: number }>(() => ({ open: false, seq: 0 })); + +export function openNavBarPopup() { + useNavBarPopup.setState((s) => ({ open: true, seq: s.seq + 1 })); +} + +const close = () => useNavBarPopup.setState({ open: false }); + +export function NavBarPopup() { + const open = useNavBarPopup((s) => s.open); + const seq = useNavBarPopup((s) => s.seq); + const status = useEditorStatus((s) => s.status); + if (!open || !status) return null; + return ; +} + +async function childrenOf(seg: NavSegment, outline: readonly OutlineItem[] | undefined): Promise { + if (seg.kind === "dir") return isTauri() ? dirChildren(await call(commands.fsReadDir(seg.path))) : []; + if (seg.kind === "file") return symbolChildren(seg.path, outline ?? []); + return symbolChildren(seg.path, seg.item.children); +} + +function anchorPoint(): { x: number; y: number } { + const ed = editorController().activeSlot?.view?.dom; + const r = (ed ?? document.querySelector("[data-editor-area]"))?.getBoundingClientRect(); + return r ? { x: r.left + 8, y: r.top + 4 } : { x: 80, y: 80 }; +} + +function NavBar({ path, line, col }: { path: string; line: number; col: number }) { + const project = useProject((s) => s.project); + const outline = useOutline((s) => s[path]); + const initial = useMemo( + () => navSegments(project ? { root: project.root, name: project.name } : null, path, outline ? symbolChain(outline, line, col) : []), + // The bar is a snapshot of where ⌘↑ was pressed. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + const [segments, setSegments] = useState(initial); + const [active, setActive] = useState(() => initial.findIndex((s) => s.kind === "file")); + const [list, setList] = useState<{ seg: number; items: NavChild[]; active: number; left: number } | null>(null); + const [anchor] = useState(anchorPoint); + const buttons = useRef>([]); + const listRef = useRef(null); + const request = useRef(0); + + const openList = async (index: number, segs = segments) => { + const seg = segs[index]; + if (!seg) return; + const id = ++request.current; + const items = await childrenOf(seg, outline).catch((): NavChild[] => []); + if (id !== request.current) return; + // Start on the child that leads to the next segment, like IDEA. + const next = segs[index + 1]; + const at = next ? items.findIndex((c) => c.path === next.path && c.label === next.label) : -1; + setList({ seg: index, items, active: Math.max(0, at), left: buttons.current[index]?.offsetLeft ?? 0 }); + }; + + const choose = (child: NavChild) => { + if (!list) return; + if (child.kind === "dir") { + const segs: NavSegment[] = [...segments.slice(0, list.seg + 1), { kind: "dir", path: child.path, label: child.label }]; + setSegments(segs); + setActive(segs.length - 1); + void openList(segs.length - 1, segs); + return; + } + close(); + if (child.kind === "file") void editorController().openFile(child.path, { focus: true }); + else { + const p = child.item.selection_range.start; + void editorController().openFile(child.path, { selection: { line: p.line, col: p.col }, focus: true }); + } + }; + + const move = (delta: number) => { + const next = stepSegment(active, segments.length, delta); + setActive(next); + if (list) void openList(next); + }; + + const onKeyDown = (e: KeyboardEvent) => { + let handled = true; + if (e.key === "ArrowLeft") move(-1); + else if (e.key === "ArrowRight") move(1); + else if (e.key === "ArrowDown" && !list) void openList(active); + else if (e.key === "ArrowDown" && list) setList({ ...list, active: Math.min(list.items.length - 1, list.active + 1) }); + else if (e.key === "ArrowUp" && list) setList({ ...list, active: Math.max(0, list.active - 1) }); + else if (e.key === "Enter" && list) { + const child = list.items[list.active]; + if (child) choose(child); + } else if (e.key === "Enter") void openList(active); + else if (e.key === "Escape" && list) setList(null); + else handled = false; + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + useLayoutEffect(() => { + buttons.current[active]?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + }, [active]); + useEffect(() => { + listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest" }); + }, [list]); + + return ( + +
+
+ {segments.map((s, i) => ( + + {i > 0 && } + + + ))} +
+ {list && ( +
+ {list.items.length === 0 &&
Nothing here
} + {list.items.map((c, i) => ( +
setList({ ...list, active: i })} + onClick={() => choose(c)} + > + + {c.label} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/editor/core/view/StatusItems.tsx b/src/editor/core/view/StatusItems.tsx new file mode 100644 index 00000000..ced7676d --- /dev/null +++ b/src/editor/core/view/StatusItems.tsx @@ -0,0 +1,353 @@ +/** Status bar widgets of the active text editor (design footer). */ +import { Fragment, useMemo, useRef, useState } from "react"; +import clsx from "clsx"; +import { executeAction } from "@/platform/actions"; +import { shortcutLabel } from "@/platform/keymap"; +import { basename, dirname, relativePath, useProject } from "@/platform/project"; +import type { LineEnding } from "@/platform/ipc"; +import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui"; +import { notifications } from "@/workbench/contributions"; +import { ENCODING_CHOICES, useEditorPrefs } from "@/platform/editorSettings"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { editorController, useEditorStatus, useGotoLine } from "../controller"; +import { useDocMeta, useOutline } from "../model/document"; +import { symbolChain } from "./outline"; +import styles from "./EditorArea.module.css"; + +function useActiveMeta() { + const path = useEditorStatus((s) => s.status?.path ?? null); + const meta = useDocMeta((s) => (path ? s[path] : undefined)); + return meta; +} + +interface Crumb { + label: string; + icon?: string; + title: string; + run: () => void; +} + +/** IDEA new UI navigation bar in the status bar: project › dirs › file › symbols, with icons. */ +export function Breadcrumbs() { + const status = useEditorStatus((s) => s.status); + const project = useProject((s) => s.project); + const outline = useOutline((s) => (status ? s[status.path] : undefined)); + const line = status?.line ?? 0; + const col = status?.col ?? 0; + const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]); + if (!status) return null; + const path = status.path; + const parent = dirname(path); + const segments: Crumb[] = []; + if (project) + segments.push({ + label: project.name, + icon: "module", + title: project.root, + run: () => void executeAction("navigate.selectIn", { path: project.root }), + }); + if (project && parent !== project.root && parent.startsWith(project.root + "/")) { + // Every directory from the root to the file, eliding the middle of deep paths. + const dirs = relativePath(project.root, parent).split("/"); + const shown = dirs.length > 4 ? [...dirs.slice(0, 1), "…", ...dirs.slice(-2)] : dirs; + let consumed = 0; + for (const label of shown) { + if (label === "…") { + consumed = dirs.length - 2; + segments.push({ + label, + title: dirs.join("/"), + run: () => void executeAction("navigate.selectIn", { path: parent }), + }); + continue; + } + consumed += 1; + const dir = `${project.root}/${dirs.slice(0, consumed).join("/")}`; + segments.push({ + label, + icon: "folder", + title: relativePath(project.root, dir), + run: () => void executeAction("navigate.selectIn", { path: dir }), + }); + } + } + const file = basename(path); + const stem = file.includes(".") ? file.slice(0, file.lastIndexOf(".")) || file : file; + segments.push({ + label: file, + icon: fileIconName(file), + title: project ? relativePath(project.root, path) : path, + run: () => void executeAction("navigate.selectIn", { path }), + }); + for (const item of chain.filter((it, i) => i > 0 || it.name !== stem)) + segments.push({ + label: item.name, + icon: `symbol-${badgeKind(item.kind)}`, + title: item.detail ?? item.name, + run: () => + void editorController().openFile(path, { + selection: { line: item.selection_range.start.line, col: item.selection_range.start.col }, + }), + }); + return ( + + ); +} + +/** + * Settings › Editor › General › Breadcrumbs: the code structure at the caret + * (class › method …) above or below the editor of `tabId`. + */ +export function CodeBreadcrumbs({ + path, + tabId, + position, +}: { + path: string; + tabId: string; + position: "top" | "bottom"; +}) { + const prefs = useEditorPrefs().breadcrumbs; + const language = useDocMeta((s) => s[path]?.language); + const outline = useOutline((s) => s[path]); + const status = useEditorStatus((s) => (s.status?.path === path ? s.status : null)); + const slot = editorController().slots.get(tabId); + const caret = status ?? (slot ? slot.caret() : null); + const line = caret?.line ?? 0; + const col = caret?.col ?? 0; + const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]); + if ( + !prefs.show || + prefs.placement !== position || + (language && prefs.disabledLanguages.includes(language)) || + !chain.length + ) + return null; + return ( + + ); +} + +export function CaretStatus() { + const status = useEditorStatus((s) => s.status); + if (!status) return null; + let text = `${status.line + 1}:${status.col + 1}`; + if (status.carets > 1) text = `${status.carets} carets`; + else if (status.selectedChars) + text += ` (${status.selectedChars} chars${status.selectedLines ? `, ${status.selectedLines} line break${status.selectedLines > 1 ? "s" : ""}` : ""})`; + return ( + useGotoLine.setState({ open: true })} + > + {text} + + ); +} + +const EOL_LABEL: Record = { lf: "LF", crLf: "CRLF", cr: "CR" }; + +export function LineEndingStatus() { + const meta = useActiveMeta(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + if (!meta) return null; + const choose = (le: LineEnding) => { + const model = editorController().manager.get(meta.path); + if (!model || le === meta.lineEnding) return; + void editorController() + .manager.backend.setLineEnding(meta.doc, le) + .then(() => model.setMeta({ lineEnding: le, dirty: true })) + .catch((e: unknown) => + notifications().notify({ + level: "error", + title: "Cannot change line separators", + body: e instanceof Error ? e.message : String(e), + }), + ); + }; + return ( + <> + setOpen((v) => !v)} + disabled={meta.readOnly} + > + {EOL_LABEL[meta.lineEnding]} + + setOpen(false)} + anchor={ref} + placement="top-end" + aria-label="Line separator" + items={(["lf", "crLf", "cr"] as LineEnding[]).map((le) => ({ + id: le, + label: `${EOL_LABEL[le]} – ${le === "lf" ? "Unix and macOS (\\n)" : le === "crLf" ? "Windows (\\r\\n)" : "Classic Mac OS (\\r)"}`, + checked: meta.lineEnding === le ? true : undefined, + run: () => choose(le), + }))} + /> + + ); +} + +const ENCODINGS = ENCODING_CHOICES; + +/** File encoding widget (IDEA: click → choose an encoding, then Reload or Convert). */ +export function EncodingStatus() { + const meta = useActiveMeta(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + if (!meta) return null; + const apply = (encoding: string, reload: boolean) => { + const model = editorController().manager.get(meta.path); + if (!model) return; + void editorController() + .manager.backend.setEncoding(meta.doc, encoding, reload) + .then((enc) => model.setMeta({ encoding: enc, ...(reload ? {} : { dirty: true }) })) + .catch((e: unknown) => + notifications().notify({ + level: "error", + title: reload ? "Cannot reload with encoding" : "Cannot convert encoding", + body: e instanceof Error ? e.message : String(e), + }), + ); + }; + const current = meta.encoding.toLowerCase(); + const group = (reload: boolean) => + ENCODINGS.filter(([id]) => id !== current).map(([id, label]) => ({ + id: `${reload ? "reload" : "convert"}.${id}`, + label, + run: () => apply(id, reload), + })); + return ( + <> + setOpen((v) => !v)} + > + {ENCODINGS.find(([id]) => id === current)?.[1] ?? meta.encoding} + + setOpen(false)} + anchor={ref} + placement="top-end" + aria-label="File encoding" + items={[ + { id: "reload", label: "Reload with Encoding", disabled: meta.dirty, submenu: group(true) }, + { id: "convert", label: "Convert to Encoding", disabled: meta.readOnly, submenu: group(false) }, + { kind: "separator" }, + { + id: "configure", + label: "File Encodings Settings…", + run: () => void executeAction("file.settings", "editor.fileEncodings"), + }, + ]} + /> + + ); +} + +/** Indentation widget: per-file override, or open the Code Style settings. */ +export function IndentStatus() { + const meta = useActiveMeta(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + if (!meta) return null; + const { tabSize, insertSpaces } = meta.indent; + const label = insertSpaces ? `${tabSize} spaces` : `Tab (${tabSize})`; + const set = (indent: { tabSize: number; insertSpaces: boolean }) => + editorController().manager.get(meta.path)?.setIndent(indent); + const choice = (id: string, text: string, indent: { tabSize: number; insertSpaces: boolean }) => ({ + id, + label: text, + checked: indent.tabSize === tabSize && indent.insertSpaces === insertSpaces ? true : undefined, + run: () => set(indent), + }); + return ( + <> + setOpen((v) => !v)} + > + {label} + + setOpen(false)} + anchor={ref} + placement="top-end" + aria-label="Indentation" + items={[ + choice("s2", "2 spaces", { tabSize: 2, insertSpaces: true }), + choice("s4", "4 spaces", { tabSize: 4, insertSpaces: true }), + choice("s8", "8 spaces", { tabSize: 8, insertSpaces: true }), + choice("tab", `Tab (${tabSize})`, { tabSize, insertSpaces: false }), + { kind: "separator" }, + { + id: "configure", + label: "Configure Indents…", + run: () => void executeAction("file.settings", "editor.codeStyle"), + }, + ]} + /> + + ); +} + +/** IDEA's read-only attribute widget: the lock toggles the file's writable bit (asking first). */ +export function ReadOnlyStatus() { + const meta = useActiveMeta(); + if (!meta || isVirtualUri(meta.path) || meta.banner === "binary" || meta.banner === "tooLarge") return null; + const label = meta.readOnly ? "Make file writable" : "Make file read-only"; + return ( + void executeAction("file.toggleReadOnly")}> + + + + + ); +} diff --git a/src/editor/core/view/TextEditorHost.tsx b/src/editor/core/view/TextEditorHost.tsx new file mode 100644 index 00000000..79cd5d38 --- /dev/null +++ b/src/editor/core/view/TextEditorHost.tsx @@ -0,0 +1,43 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { EmptyState } from "@/ui"; +import { editorController, useTabErrors } from "../controller"; +import type { EditorSlot } from "../model/slot"; +import styles from "./EditorArea.module.css"; + +/** Mounts the CodeMirror view of one tab while it is visible. */ +export function TextEditorHost({ tabId }: { tabId: string }) { + const ctrl = editorController(); + const ref = useRef(null); + const [slot, setSlot] = useState(() => ctrl.slotFor(tabId)); + const error = useTabErrors((s) => s[tabId]); + + useEffect(() => { + if (slot && !slot.disposed) return; + let live = true; + void ctrl.ensureSlot(tabId).then((s) => { + if (live && s) setSlot(s); + }); + return () => { + live = false; + }; + }, [ctrl, tabId, slot]); + + useLayoutEffect(() => { + if (!slot || slot.disposed || !ref.current) return; + slot.mount(ref.current); + ctrl.onMounted(slot); + return () => slot.unmount(); + }, [ctrl, slot]); + + if (error) + return ( +
+ + {error} + +
+ ); + return ( +
+ ); +} diff --git a/src/editor/core/view/navBar.test.ts b/src/editor/core/view/navBar.test.ts new file mode 100644 index 00000000..3395f2aa --- /dev/null +++ b/src/editor/core/view/navBar.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { FileEntry, OutlineItem } from "@/platform/ipc"; +import { dirChildren, navSegments, stepSegment, symbolChildren } from "./navBar"; + +const r = { start: { line: 0, col: 0 }, end: { line: 9, col: 0 } }; +const sym = (name: string, children: OutlineItem[] = []): OutlineItem => ({ name, kind: "class", detail: null, range: r, selection_range: r, children }); + +describe("navigation bar popup model", () => { + it("lists project › dirs › file › symbols, skipping the class named like its file", () => { + const segs = navSegments({ root: "/p", name: "proj" }, "/p/src/a/Foo.java", [sym("Foo"), sym("run")]); + expect(segs.map((s) => `${s.kind}:${s.label}`)).toEqual(["dir:proj", "dir:src", "dir:a", "file:Foo.java", "symbol:run"]); + expect(segs[2]).toMatchObject({ path: "/p/src/a" }); + }); + + it("shows only the parent folder outside the project (scratches)", () => { + expect(navSegments({ root: "/p", name: "proj" }, "/data/scratches/scratch.py", []).map((s) => s.label)).toEqual(["scratches", "scratch.py"]); + }); + + it("lists folder children without IDE-hidden names, and symbol children", () => { + const e = (name: string, kind: FileEntry["kind"]) => ({ name, path: `/p/${name}`, kind, size: 0, mtimeMs: 0, ignored: false, hidden: false }) as FileEntry; + expect(dirChildren([e(".git", "dir"), e("src", "dir"), e("a.ts", "file")]).map((c) => `${c.kind}:${c.label}`)).toEqual(["dir:src", "file:a.ts"]); + expect(symbolChildren("/p/a.ts", [sym("A", [sym("b")])]).map((c) => c.label)).toEqual(["A"]); + }); + + it("clamps ←/→ at the ends", () => { + expect(stepSegment(0, 3, -1)).toBe(0); + expect(stepSegment(1, 3, 1)).toBe(2); + expect(stepSegment(2, 3, 1)).toBe(2); + }); +}); diff --git a/src/editor/core/view/navBar.ts b/src/editor/core/view/navBar.ts new file mode 100644 index 00000000..59f58458 --- /dev/null +++ b/src/editor/core/view/navBar.ts @@ -0,0 +1,67 @@ +/** + * Navigation bar popup (⌘↑) model: the path of the active file as segments + * (project › dirs › file › symbols at the caret) and the child list of a segment. + */ +import type { FileEntry, OutlineItem } from "@/platform/ipc"; +import { badgeKind, fileIconName } from "@/ui"; + +export type NavSegment = + | { kind: "dir"; path: string; label: string; root?: boolean } + | { kind: "file"; path: string; label: string } + | { kind: "symbol"; path: string; label: string; item: OutlineItem }; + +export type NavChild = + | { kind: "dir"; path: string; label: string } + | { kind: "file"; path: string; label: string } + | { kind: "symbol"; path: string; label: string; item: OutlineItem }; + +const parentOf = (p: string) => p.slice(0, Math.max(p.lastIndexOf("/"), 0)) || "/"; +const nameOf = (p: string) => p.slice(p.lastIndexOf("/") + 1); + +/** Segments for `path`; directories from the project root, or just the parent folder outside it. */ +export function navSegments(project: { root: string; name: string } | null, path: string, chain: readonly OutlineItem[]): NavSegment[] { + const out: NavSegment[] = []; + const parent = parentOf(path); + if (project && (parent === project.root || parent.startsWith(project.root + "/"))) { + out.push({ kind: "dir", path: project.root, label: project.name, root: true }); + const rel = parent.slice(project.root.length + 1); + let dir = project.root; + for (const part of rel ? rel.split("/") : []) { + dir = `${dir}/${part}`; + out.push({ kind: "dir", path: dir, label: part }); + } + } else out.push({ kind: "dir", path: parent, label: nameOf(parent) || parent }); + const file = nameOf(path); + out.push({ kind: "file", path, label: file }); + const stem = file.includes(".") ? file.slice(0, file.lastIndexOf(".")) || file : file; + chain.forEach((item, i) => { + // `Foo.java › Foo`: the file already names its single class. + if (i === 0 && item.name === stem) return; + out.push({ kind: "symbol", path, label: item.name, item }); + }); + return out; +} + +const HIDDEN = new Set([".git", ".hg", ".svn", ".DS_Store", "__pycache__"]); + +export function dirChildren(entries: readonly FileEntry[]): NavChild[] { + return entries + .filter((e) => !HIDDEN.has(e.name)) + .map((e) => ({ kind: e.kind === "dir" || (e.kind === "symlink" && e.size === 0) ? "dir" : "file", path: e.path, label: e.name }) as NavChild); +} + +/** Children of a file or symbol segment: its outline items (one level). */ +export function symbolChildren(path: string, items: readonly OutlineItem[]): NavChild[] { + return items.map((item) => ({ kind: "symbol", path, label: item.name, item })); +} + +export function iconOf(x: NavSegment | NavChild): string { + if (x.kind === "dir") return "root" in x && x.root ? "module" : "folder"; + if (x.kind === "file") return fileIconName(x.label); + return `symbol-${badgeKind(x.item.kind)}`; +} + +/** Next active segment for ←/→ (clamped). */ +export function stepSegment(index: number, count: number, delta: number): number { + return Math.max(0, Math.min(count - 1, index + delta)); +} diff --git a/src/editor/core/view/outline.ts b/src/editor/core/view/outline.ts new file mode 100644 index 00000000..79451cfe --- /dev/null +++ b/src/editor/core/view/outline.ts @@ -0,0 +1,16 @@ +import type { OutlineItem, Pos } from "@/platform/ipc"; + +const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col <= b.col); + +/** Outline items enclosing (line, col), outermost first. */ +export function symbolChain(items: readonly OutlineItem[], line: number, col: number): OutlineItem[] { + const pos = { line, col }; + const out: OutlineItem[] = []; + let level: readonly OutlineItem[] = items; + for (;;) { + const hit = level.find((i) => before(i.range.start, pos) && before(pos, i.range.end)); + if (!hit) return out; + out.push(hit); + level = hit.children; + } +} diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts new file mode 100644 index 00000000..f14ea14b --- /dev/null +++ b/src/editor/core/virtual.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { VirtualDocument } from "@/platform/ipc"; + +const JDT = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class"; +const TEXT = "// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n"; + +vi.mock("@/platform/virtualDocs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + virtualDocument: vi.fn( + async (uri: string): Promise => { + if (uri !== JDT) throw new Error(`no contents for ${uri}`); + return { + uri, + text: TEXT, + languageId: "java", + title: "Lib.class", + description: "/m2/lib-1.0.jar", + decompiled: true, + }; + }, + ), + }; +}); + +const { EditorController, isTextInput, useTabs, virtualInput } = await import("./controller"); +const { createMemoryBackend } = await import("./backend"); +const { initialTabsState } = await import("./model/tabs"); +const { serializeSession } = await import("./model/session"); +const { useDocMeta } = await import("./model/document"); + +let ctrl: InstanceType | null = null; + +afterEach(() => { + ctrl?.stop(); + ctrl = null; + useTabs.setState(initialTabsState(), true); +}); + +const activeTab = () => { + const s = useTabs.getState(); + const pane = s.panes[s.activePane]!; + return pane.tabs.find((t) => t.id === pane.active)!; +}; + +describe("virtual editor input", () => { + it("describes a library class as a text input keyed by its URI", () => { + const input = virtualInput({ uri: JDT, title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true }); + expect(input).toMatchObject({ kind: "virtual", key: `virtual:${JDT}`, path: JDT }); + expect(input.data).toEqual({ title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true }); + expect(isTextInput(input)).toBe(true); + expect(isTextInput({ kind: "diff", key: "d" })).toBe(false); + }); + + it("opens a URI read-only at the target and keeps it out of the saved session", async () => { + ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT })); + ctrl.start(); + await ctrl.openFile("/p/App.java", { focus: false }); + await ctrl.openFile(JDT, { selection: { line: 3, col: 13, endLine: 3, endCol: 16 }, focus: false }); + + const tab = activeTab(); + expect(tab.input.kind).toBe("virtual"); + expect(tab.input.key).toBe(`virtual:${JDT}`); + const slot = ctrl.slotFor(tab.id)!; + expect(slot.model.virtual).toBe(true); + expect(slot.model.ctx()).toMatchObject({ path: JDT, language: "java", readOnly: true, virtual: true }); + expect(useDocMeta.getState()[JDT]).toMatchObject({ readOnly: true, banner: null }); + const sel = slot.currentState().selection.main; + expect(slot.currentState().sliceDoc(sel.from, sel.to)).toBe("Lib"); + + // The same URI reuses its tab. + await ctrl.openFile(JDT, { focus: false }); + expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(2); + + // Navigation history goes back to the file and forward into the library class. + await ctrl.back(); + expect(activeTab().input.path).toBe("/p/App.java"); + await ctrl.forward(); + expect(activeTab().input.path).toBe(JDT); + + const session = serializeSession(useTabs.getState(), {}); + expect(JSON.stringify(session)).not.toContain("jdt:"); + expect(ctrl.tabMenu(useTabs.getState().activePane, activeTab()).some((i) => "label" in i && i.label === "Reveal in Project")).toBe(false); + }); + + it("does not open a tab when the server has no contents", async () => { + ctrl = new EditorController(createMemoryBackend()); + ctrl.start(); + await ctrl.openFile("jdt://contents/missing.jar/p/X.class", { focus: false }); + expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0); + }); +}); diff --git a/src/editor/core/wordCompletion.ts b/src/editor/core/wordCompletion.ts new file mode 100644 index 00000000..f3883561 --- /dev/null +++ b/src/editor/core/wordCompletion.ts @@ -0,0 +1,103 @@ +/** + * Code › Code Completion › Cyclic Expand Word (⌥/) and its backward twin + * (⌥⇧/), IDEA's "hippie" completion: completes the word prefix at the caret + * from words in the current file (nearest first, ⌥/ searching upwards first, + * ⌥⇧/ downwards), then from other open editors. Repeated presses cycle + * through the candidates and back to the typed prefix. + */ +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; + +const WORD_CHAR = /[\p{L}\p{N}_$]/u; +const WORD = /[\p{L}\p{N}_$]+/gu; + +/** Start of the word prefix ending at `caret`. */ +export function prefixStart(text: string, caret: number): number { + let i = caret; + while (i > 0) { + const ch = text[i - 1]!; + // Low surrogates belong to the character before them. + const cp = ch >= "\udc00" && ch <= "\udfff" && i > 1 ? text.slice(i - 2, i) : ch; + if (!WORD_CHAR.test(cp)) break; + i -= cp.length; + } + return i; +} + +/** + * Candidates for the prefix `text[from, caret)`: words starting with it (and + * longer), nearest first in `direction`, then the other way, then `others`. + * The word being completed itself is skipped. + */ +export function expandCandidates( + text: string, + from: number, + caret: number, + direction: "backward" | "forward", + others: readonly string[] = [], +): string[] { + const prefix = text.slice(from, caret); + if (!prefix) return []; + const before: string[] = []; + const after: string[] = []; + for (const m of text.matchAll(WORD)) { + const start = m.index; + if (start <= from && start + m[0].length >= caret) continue; + const w = m[0]; + if (w.length <= prefix.length || !w.startsWith(prefix)) continue; + (start < from ? before : after).push(w); + } + before.reverse(); + const ordered = direction === "backward" ? [...before, ...after] : [...after, ...before]; + for (const other of others) + for (const m of other.matchAll(WORD)) + if (m[0].length > prefix.length && m[0].startsWith(prefix)) ordered.push(m[0]); + return [...new Set(ordered)]; +} + +interface Session { + from: number; + prefix: string; + candidates: string[]; + /** Index into candidates; `candidates.length` shows the bare prefix again. */ + index: number; + /** Document after our last expansion; any other edit ends the session. */ + doc: EditorView["state"]["doc"]; + end: number; +} + +const sessions = new WeakMap(); + +/** Expands (or cycles) the word at the caret. Returns a hint when there is nothing to do. */ +export function cyclicExpandWord( + view: EditorView, + direction: "backward" | "forward", + others: () => string[], +): string | null { + const state = view.state; + if (state.readOnly) return null; + const head = state.selection.main.head; + let s = sessions.get(view); + if (!s || s.doc !== state.doc || s.end !== head || !state.selection.main.empty) { + const text = state.doc.toString(); + const from = prefixStart(text, head); + const candidates = expandCandidates(text, from, head, direction, others()); + if (!candidates.length) { + sessions.delete(view); + return from === head ? "No word to expand at the caret" : "No suitable completions found"; + } + s = { from, prefix: text.slice(from, head), candidates, index: -1, doc: state.doc, end: head }; + } + s.index = (s.index + 1) % (s.candidates.length + 1); + const insert = s.candidates[s.index] ?? s.prefix; + view.dispatch({ + changes: { from: s.from, to: s.end, insert }, + selection: EditorSelection.cursor(s.from + insert.length), + scrollIntoView: true, + userEvent: "input.complete", + }); + s.doc = view.state.doc; + s.end = s.from + insert.length; + sessions.set(view, s); + return null; +} diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css new file mode 100644 index 00000000..6a85ef4a --- /dev/null +++ b/src/editor/lsp/Popups.module.css @@ -0,0 +1,289 @@ +.listPopup { + min-width: 360px; + max-width: min(720px, 90vw); + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} +.listHeader { + height: 28px; + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + font-size: var(--fs-ui); + font-weight: 600; + color: var(--fg-strong); +} +.listTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.query { + font-family: var(--font-mono); + color: var(--accent); +} +.listBody { + display: flex; + flex-direction: column; + min-height: 24px; + padding: 0 0 4px; + box-sizing: content-box; +} +.listBody > * { + flex: 1; +} +.item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + font-size: var(--fs-ui); +} +.emphasis .label { + font-weight: 600; + color: var(--fg-strong); +} +.disabled { + color: var(--fg-faint); +} +.label { + flex: none; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; +} +.preview { + min-width: 80px; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + font-size: var(--fs-small); + color: var(--fg-muted); +} +.spacer { + flex: 1; +} +.detail { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.shortcut { + flex: none; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.shortcut { + font-family: var(--font-keys); +} +.itemIcon { + flex: none; + color: var(--fg-muted); +} +.iconSpacer { + width: 14px; + flex: none; +} +.bulb { + flex: none; + color: var(--warning); +} +.bulbFix { + color: var(--danger); +} +.empty { + font-size: var(--fs-ui); + color: var(--fg-faint); +} + +.renamePopup { + padding: 10px 12px; + width: 300px; +} +.renameLabel { + display: block; + margin-bottom: 6px; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.renameSuggestions { + list-style: none; + margin: 6px 0 0; + padding: 2px 0; + border: 1px solid var(--border); + border-radius: var(--radius-row); + max-height: 140px; + overflow-y: auto; +} +.renameSuggestion { + display: block; + width: 100%; + padding: 2px 8px; + border: 0; + border-radius: var(--radius-row); + background: transparent; + color: var(--fg); + font-family: var(--font-mono); + font-size: var(--fs-ui); + text-align: left; +} +.renameSuggestion:hover { + background: var(--bg-hover); +} +.renameSuggestionActive { + background: var(--bg-selected); +} +.renameNote { + margin-top: 6px; + font-size: var(--fs-tiny); + color: var(--fg-subtle); +} +.renameHint { + margin-top: 6px; + font-size: var(--fs-tiny); + color: var(--fg-faint); +} + +.docPopup { + max-width: min(600px, 90vw); + max-height: 420px; + overflow: auto; +} +.docScope { + display: contents; +} +.docBody { + padding: 10px 14px; +} +.defPopup { + width: min(640px, 90vw); + max-height: 460px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.defHeader { + height: 28px; + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px 0 12px; + background: var(--bg-popup-inline, var(--bg-elev)); + border-bottom: 1px solid var(--border-popup); + font-size: var(--fs-ui); + color: var(--fg-muted); +} +.defFile { + color: var(--fg-emphasis); +} +.defPath { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-subtle); +} +.defBody { + overflow: auto; + padding: 4px 12px; +} +.defBody pre { + white-space: pre; +} + +.previewSummary { + padding: 10px 14px 6px; + font-size: var(--fs-ui); + color: var(--fg-muted); +} +.previewList { + list-style: none; + margin: 0; + padding: 0 0 8px; + max-height: 320px; + overflow: auto; +} +.previewRow { + height: var(--h-row); + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + font-size: var(--fs-ui); +} +.previewName { + color: var(--fg-emphasis); +} +.previewPath { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.previewOp, +.previewCount { + flex: none; + font-size: var(--fs-small); + color: var(--fg-muted); +} + +.previewWarning { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 10px 14px 0; + font-size: var(--fs-ui); + color: var(--fg-muted); +} +.previewWarning > :first-child { + flex: none; + margin-top: 1px; + color: var(--warning); +} +.previewLine { + flex: none; + min-width: 28px; + text-align: right; + font-family: var(--font-mono); + font-size: var(--fs-small); + color: var(--fg-faint); +} +.previewCode { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; + font-family: var(--font-mono); + font-size: var(--fs-small); + color: var(--fg-editor); +} +.previewMatch { + background: var(--bg-search-match); + color: var(--fg-search-match, inherit); + border-radius: 2px; +} + +.choiceMessage { + padding: 14px 16px 4px; + font-size: var(--fs-ui); + color: var(--fg); +} +.choiceDetail { + padding: 4px 16px 12px; + font-size: var(--fs-ui); + color: var(--fg-muted); +} diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx new file mode 100644 index 00000000..405484ed --- /dev/null +++ b/src/editor/lsp/Popups.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { usePlatform } from "@/platform/platformInfo"; +import { LspPopups } from "./Popups"; +import { openPopup, useLspPopup } from "./popupStore"; + +function openUsagesDetected() { + const view = vi.fn(); + const anyway = vi.fn(); + const cancel = vi.fn(); + act(() => + openPopup({ + kind: "choice", + title: "Usages Detected", + message: "Function 'unused' has 1 usage that is not safe to delete.", + choices: [ + { label: "View Usages", primary: true, run: view }, + { label: "Delete Anyway", run: anyway }, + { label: "Cancel", run: cancel }, + ], + onDismiss: cancel, + }), + ); + return { view, anyway, cancel }; +} + +const footerLabels = () => + [...screen.getByRole("dialog").querySelectorAll("[data-dialog-footer] button")].map((b) => b.textContent); + +describe("Usages Detected dialog", () => { + const os = usePlatform.getState().os; + afterEach(() => { + usePlatform.setState({ os }); + act(() => useLspPopup.setState({ popup: null })); + }); + + it("macOS: Cancel · Delete Anyway · View Usages (default rightmost, focused); Enter views usages", () => { + usePlatform.setState({ os: "macos" }); + render(); + const { view } = openUsagesDetected(); + expect(footerLabels()).toEqual(["Cancel", "Delete Anyway", "View Usages"]); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "View Usages" })); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" }); + expect(view).toHaveBeenCalledTimes(1); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("Windows: View Usages · Delete Anyway · Cancel; Escape and the close button cancel", () => { + usePlatform.setState({ os: "windows" }); + render(); + let { cancel } = openUsagesDetected(); + expect(footerLabels()).toEqual(["View Usages", "Delete Anyway", "Cancel"]); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(cancel).toHaveBeenCalledTimes(1); + ({ cancel } = openUsagesDetected()); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(cancel).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx new file mode 100644 index 00000000..7d754020 --- /dev/null +++ b/src/editor/lsp/Popups.tsx @@ -0,0 +1,345 @@ +/** React popups for LSP editor features, rendered as a workbench overlay. */ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import clsx from "clsx"; +import { Button, Dialog, HighlightedText, Icon, Input, List, Popup, SymbolBadge, fileIconName, fuzzyMatch } from "@/ui"; +import { basename, relativePath, useProject } from "@/platform/project"; +import { editorService } from "@/workbench/contributions"; +import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore"; +import { renderCode, renderMarkup } from "./markdown"; +import { describeSummary } from "./workspaceEdit"; +import styles from "./Popups.module.css"; + +export function BulbIcon({ fix }: { fix?: boolean }) { + return ; +} + +function ItemIcon({ item }: { item: ListPopupItem }) { + if (item.symbol) return ; + if (item.icon === "bulb" || item.icon === "bulb-fix") return ; + if (item.icon) return ; + return ; +} + +function ListPopup({ popup }: { popup: Extract }) { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => { + if (!query) return popup.items.map((item) => ({ item, match: null as number[] | null })); + return popup.items + .map((item) => ({ item, match: fuzzyMatch(item.label, query) })) + .filter((r) => r.match !== null); + }, [popup.items, query]); + const firstEnabled = Math.max( + 0, + filtered.findIndex((r) => !r.item.disabled), + ); + const [active, setActive] = useState(firstEnabled); + const activeIndex = Math.min(active, Math.max(0, filtered.length - 1)); + + const activate = (item: ListPopupItem) => { + if (item.disabled) return; + closePopup(); + void item.run(); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === "Backspace" && query) { + setQuery(query.slice(0, -1)); + setActive(0); + e.preventDefault(); + } else if (e.key.length === 1 && !(e.key === " " && !query)) { + setQuery(query + e.key); + setActive(0); + e.preventDefault(); + } + }; + + const height = Math.min(12, Math.max(1, filtered.length)) * 24; + // Rows are virtualized (absolutely positioned), so size the popup from the content. + const width = useMemo(() => { + const chars = Math.max( + popup.title.length, + ...popup.items.map((i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0)), + ); + return Math.min(720, Math.max(320, chars * 7 + 90)); + }, [popup]); + return ( + +
+ {popup.title} + {query && {query}} +
+
+ r.item.id} + activeIndex={activeIndex} + onActiveChange={setActive} + hoverSelects + aria-label={popup.title} + onActivate={(r) => activate(r.item)} + empty={Nothing matches} + renderItem={({ item, match }) => ( +
+ + + {item.preview && {item.preview}} + + {item.detail && {item.detail}} + {item.shortcut && {item.shortcut}} +
+ )} + /> +
+
+ ); +} + +function RenamePopup({ popup }: { popup: Extract }) { + const [value, setValue] = useState(popup.initial); + const ref = useRef(null); + const suggestions = popup.suggestions ?? []; + useEffect(() => { + ref.current?.select(); + }, []); + const submit = (name = value.trim()) => { + closePopup(); + popup.onSubmit(name); + }; + const pick = (delta: number) => { + if (!suggestions.length) return false; + const i = suggestions.indexOf(value.trim()); + const next = suggestions[(i + delta + suggestions.length) % suggestions.length]!; + setValue(next); + requestAnimationFrame(() => ref.current?.select()); + return true; + }; + return ( + +
{ + e.preventDefault(); + submit(); + }} + > + + setValue(e.target.value)} + onKeyDown={(e) => { + if ((e.key === "ArrowDown" || e.key === "ArrowUp") && pick(e.key === "ArrowDown" ? 1 : -1)) e.preventDefault(); + }} + invalid={!value.trim()} + aria-label="New name" + /> + {suggestions.length > 1 && ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ )} + {popup.note &&
{popup.note}
} +
{popup.hint ?? "↵ Rename · Esc Cancel"}
+
+
+ ); +} + +function DomContent({ build, className }: { build: () => HTMLElement; className?: string }) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const node = build(); + el.replaceChildren(node); + return () => el.replaceChildren(); + // Built once per popup. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
; +} + +function DocPopup({ popup }: { popup: Extract }) { + return ( + +
+ renderMarkup(popup.markup, popup.language)} /> +
+
+ ); +} + +function DefinitionPopup({ popup }: { popup: Extract }) { + const root = useProject((s) => s.project?.root ?? ""); + const { location } = popup; + const open = () => { + closePopup(); + void editorService().openFile(location.path, { + selection: { line: location.range.start.line, col: location.range.start.col }, + focus: true, + }); + }; + return ( + +
+ + {basename(location.path)} + + {relativePath(root, location.path)}:{location.range.start.line + 1} + + + +
+
+ renderCode(popup.text, popup.language, "cm-lsp-code")} + /> +
+
+ ); +} + +function PreviewDialog({ popup }: { popup: Extract }) { + const root = useProject((s) => s.project?.root ?? ""); + const { summary } = popup; + return ( + { + closePopup(); + popup.onConfirm(); + }, + }, + { label: "Cancel", role: "cancel" }, + ]} + > + {popup.warnings?.map((w) => ( +
+ + {w} +
+ ))} +
{describeSummary(summary)}
+ {popup.occurrences ? ( +
    + {popup.occurrences.map((o, i) => { + const lead = o.text.length - o.text.trimStart().length; + const text = o.text.trim(); + const from = Math.max(0, o.from - lead); + const to = Math.max(from, Math.min(text.length, o.to - lead)); + return ( +
  • + {o.line + 1} + + {text.slice(0, from)} + {text.slice(from, to)} + {text.slice(to)} + +
  • + ); + })} +
+ ) : ( +
    + {summary.entries.map((e) => ( +
  • + + {basename(e.path)} + {relativePath(root, e.path)} + + {e.operation === "rename" && e.to && → {basename(e.to)}} + {e.operation && e.operation !== "rename" && {e.operation}} + {e.edits > 0 && ( + + {e.edits} {e.edits === 1 ? "occurrence" : "occurrences"} + + )} +
  • + ))} +
+ )} +
+ ); +} + +function ChoiceDialog({ popup }: { popup: Extract }) { + // Answer first, then close (unless the answer opened another popup). + const answer = (run?: () => void) => { + run?.(); + if (useLspPopup.getState().popup === popup) closePopup(); + }; + return ( + answer(popup.onDismiss)} + title={popup.title} + width={460} + buttons={popup.choices.map((c) => ({ + label: c.label, + role: c.primary ? "default" : c.label === "Cancel" ? "cancel" : "extra", + autoFocus: c.primary, + onClick: () => answer(c.run), + }))} + > +
{popup.message}
+ {popup.detail &&
{popup.detail}
} +
+ ); +} + +/** Overlay host: renders the current LSP popup, if any. */ +export function LspPopups() { + const popup = useLspPopup((s) => s.popup); + const seq = useLspPopup((s) => s.seq); + if (!popup) return null; + switch (popup.kind) { + case "list": + return ; + case "rename": + return ; + case "doc": + return ; + case "definition": + return ; + case "preview": + return ; + case "choice": + return ; + } +} diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts new file mode 100644 index 00000000..9bc45fac --- /dev/null +++ b/src/editor/lsp/actions.ts @@ -0,0 +1,177 @@ +/** Actions of the LSP editor layer (ids from keymaps/intellij-macos.ts). */ +import type { EditorView } from "@codemirror/view"; +import { registerActions, type Action } from "@/platform/actions"; +import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api"; +import { maybeEditorService } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; +import { startCompletion } from "./completion"; +import { triggerSignatureHelp } from "./signature"; +import { quickDefinition, quickDocumentation } from "./hover"; +import { expressionTypeInfo } from "./typeInfo"; +import { gotoTargets, highlightUsages } from "./navigation"; +import { gotoDiagnostic } from "./diagnostics"; +import { applyKindSilently, runKindAction, showIntentions } from "./intentions"; +import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings"; +import { startRename } from "./rename"; +import { formatDocument } from "./format"; +import { showHint } from "./hint"; +import { refactorThis, runRefactoring, safeDelete } from "@/editor/refactor"; + +/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */ +export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; + +function withEditor(run: (view: EditorView) => unknown): Action["run"] { + return () => { + const ed = activeEditor(); + if (!ed) return; + void run(ed.view); + }; +} + +/** The editor showing `path` once it is mounted (opening a file renders its editor a little later). */ +async function mountedEditor(path: string, frames = 60): Promise { + for (let i = 0; i < frames; i++) { + const ed = editorsFor(path)[0]; + if (ed) return ed; + await new Promise((r) => setTimeout(r, 16)); + } + return null; +} + +/** Runs on the file `{ path }` (opened in the editor first, as from the project view), else on the active editor. */ +function inFile(run: (view: EditorView) => unknown): Action["run"] { + return async ({ arg }) => { + const path = typeof arg === "string" ? arg : (arg as { path?: unknown } | undefined)?.path; + let ed = activeEditor(); + if (typeof path === "string" && ed?.ctx.path !== path) { + await maybeEditorService()?.openFile(path, { focus: true }); + ed = await mountedEditor(path); + } + if (ed) await run(ed.view); + }; +} + +const reformat = (v: EditorView) => formatDocument(v, { explicit: true }); +const optimizeImports = (v: EditorView) => + runKindAction(v, "source.organizeImports", { + whole: true, + none: "Optimize imports is not available", + label: "Optimize Imports", + }); + +/** Code action titles of Implement Methods (⌃I) and Override Methods (⌃O). */ +export const IMPLEMENT_TITLE = /\bimplement\b|\b(add|generate) (missing|unimplemented|all missing)\b|missing (members|methods|trait items)/i; +export const OVERRIDE_TITLE = /\boverride\b/i; + +function editorAction(id: string, title: string, category: string, run: (view: EditorView) => unknown, extra: Partial = {}): Action { + return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra }; +} + +/** Format on save (settings.editor.formatOnSave), run by editor/core before writing. */ +export function registerFormatOnSave(): () => void { + return registerSaveParticipant({ + id: "lsp.formatOnSave", + order: 50, + beforeSave: async (ctx, handle) => { + if (!handle || ctx.readOnly || !useProject.getState().settings?.editor?.formatOnSave) return; + await formatDocument(handle.view, { explicit: false, selectionOnly: false }); + }, + }); +} + +/** Settings › Tools › Actions on Save: optimize imports / code cleanup, before reformatting. */ +export function registerActionsOnSave(): () => void { + const participant = (id: string, order: number, kind: string, enabled: (s: ActionsOnSaveValid) => boolean) => + registerSaveParticipant({ + id, + order, + beforeSave: async (ctx, handle) => { + if (!handle || ctx.readOnly || !enabled(workflowSettings().actionsOnSave)) return; + await applyKindSilently(handle.view, kind); + }, + }); + const disposers = [ + participant("lsp.organizeImportsOnSave", 40, "source.organizeImports", (s) => s.optimizeImports), + participant("lsp.cleanupOnSave", 45, "source.fixAll", (s) => s.cleanup), + ]; + return () => disposers.forEach((d) => d()); +} + +export function registerLspActions(): () => void { + return registerActions([ + editorAction("code.completion", "Basic", "Code Completion", (v) => startCompletion(v), { + icon: "list", + keywords: ["code completion", "basic completion", "autocomplete"], + }), + editorAction("code.parameterInfo", "Parameter Info", "Code", (v) => triggerSignatureHelp(v, true)), + editorAction("code.quickDocumentation", "Quick Documentation", "Code", quickDocumentation, { + keywords: ["javadoc", "docs", "hover"], + }), + editorAction("code.quickDefinition", "Quick Definition", "Code", quickDefinition), + editorAction("code.showIntentions", "Show Context Actions", "Code", showIntentions, { + keywords: ["intention", "quick fix", "lightbulb"], + }), + editorAction("code.reformat", "Reformat Code", "Code", reformat, { + keywords: ["format", "prettier"], + }), + editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }), + // Project view / editor tab variants: `{ path }` of a file, opened first. + { id: "code.reformatFile", title: "Reformat File", category: "Code", hidden: true, when: "projectOpen", run: inFile(reformat) }, + { + id: "code.optimizeImportsFile", + title: "Optimize Imports in File", + category: "Code", + hidden: true, + when: "projectOpen", + run: inFile(optimizeImports), + }, + editorAction("navigate.declaration", "Go to Declaration or Usages", "Navigate", (v) => gotoTargets(v, "definition"), { + keywords: ["definition"], + }), + editorAction("navigate.implementation", "Go to Implementation(s)", "Navigate", (v) => gotoTargets(v, "implementation")), + editorAction("navigate.typeDeclaration", "Go to Type Declaration", "Navigate", (v) => gotoTargets(v, "typeDefinition")), + editorAction("navigate.nextError", "Next Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, 1)), + editorAction("navigate.prevError", "Previous Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, -1)), + editorAction("find.highlightUsages", "Highlight Usages in File", "Find", highlightUsages), + editorAction("refactor.thisMenu", "Refactor This…", "Refactor", (v) => refactorThis(v, startRename)), + editorAction("refactor.rename", "Rename…", "Refactor", startRename), + editorAction("refactor.extractVariable", "Introduce Variable…", "Refactor", (v) => runRefactoring(v, "extractVariable")), + editorAction("refactor.extractMethod", "Extract Method…", "Refactor", (v) => runRefactoring(v, "extractMethod")), + editorAction("refactor.extractConstant", "Introduce Constant…", "Refactor", (v) => runRefactoring(v, "extractConstant")), + editorAction("refactor.extractField", "Introduce Field…", "Refactor", (v) => runRefactoring(v, "extractField")), + editorAction("refactor.extractParameter", "Introduce Parameter…", "Refactor", (v) => runRefactoring(v, "extractParameter")), + editorAction("refactor.move", "Move…", "Refactor", (v) => runRefactoring(v, "move")), + editorAction("refactor.changeSignature", "Change Signature…", "Refactor", (v) => runRefactoring(v, "changeSignature")), + editorAction("refactor.safeDelete", "Safe Delete…", "Refactor", safeDelete), + editorAction("code.generate", "Generate…", "Code", (v) => + runKindAction(v, "", { + title: /^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i, + none: "Nothing to generate here", + label: "Generate", + }), + ), + editorAction("refactor.inline", "Inline…", "Refactor", (v) => runRefactoring(v, "inline")), + // Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement + // Methods…", rust-analyzer "Implement missing members", TS "Implement interface"…). + editorAction("code.implementMethods", "Implement Methods…", "Code", (v) => + runKindAction(v, "", { title: IMPLEMENT_TITLE, none: "Nothing to implement", label: "Implement Members" }), + ), + editorAction("code.overrideMethods", "Override Methods…", "Code", (v) => + runKindAction(v, "", { title: OVERRIDE_TITLE, none: "Nothing to override", label: "Override Members" }), + ), + editorAction("code.expressionTypeInfo", "Expression Type Info", "View", expressionTypeInfo, { + keywords: ["type of expression", "show type"], + }), + { + id: "editor.showHint", + title: "Show Editor Hint", + hidden: true, + description: "Shows `{ text, tone? }` as a transient hint at the caret of the active editor.", + run: ({ arg }) => { + const view = activeEditor()?.view; + const a = (arg ?? {}) as { text?: string; tone?: "info" | "warning" | "error" }; + if (view && a.text) showHint(view, a.text, { tone: a.tone }); + }, + }, + ]); +} diff --git a/src/editor/lsp/autoImport.ts b/src/editor/lsp/autoImport.ts new file mode 100644 index 00000000..7cc15aea --- /dev/null +++ b/src/editor/lsp/autoImport.ts @@ -0,0 +1,124 @@ +/** + * Settings › Editor › General › Auto Import, on top of LSP code actions: + * - "Add unambiguous imports on the fly": an unresolved reference whose quick + * fixes contain exactly one import action gets it applied; + * - "Optimize imports on the fly": `source.organizeImports` runs once the file + * has no errors, unless its edit touches the caret line. + * Both wait until typing pauses and never act on the caret line. + */ +import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; +import { commands, type CodeAction, type Diagnostic } from "@/platform/ipc"; +import { editorPrefs } from "@/platform/editorSettings"; +import { docOf, lspRequest } from "./lsp"; +import { diagnosticsFor, subscribeDiagnostics } from "./diagnostics"; +import { applyCodeAction } from "./intentions"; +import { offsetsToRange, rangeToOffsets } from "./positions"; +import { serverBackoff } from "./availability"; + +const IDLE_MS = 1500; +const IMPORT_TITLE = /\bimport\b/i; + +/** The single import fix among `actions`, if exactly one exists. */ +export function unambiguousImport(actions: readonly CodeAction[]): CodeAction | null { + const imports = actions.filter((a) => !a.disabledReason && IMPORT_TITLE.test(a.title) && (!a.kind || a.kind.startsWith("quickfix"))); + return imports.length === 1 ? imports[0]! : null; +} + +/** Whether a workspace edit for `path` touches 0-based `line`. */ +export function editTouchesLine(action: CodeAction, path: string, line: number): boolean { + for (const f of action.edit?.files ?? []) { + if (f.kind !== "edit" || f.path !== path) continue; + if (f.edits.some((e) => e.range.start.line <= line && line <= e.range.end.line)) return true; + } + return false; +} + +const autoImportPlugin = ViewPlugin.fromClass( + class { + private timer: ReturnType | undefined; + private unsubscribe: (() => void) | undefined; + private tried = new Set(); + private dirty = false; + private running = false; + + constructor(private view: EditorView) { + const doc = docOf(view); + if (doc) this.unsubscribe = subscribeDiagnostics(doc.path, () => this.schedule()); + } + + update(u: ViewUpdate) { + if (u.docChanged) { + this.dirty = true; + this.schedule(); + } + } + + private schedule() { + clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), IDLE_MS); + } + + private async run() { + const prefs = editorPrefs().autoImport; + const view = this.view; + const doc = docOf(view); + if ((!prefs.addUnambiguous && !prefs.optimizeOnTheFly) || this.running || !doc || doc.readOnly) return; + if (!view.dom.isConnected || !view.hasFocus || serverBackoff.active(doc.doc)) return; + this.running = true; + try { + const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1; + const errors = diagnosticsFor(doc.path).filter((d) => d.severity === "error"); + if (prefs.addUnambiguous) await this.addImports(errors, caretLine); + if (prefs.optimizeOnTheFly && this.dirty && !diagnosticsFor(doc.path).some((d) => d.severity === "error")) { + this.dirty = false; + await this.optimize(caretLine); + } + } catch { + // Servers without these code actions: nothing to do. + } finally { + this.running = false; + } + } + + private async addImports(errors: Diagnostic[], caretLine: number) { + const doc = docOf(this.view)!; + for (const d of errors.slice(0, 8)) { + if (d.range.start.line <= caretLine && caretLine <= d.range.end.line) continue; + const key = `${d.range.start.line}:${d.range.start.col}:${d.message}`; + if (this.tried.has(key)) continue; + this.tried.add(key); + const actions = await lspRequest(() => commands.lspCodeActions(doc.doc, d.range, ["quickfix"])); + const fix = unambiguousImport(actions); + if (fix && !editTouchesLine(fix, doc.path, caretLine)) { + await applyCodeAction(this.view, fix); + return; + } + } + if (this.tried.size > 500) this.tried.clear(); + } + + private async optimize(caretLine: number) { + const view = this.view; + const doc = docOf(view)!; + const range = offsetsToRange(view.state.doc, 0, view.state.doc.length); + const actions = await lspRequest(() => commands.lspCodeActions(doc.doc, range, ["source.organizeImports"])); + const action = actions.find((a) => a.kind?.startsWith("source.organizeImports") && !a.disabledReason && a.edit); + if (!action || editTouchesLine(action, doc.path, caretLine)) return; + const edits = action.edit!.files.flatMap((f) => (f.kind === "edit" && f.path === doc.path ? f.edits : [])); + const noop = edits.every((e) => { + const { from, to } = rangeToOffsets(view.state.doc, e.range); + return view.state.sliceDoc(from, to) === e.new_text; + }); + if (!noop) await applyCodeAction(view, action); + } + + destroy() { + clearTimeout(this.timer); + this.unsubscribe?.(); + } + }, +); + +export function autoImportExtension() { + return autoImportPlugin; +} diff --git a/src/editor/lsp/availability.ts b/src/editor/lsp/availability.ts new file mode 100644 index 00000000..f7ecef2e --- /dev/null +++ b/src/editor/lsp/availability.ts @@ -0,0 +1,53 @@ +/** + * Per-document back-off after `unavailable` errors, so background features + * (completion while typing, lightbulb, inlay hints…) don't hammer the backend + * for files without a language server. Cleared when any server becomes ready. + */ +import { isTauri, onIdeEvent } from "@/platform/ipc"; +import type { LspRefreshKind } from "@/bindings"; + +const BACKOFF_MS = 15_000; +const until = new Map(); + +export const serverBackoff = { + mark(doc: number) { + until.set(doc, Date.now() + BACKOFF_MS); + }, + active(doc: number): boolean { + const t = until.get(doc); + if (t === undefined) return false; + if (t > Date.now()) return true; + until.delete(doc); + return false; + }, + clear() { + until.clear(); + }, +}; + +/** + * Calls `fn` when data of `kind` fetched earlier may be stale: the server asked + * for a refresh (`workspace/inlayHint/refresh`…, e.g. after indexing) or it + * has just (re)started, so requests made while it was down came back empty. + */ +export function onLspRefresh(kind: LspRefreshKind, fn: () => void): () => void { + if (!isTauri()) return () => {}; + const starting = new Set(); + const offRefresh = onIdeEvent("lspRefresh", (e) => { + if (e.kind === kind) fn(); + }); + const offStatus = onIdeEvent("languageServer", (e) => { + if (e.status === "starting") starting.add(e.server); + else if (e.status === "ready" && starting.delete(e.server)) fn(); + }); + return () => { + offRefresh(); + offStatus(); + }; +} + +export function installAvailabilityEvents(): () => void { + return onIdeEvent("languageServer", (e) => { + if (e.status === "ready") serverBackoff.clear(); + }); +} diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts new file mode 100644 index 00000000..abcfb84f --- /dev/null +++ b/src/editor/lsp/codeLens.ts @@ -0,0 +1,193 @@ +/** + * "N usages" code lenses above declarations (IDEA's usages inlay). Cheap by + * construction: only declarations in (and near) the viewport are counted, one + * debounced pass after edits / scrolling, results cached per document text + * and symbol, and at most a few reference requests in flight. + */ +import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { codeLenses, type CodeLens } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { commands, type OutlineItem, type SymbolKind } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; +import { posToOffset } from "./positions"; +import { onLspRefresh, serverBackoff } from "./availability"; +import { editorPrefs } from "@/platform/editorSettings"; + +const LENS_KINDS = new Set([ + "class", + "interface", + "struct", + "enum", + "trait", + "function", + "method", + "constructor", + "typeAlias", + "macro", +]); + +const MAX_PER_PASS = 40; +const CONCURRENCY = 4; +/** Lines above/below the viewport that are counted too, so scrolling feels instant. */ +const MARGIN_LINES = 60; + +export interface LensTarget { + name: string; + /** 0-based line / UTF-16 col of the identifier. */ + line: number; + col: number; +} + +/** Declarations worth a usages lens, in document order. Exported for tests. */ +export function lensTargets(items: readonly OutlineItem[]): LensTarget[] { + const out: LensTarget[] = []; + const walk = (list: readonly OutlineItem[]) => { + for (const it of list) { + if (LENS_KINDS.has(it.kind)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); + if (it.children.length) walk(it.children); + } + }; + walk(items); + const seen = new Set(); + return out + .sort((a, b) => a.line - b.line || a.col - b.col) + .filter((t) => (seen.has(t.line) ? false : (seen.add(t.line), true))); +} + +export function usagesLabel(n: number): string { + if (n === 0) return "no usages"; + return n === 1 ? "1 usage" : `${n} usages`; +} + +const setLenses = StateEffect.define(); + +const lensField = StateField.define({ + create: () => [], + update(lenses, tr) { + for (const e of tr.effects) if (e.is(setLenses)) return e.value; + if (!tr.docChanged || !lenses.length) return lenses; + // Keep lenses on their lines while typing; drop ones whose line was deleted. + const out: CodeLens[] = []; + for (const l of lenses) { + const pos = tr.changes.mapPos(l.pos, -1, MapMode.TrackDel); + if (pos !== null) out.push({ ...l, pos }); + } + return out; + }, + provide: (f) => codeLenses.from(f), +}); + +const enabled = () => { + return editorPrefs().inlay.codeVisionUsages; +}; + +function openUsages(view: EditorView, offset: number) { + view.dispatch({ selection: EditorSelection.cursor(offset), userEvent: "select.lens" }); + view.focus(); + void executeAction("find.showUsages"); +} + +class UsagesLensPlugin { + private refresh = debounce(() => void this.run(), 900); + /** `${line}:${col}:${name}` → count, valid for `cacheDoc`. */ + private cache = new Map(); + private cacheDoc: Text | null = null; + private outline: { doc: Text; targets: LensTarget[] } | null = null; + private generation = 0; + private unsubscribe: () => void; + private offReady: () => void; + + constructor(private view: EditorView) { + this.refresh(); + this.unsubscribe = useProject.subscribe((s, prev) => { + if (s.settings !== prev.settings) this.refresh(); + }); + // Counts computed while the server was indexing may be incomplete. + this.offReady = onLspRefresh("codeLens", () => { + this.cache.clear(); + this.refresh(); + }); + } + + update(u: ViewUpdate) { + if (u.docChanged || u.viewportChanged) this.refresh(); + } + + private async run() { + const view = this.view; + if (!view.dom.isConnected) return; + const doc = docOf(view); + if (!doc) return; + const gen = ++this.generation; + if (!enabled() || serverBackoff.active(doc.doc)) { + if (view.state.field(lensField).length) view.dispatch({ effects: setLenses.of([]) }); + return; + } + const text = view.state.doc; + if (this.cacheDoc !== text) { + this.cache = new Map(); + this.cacheDoc = text; + } + try { + if (this.outline?.doc !== text) { + const items = await lspRequest(() => commands.docOutline(doc.doc)); + if (gen !== this.generation || view.state.doc !== text) return; + this.outline = { doc: text, targets: lensTargets(items) }; + } + const { from, to } = view.viewport; + const firstLine = Math.max(0, text.lineAt(from).number - 1 - MARGIN_LINES); + const lastLine = text.lineAt(to).number - 1 + MARGIN_LINES; + const visible = this.outline.targets.filter((t) => t.line >= firstLine && t.line <= lastLine).slice(0, MAX_PER_PASS); + const key = (t: LensTarget) => `${t.line}:${t.col}:${t.name}`; + const todo = visible.filter((t) => !this.cache.has(key(t))); + let unavailable = false; + for (let i = 0; i < todo.length && !unavailable; i += CONCURRENCY) { + await Promise.all( + todo.slice(i, i + CONCURRENCY).map(async (t) => { + try { + const refs = await lspRequest(() => commands.lspReferences(doc.doc, { line: t.line, col: t.col }, false)); + this.cache.set(key(t), refs.length); + } catch (e) { + if (isUnavailable(e)) unavailable = true; + } + }), + ); + if (gen !== this.generation || view.state.doc !== text) return; + } + if (unavailable) serverBackoff.mark(doc.doc); + const lenses: CodeLens[] = []; + for (const t of visible) { + const n = this.cache.get(key(t)); + if (n === undefined) continue; + const offset = posToOffset(text, { line: t.line, col: t.col }); + lenses.push({ + pos: offset, + title: usagesLabel(n), + tooltip: n ? `Show usages of ${t.name}` : undefined, + order: 10, + run: n ? (v) => openUsages(v, Math.min(offset, v.state.doc.length)) : undefined, + }); + } + if (!sameLenses(view.state.field(lensField), lenses)) view.dispatch({ effects: setLenses.of(lenses) }); + } catch (e) { + if (isUnavailable(e)) serverBackoff.mark(doc.doc); + } + } + + destroy() { + this.refresh.cancel(); + this.unsubscribe(); + this.offReady(); + this.generation++; + } +} + +function sameLenses(a: readonly CodeLens[], b: readonly CodeLens[]) { + return a.length === b.length && a.every((l, i) => l.pos === b[i]!.pos && l.title === b[i]!.title); +} + +export function usagesLensExtension(): Extension { + return [lensField, ViewPlugin.fromClass(UsagesLensPlugin)]; +} diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts new file mode 100644 index 00000000..f2bcddbb --- /dev/null +++ b/src/editor/lsp/completion.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CompletionItem } from "@/bindings"; +import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems"; +import { applyLspCompletion, makeCompletion } from "./completion"; + +function item(over: Partial = {}): CompletionItem { + return { + label: "create", + labelDetail: null, + detail: null, + kind: "method", + documentation: null, + sortText: null, + filterText: null, + preselect: false, + deprecated: false, + insertText: "create", + isSnippet: false, + replaceRange: null, + replaceRangeTab: null, + additionalEdits: [], + commitCharacters: [], + command: null, + data: null, + server: 1, + ...over, + }; +} + +describe("toCmCompletion", () => { + it("maps label, detail, kind and sort text", () => { + const c = toCmCompletion( + item({ labelDetail: "(req: CreateOrderRequest)", detail: "Order", sortText: "0001", commitCharacters: ["("] }), + ); + expect(c).toMatchObject({ + label: "create", + detail: "(req: CreateOrderRequest)", + type: "method", + sortText: "0001", + commitCharacters: ["("], + }); + expect(c.displayLabel).toBeUndefined(); + expect(c.boost).toBeUndefined(); + }); + + it("filters on filterText while displaying the label", () => { + const c = toCmCompletion(item({ label: "@GetMapping", filterText: "GetMapping" })); + expect(c.label).toBe("GetMapping"); + expect(c.displayLabel).toBe("@GetMapping"); + }); + + it("boosts preselected and demotes deprecated items", () => { + expect(toCmCompletion(item({ preselect: true })).boost).toBe(99); + expect(toCmCompletion(item({ deprecated: true })).boost).toBeLessThan(0); + }); + + it("maps completion kinds to symbol badges", () => { + expect(completionSymbolKind("function")).toBe("function"); + expect(completionSymbolKind("enumMember")).toBe("constant"); + expect(completionSymbolKind("typeParameter")).toBe("type"); + expect(completionSymbolKind(null)).toBe("text"); + }); +}); + +describe("completion helpers", () => { + it("detects trigger characters", () => { + expect(triggerCharacter("a.")).toBe("."); + expect(triggerCharacter("..")).toBeNull(); + expect(triggerCharacter("a::")).toBe(":"); + expect(triggerCharacter("a:")).toBeNull(); + expect(triggerCharacter("a->")).toBe(">"); + expect(triggerCharacter("a>")).toBeNull(); + expect(triggerCharacter(" @")).toBe("@"); + expect(triggerCharacter("ab")).toBeNull(); + }); + + it("finds identifier ends", () => { + expect(identifierEnd("foo.barBaz(1)", 4)).toBe(10); + expect(identifierEnd("x ", 1)).toBe(1); + }); +}); + +describe("applying completions", () => { + let view: EditorView | null = null; + afterEach(() => { + view?.destroy(); + view = null; + }); + + /** `|` marks the caret where completion was requested; `typed` is typed afterwards. */ + function setup(text: string, typed = "") { + const pos = text.indexOf("|"); + const doc = text.replace("|", ""); + const state = EditorState.create({ doc, selection: { anchor: pos } }); + view = new EditorView({ state, parent: document.body }); + const req = { doc: 1, path: "/p/a.ts", language: null, pos, text: view.state.doc }; + if (typed) view.dispatch({ changes: { from: pos, insert: typed }, selection: { anchor: pos + typed.length } }); + return { view, req, pos }; + } + + const apply = (v: EditorView, c: ReturnType, from: number) => + (c.apply as (v: EditorView, c: unknown, f: number, t: number) => void)(v, c, from, v.state.selection.main.head); + + it("inserts over the typed prefix and keeps text after the caret (↵)", () => { + const { view, req } = setup("service.cr|Order()"); + apply(view, makeCompletion(item({ insertText: "create" }), req), 8); + expect(view.state.doc.toString()).toBe("service.createOrder()"); + expect(view.state.selection.main.head).toBe(14); + }); + + it("replaces the rest of the identifier with ⇥", () => { + const { view, req } = setup("service.cr|Order()"); + applyLspCompletion(view, makeCompletion(item({ insertText: "create" }), req), 8, 10, "replace"); + expect(view.state.doc.toString()).toBe("service.create()"); + }); + + it("uses the server's ⇥ range when given", () => { + const { view, req } = setup("a.cr|Order.x"); + const c = makeCompletion( + item({ + insertText: "create", + replaceRange: { start: { line: 0, col: 2 }, end: { line: 0, col: 4 } }, + replaceRangeTab: { start: { line: 0, col: 2 }, end: { line: 0, col: 9 } }, + }), + req, + ); + applyLspCompletion(view, c, 2, 4, "replace"); + expect(view.state.doc.toString()).toBe("a.create.x"); + }); + + it("honours the server's replace range shifted by text typed since the request", () => { + const { view, req } = setup("x.c|", "re"); + const c = makeCompletion( + item({ insertText: "create()", replaceRange: { start: { line: 0, col: 2 }, end: { line: 0, col: 3 } } }), + req, + ); + apply(view, c, 2); + expect(view.state.doc.toString()).toBe("x.create()"); + }); + + it("applies additional edits (imports) in the same change", () => { + const { view, req } = setup("\nfoo.ba|"); + const c = makeCompletion( + item({ + insertText: "bar", + additionalEdits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "import bar;" }], + }), + req, + ); + apply(view, c, 5); + expect(view.state.doc.toString()).toBe("import bar;\nfoo.bar"); + }); + + it("expands snippets with fields", () => { + const { view, req } = setup("pri|"); + apply(view, makeCompletion(item({ insertText: "println!(${1:arg})$0", isSnippet: true }), req), 0); + expect(view.state.doc.toString()).toBe("println!(arg)"); + const sel = view.state.selection.main; + expect(view.state.sliceDoc(sel.from, sel.to)).toBe("arg"); + }); +}); diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts new file mode 100644 index 00000000..20159623 --- /dev/null +++ b/src/editor/lsp/completion.ts @@ -0,0 +1,395 @@ +/** + * Code completion backed by `lsp_completion`, rendered in IDEA's style: mono + * rows with a kind letter, the label with the matched prefix emphasised, the + * label detail, the type right-aligned and a footer hint bar. ↵ inserts, ⇥ + * replaces the rest of the identifier, documentation resolves lazily into the + * side panel. Without a language server the editor's language-data sources + * (keywords, local words) are used instead. + */ +import { + acceptCompletion, + autocompletion, + completeFromList, + completionStatus, + pickedCompletion, + snippet, + startCompletion, + type Completion, + type CompletionContext, + type CompletionInfo, + type CompletionResult, + type CompletionSource, +} from "@codemirror/autocomplete"; +import { EditorSelection, Prec, type Text } from "@codemirror/state"; +import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view"; +import { commands, isTauri, nextRequestId, type CompletionItem, type RequestId } from "@/platform/ipc"; +import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems"; +import { lspSnippetToCm, snippetVariables } from "./snippet"; +import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp"; +import { offsetToPos, posToOffset, textEditsToChanges } from "./positions"; +import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown"; +import { iconNode } from "./iconMarkup"; +import { triggerSignatureHelp } from "./signature"; +import { serverBackoff } from "./availability"; +import { editorPrefs } from "@/platform/editorSettings"; + +interface RequestContext { + doc: number; + path: string; + language: string | null; + /** Caret offset and document at request time; item ranges refer to these. */ + pos: number; + text: Text; +} + +interface LspCompletion extends Completion { + lsp: CompletionItem; + req: RequestContext; + resolved?: CompletionItem; +} + +const isLsp = (c: Completion): c is LspCompletion => "lsp" in c; + +// --------------------------------------------------------------------------- +// Resolve (documentation, lazily computed edits) + +const resolving = new WeakMap>(); + +function resolveItem(doc: number, item: CompletionItem): Promise { + if (item.data == null && !isEmptyMarkup(item.documentation)) return Promise.resolve(item); + let p = resolving.get(item); + if (!p) { + p = lspRequest(() => commands.lspResolveCompletion(doc, item)).catch(() => item); + resolving.set(item, p); + } + return p; +} + +function renderInfo(c: LspCompletion): Promise { + const prefs = editorPrefs().completion; + if (!prefs.showDocumentation) return Promise.resolve(null); + const delay = Math.max(0, prefs.documentationDelay); + const wait = delay ? new Promise((r) => setTimeout(r, delay)) : Promise.resolve(); + return Promise.all([resolveItem(c.req.doc, c.lsp), wait]).then(([item]) => { + c.resolved = item; + const hasDoc = !isEmptyMarkup(item.documentation); + if (!hasDoc && !item.detail) return null; + const dom = document.createElement("div"); + dom.className = "cm-lsp-completion-doc"; + if (item.detail) dom.appendChild(renderCode(item.detail, c.req.language, "cm-lsp-code cm-lsp-doc-signature")); + if (hasDoc) dom.appendChild(renderMarkup(item.documentation!, c.req.language)); + return dom; + }); +} + +// --------------------------------------------------------------------------- +// Apply + +/** Set by the ⇥ binding for the duration of `acceptCompletion`. */ +let replaceMode = false; + +function applyLsp(view: EditorView, completion: Completion, from: number, to: number) { + applyLspCompletion(view, completion, from, to, replaceMode ? "replace" : "insert"); +} + +/** ↵ (`insert`) or ⇥ (`replace`: also overwrites the rest of the identifier). */ +export function applyLspCompletion( + view: EditorView, + completion: Completion, + from: number, + to: number, + mode: "insert" | "replace", +) { + if (!isLsp(completion)) return; + const { req } = completion; + const item = completion.resolved ?? completion.lsp; + const state = view.state; + const delta = to - req.pos; + const mapOld = (offset: number) => Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta)); + + let start = from; + let end = to; + if (item.replaceRange) { + start = Math.min(mapOld(posToOffset(req.text, item.replaceRange.start)), to); + end = Math.max(mapOld(posToOffset(req.text, item.replaceRange.end)), to); + } + if (mode === "replace") { + if (item.replaceRangeTab) end = Math.max(end, mapOld(posToOffset(req.text, item.replaceRangeTab.end))); + else { + const line = state.doc.lineAt(end); + end = line.from + identifierEnd(line.text, end - line.from); + } + } + + const extra = textEditsToChanges(req.text, item.additionalEdits) + .map((c) => ({ from: mapOld(c.from), to: mapOld(c.to), insert: c.insert })) + .filter((c) => c.to <= start || c.from >= end); + + if (item.isSnippet) { + if (extra.length) { + const tr = state.update({ changes: extra, userEvent: "input.complete" }); + view.dispatch(tr); + start = tr.changes.mapPos(start, 1); + end = tr.changes.mapPos(end, 1); + } + snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end); + } else { + const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText }]); + view.dispatch({ + changes, + selection: EditorSelection.cursor(changes.mapPos(end, 1)), + userEvent: "input.complete", + annotations: pickedCompletion.of(completion), + scrollIntoView: true, + }); + } + + if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start); + runCommand(view, item); +} + +/** + * Some servers (tsserver auto-imports) only compute additional edits on + * resolve. Apply those that lie before the insertion once they arrive, if the + * text before it is unchanged. + */ +async function applyLateEdits(view: EditorView, c: LspCompletion, insertedAt: number) { + const item = await resolveItem(c.req.doc, c.lsp); + if (!item.additionalEdits.length || !view.dom.isConnected) return; + const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter((e) => e.to <= Math.min(insertedAt, c.req.pos)); + if (!edits.length) return; + const limit = Math.max(...edits.map((e) => e.to)); + if (view.state.doc.sliceString(0, limit) !== c.req.text.sliceString(0, limit)) return; + view.dispatch({ changes: edits, userEvent: "input.complete" }); +} + +/** Client-side commands handled by the editor itself (VS Code ids servers commonly send). */ +const CLIENT_COMMANDS = new Set(["editor.action.triggerParameterHints", "editor.action.triggerSuggest"]); + +function runCommand(view: EditorView, item: CompletionItem) { + const command = item.command; + if (!command?.command) return; + const cmd = command.command; + if (cmd === "editor.action.triggerParameterHints") setTimeout(() => triggerSignatureHelp(view), 0); + else if (cmd === "editor.action.triggerSuggest") setTimeout(() => startCompletion(view), 0); + else if (!CLIENT_COMMANDS.has(cmd) && !cmd.startsWith("editor.")) { + const doc = docOf(view); + // Server commands (auto-import, postfix…); resulting edits come back as workspace/applyEdit. + if (doc) void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => reportLspError(view, e, false)); + } +} + +// --------------------------------------------------------------------------- +// Source + +const WORD_BEFORE = /[\p{L}\p{N}_$]+$/u; +const VALID_FOR = /^[\p{L}\p{N}_$]*$/u; + +let inflight: RequestId | null = null; + +function cancelInflight() { + if (inflight !== null && isTauri()) void commands.cancelRequest(inflight); + inflight = null; +} + +/** Language-data completion sources (keywords etc.), used when no server answers. */ +async function fallback(ctx: CompletionContext): Promise { + const sources = ctx.state.languageDataAt("autocomplete", ctx.pos); + for (const s of sources) { + const source = typeof s === "function" ? s : completeFromList(s); + const r = await source(ctx); + if (r) return r; + } + return null; +} + +export function makeCompletion(item: CompletionItem, req: RequestContext): LspCompletion { + return Object.assign(toCmCompletion(item), { + lsp: item, + req, + apply: applyLsp, + info: (c: Completion) => renderInfo(c as LspCompletion), + }); +} + +/** + * Sources other features contribute via the `openideaCompletion` language-data + * key (live templates, postfix, SQL). Results starting where the first one + * does are merged into one list. Options may carry `icon` (an icon name, `null` + * for none) and `typeText` (the right-aligned type column), like LSP items. + */ +const contributedSource: CompletionSource = async (ctx) => { + const results: CompletionResult[] = []; + for (const source of ctx.state.languageDataAt("openideaCompletion", ctx.pos)) { + const result = await source(ctx); + if (result) results.push(result); + } + return mergeResults(results); +}; + +export function mergeResults(results: readonly CompletionResult[]): CompletionResult | null { + const [first] = results; + if (!first || results.length === 1) return first ?? null; + const same = results.filter((r) => r.from === first.from); + const validFor = same.every((r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source) + ? first.validFor + : undefined; + return { from: first.from, to: first.to, options: same.flatMap((r) => r.options), validFor }; +} + +/** Rendering extras of contributed (non-LSP) options. */ +interface RichCompletion extends Completion { + icon?: string | null; + typeText?: string; +} + +async function lspCompletionSource(ctx: CompletionContext): Promise { + const doc = ctx.state.facet(lspDoc); + if (!doc || doc.readOnly) return null; + const word = ctx.matchBefore(WORD_BEFORE); + const trigger = triggerCharacter(ctx.state.sliceDoc(Math.max(0, ctx.pos - 2), ctx.pos)); + if (!ctx.explicit && !word && !trigger) return null; + if (!ctx.explicit && word && !trigger && /^\d/.test(word.text)) return null; + if (serverBackoff.active(doc.doc)) return fallback(ctx); + + cancelInflight(); + const id = nextRequestId(); + inflight = id; + ctx.addEventListener("abort", () => { + if (inflight === id) cancelInflight(); + }); + let list; + try { + list = await lspRequest(() => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger)); + } catch (e) { + if (isUnavailable(e)) { + serverBackoff.mark(doc.doc); + return fallback(ctx); + } + return null; + } finally { + if (inflight === id) inflight = null; + } + if (ctx.aborted || !list.items.length) return list.items.length ? null : fallback(ctx); + const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc }; + const prefs = editorPrefs().completion; + const typed = word?.text ?? ""; + const items = prefs.matchCase ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items; + return { + from: word ? word.from : ctx.pos, + options: items.map((item) => makeCompletion(item, req)), + // "All letters" must re-check case on every keystroke. + validFor: list.isIncomplete || (prefs.matchCase && prefs.caseSensitivity === "all") ? undefined : VALID_FOR, + filter: true, + ...(prefs.insertOnSpecialKeys ? { commitCharacters: [" ", ".", "(", ",", ";"] } : {}), + }; +} + +/** Code Completion › Match case: first letter only, or every typed letter (as a subsequence). */ +export function matchesCase(label: string, typed: string, mode: string): boolean { + if (!typed) return true; + if (mode !== "all") { + const first = typed[0]!; + const head = label[0] ?? ""; + return head.toLowerCase() !== first.toLowerCase() || head === first; + } + let i = 0; + for (const ch of label) if (ch === typed[i]) i++; + return i >= typed.length; +} + +/** Show suggestions as you type: sources only answer explicit requests when it is off. */ +function gated(source: CompletionSource): CompletionSource { + return (ctx) => (!ctx.explicit && !editorPrefs().completion.autoPopup ? null : source(ctx)); +} + +// --------------------------------------------------------------------------- +// Rendering + +function kindBadge(c: Completion): Node | null { + if (!isLsp(c) && "icon" in c) { + const icon = (c as RichCompletion).icon; + if (icon) return iconNode(icon, 16, "cm-lsp-kind"); + const blank = document.createElement("span"); + blank.className = "cm-lsp-kind"; + return blank; + } + const kind = isLsp(c) ? completionSymbolKind(c.lsp.kind) : (c.type ?? "text"); + const span = iconNode(`symbol-${kind}`, 16, "cm-lsp-kind"); + span.dataset.kind = kind; + return span; +} + +function typeColumn(c: Completion): Node | null { + const detail = isLsp(c) ? c.lsp.detail : (c as RichCompletion).typeText; + if (!detail) return null; + const span = document.createElement("span"); + span.className = "cm-lsp-type"; + span.textContent = detail.replace(/\s+/g, " "); + return span; +} + +/** IDEA's advertiser bar under the list (one tip: "Press ↵ to insert, ⇥ to replace"). */ +const completionFooter = ViewPlugin.fromClass( + class { + constructor(private view: EditorView) {} + update(u: ViewUpdate) { + if (completionStatus(u.state) !== "active") return; + this.view.requestMeasure({ + read: () => null, + write: () => { + const scope = this.view.dom.ownerDocument; + const tooltips = [ + ...this.view.dom.querySelectorAll(".cm-tooltip-autocomplete"), + ...scope.querySelectorAll(".cm-tooltip-autocomplete"), + ]; + for (const t of tooltips) { + if (t.querySelector(":scope > .cm-lsp-completion-footer")) continue; + const footer = document.createElement("div"); + footer.className = "cm-lsp-completion-footer"; + footer.textContent = "Press ↵ to insert, ⇥ to replace"; + t.appendChild(footer); + } + }, + }); + } + }, +); + +export function completionExtension() { + return [ + autocompletion({ + override: [gated(lspCompletionSource), gated(contributedSource)], + icons: false, + addToOptions: [ + { render: kindBadge, position: 20 }, + { render: typeColumn, position: 90 }, + ], + optionClass: (c) => { + // IDEA shows keywords in bold. + const keyword = (isLsp(c) ? c.lsp.kind : c.type) === "keyword" ? " cm-lsp-keyword-option" : ""; + return (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword; + }, + tooltipClass: () => "cm-lsp-completion", + }), + completionFooter, + Prec.highest( + keymap.of([ + { + key: "Tab", + run: (view) => { + if (completionStatus(view.state) !== "active") return false; + replaceMode = true; + try { + return acceptCompletion(view); + } finally { + replaceMode = false; + } + }, + }, + ]), + ), + ]; +} + +export { startCompletion }; diff --git a/src/editor/lsp/completionItems.ts b/src/editor/lsp/completionItems.ts new file mode 100644 index 00000000..1064660b --- /dev/null +++ b/src/editor/lsp/completionItems.ts @@ -0,0 +1,75 @@ +/** Pure mapping from backend completion items to CodeMirror completions. */ +import type { Completion } from "@codemirror/autocomplete"; +import type { CompletionItem, CompletionKind } from "@/bindings"; + +/** Completion kind → the design system's symbol badge kind (`ui/icons` `SymbolBadge`). */ +const KIND_TO_SYMBOL: Record = { + text: "text", + method: "method", + function: "function", + constructor: "constructor", + field: "field", + variable: "variable", + class: "class", + interface: "interface", + module: "module", + property: "property", + unit: "constant", + value: "constant", + enum: "enum", + keyword: "keyword", + snippet: "snippet", + color: "constant", + file: "file", + reference: "variable", + folder: "package", + enumMember: "constant", + constant: "constant", + struct: "struct", + event: "field", + operator: "keyword", + typeParameter: "type", +}; + +export function completionSymbolKind(kind: CompletionKind | null): string { + return kind ? KIND_TO_SYMBOL[kind] : "text"; +} + +/** + * Maps the declarative fields. `label` is what CodeMirror filters on, so an + * LSP `filterText` that differs from the label becomes the label and the + * original label is shown through `displayLabel`. + */ +export function toCmCompletion(item: CompletionItem): Completion { + const filter = item.filterText && item.filterText !== item.label ? item.filterText : null; + const c: Completion = { + label: filter ?? item.label, + type: completionSymbolKind(item.kind), + }; + if (filter) c.displayLabel = item.label; + if (item.labelDetail) c.detail = item.labelDetail; + if (item.sortText) c.sortText = item.sortText; + if (item.preselect) c.boost = 99; + else if (item.deprecated) c.boost = -20; + if (item.commitCharacters.length) c.commitCharacters = item.commitCharacters; + return c; +} + +const WORD_CHAR = /[\p{L}\p{N}_$]/u; + +/** End of the identifier starting at `pos` in `line` (for ⇥ Replace without a server range). */ +export function identifierEnd(text: string, pos: number): number { + let i = pos; + while (i < text.length && WORD_CHAR.test(text[i]!)) i++; + return i; +} + +/** Completion trigger character for the text just before the caret, if any. */ +export function triggerCharacter(before: string): string | null { + const last = before.slice(-1); + if (last === ".") return before.slice(-2) === ".." ? null : "."; + if (last === ":") return before.slice(-2) === "::" ? ":" : null; + if (last === ">") return before.slice(-2) === "->" ? ">" : null; + if (last === "@") return "@"; + return null; +} diff --git a/src/editor/lsp/diagnostics.test.ts b/src/editor/lsp/diagnostics.test.ts new file mode 100644 index 00000000..abb5b52b --- /dev/null +++ b/src/editor/lsp/diagnostics.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import type { Diagnostic } from "@/bindings"; +import { diagnosticsFor, setFileDiagnostics, subscribeDiagnostics, toLintDiagnostics } from "./diagnostics"; + +const d = (line: number, col: number, endCol: number, severity: Diagnostic["severity"] = "error", over: Partial = {}): Diagnostic => ({ + range: { start: { line, col }, end: { line, col: endCol } }, + severity, + message: `m${line}:${col}`, + source: null, + code: null, + ...over, +}); + +describe("editor diagnostics store", () => { + it("keeps one list per source and notifies subscribers of the path", () => { + let calls = 0; + const off = subscribeDiagnostics("/p/x.rs", () => calls++); + setFileDiagnostics("/p/x.rs", "rust-analyzer", [d(0, 0, 1)]); + setFileDiagnostics("/p/x.rs", "cargo", [d(1, 0, 1, "warning")]); + expect(diagnosticsFor("/p/x.rs").map((x) => x.source)).toEqual(["rust-analyzer", "cargo"]); + setFileDiagnostics("/p/x.rs", "rust-analyzer", []); + expect(diagnosticsFor("/p/x.rs").map((x) => x.source)).toEqual(["cargo"]); + setFileDiagnostics("/p/other.rs", "cargo", [d(0, 0, 1)]); + expect(calls).toBe(3); + // Clearing an unknown source is a no-op. + setFileDiagnostics("/p/x.rs", "unknown", []); + expect(calls).toBe(3); + off(); + }); +}); + +describe("toLintDiagnostics", () => { + const doc = Text.of(["fn main() {", " let x = 1;", "}"]); + + it("converts ranges, sorts and adds mark classes", () => { + const out = toLintDiagnostics(doc, [ + d(1, 6, 7, "warning", { unnecessary: true, source: "rustc", code: "unused" }), + d(0, 3, 7, "error", { deprecated: true }), + ]); + expect(out).toEqual([ + { from: 3, to: 7, severity: "error", message: "m0:3", source: undefined, markClass: "cm-lsp-deprecated" }, + { from: 18, to: 19, severity: "warning", message: "m1:6", source: "rustc unused", markClass: "cm-lsp-unnecessary" }, + ]); + }); + + it("widens empty ranges to one character", () => { + expect(toLintDiagnostics(doc, [d(0, 2, 2)])[0]).toMatchObject({ from: 2, to: 3 }); + // At a line end the previous character is used. + expect(toLintDiagnostics(doc, [d(2, 1, 1)])[0]).toMatchObject({ from: doc.length - 1, to: doc.length }); + }); +}); diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts new file mode 100644 index 00000000..7d627b5d --- /dev/null +++ b/src/editor/lsp/diagnostics.ts @@ -0,0 +1,224 @@ +/** + * Diagnostics in the editor: the backend pushes full per-(file, producer) + * replacements (`IdeEvent::diagnostics`); each editor shows the union for its + * path through `@codemirror/lint` (wavy underlines, no gutter, no lint + * tooltip — `hover.ts` renders diagnostics together with documentation). + */ +import type { Text } from "@codemirror/state"; +import { EditorSelection, StateEffect, StateField } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint"; +import type { Diagnostic, Severity } from "@/bindings"; +import { onIdeEvent } from "@/platform/ipc"; +import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import { rangeToOffsets } from "./positions"; +import { lspDoc } from "./lsp"; +import { showHint } from "./hint"; +import { editorPrefs } from "@/platform/editorSettings"; + +type Listener = () => void; + +const byPath = new Map>(); +const listeners = new Map>(); + +export function setFileDiagnostics(path: string, source: string, items: Diagnostic[]) { + let sources = byPath.get(path); + if (!items.length) { + if (!sources?.delete(source)) return; + if (!sources.size) byPath.delete(path); + } else { + if (!sources) byPath.set(path, (sources = new Map())); + sources.set(source, items.map((d) => ({ ...d, source: d.source ?? source }))); + } + listeners.get(path)?.forEach((l) => l()); +} + +export function diagnosticsFor(path: string): Diagnostic[] { + const sources = byPath.get(path); + return sources ? [...sources.values()].flat() : []; +} + +export function subscribeDiagnostics(path: string, l: Listener): () => void { + let set = listeners.get(path); + if (!set) listeners.set(path, (set = new Set())); + set.add(l); + return () => { + set.delete(l); + if (!set.size) listeners.delete(path); + }; +} + +export function installDiagnosticsEvents(): () => void { + return onIdeEvent("diagnostics", (e) => setFileDiagnostics(e.path, e.source, e.items)); +} + +const SEVERITY_RANK: Record = { error: 0, warning: 1, info: 2, hint: 3 }; + +/** Backend diagnostics → lint diagnostics for `doc` (empty ranges widened to one character). */ +export function toLintDiagnostics(doc: Text, items: readonly Diagnostic[]): LintDiagnostic[] { + const out: LintDiagnostic[] = items.map((d) => { + let { from, to } = rangeToOffsets(doc, d.range); + if (from === to) { + const line = doc.lineAt(from); + if (to < line.to) to++; + else if (from > line.from) from--; + } + const classes = [d.unnecessary && "cm-lsp-unnecessary", d.deprecated && "cm-lsp-deprecated"].filter(Boolean); + return { + from, + to, + severity: d.severity, + message: d.message, + source: [d.source, d.code].filter(Boolean).join(" ") || undefined, + markClass: classes.length ? classes.join(" ") : undefined, + }; + }); + return out.sort((a, b) => a.from - b.from || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]); +} + +const MARK_COLOR: Partial> = { + error: "var(--danger)", + warning: "var(--warning)", + info: "var(--info)", +}; + +/** Overview-lane (scrollbar) marks for diagnostics, errors on top. */ +export function diagnosticMarks(diags: readonly LintDiagnostic[]): ScrollbarMark[] { + const out: ScrollbarMark[] = []; + for (const d of diags) { + const color = MARK_COLOR[d.severity]; + if (!color || d.markClass?.includes("cm-lsp-unnecessary")) continue; + out.push({ from: d.from, to: d.to, color, tooltip: d.message, priority: 3 - SEVERITY_RANK[d.severity] }); + } + return out; +} + +const setMarks = StateEffect.define(); + +const marksField = StateField.define({ + create: () => [], + update(marks, tr) { + for (const e of tr.effects) if (e.is(setMarks)) return e.value; + if (!tr.docChanged || !marks.length) return marks; + return marks.map((m) => { + const from = tr.changes.mapPos(m.from); + return { ...m, from, to: Math.max(from, tr.changes.mapPos(m.to ?? m.from)) }; + }); + }, + provide: (f) => scrollbarMarks.from(f), +}); + +const diagnosticsSync = ViewPlugin.fromClass( + class { + private unsubscribe: () => void; + private pending = false; + constructor(private view: EditorView) { + const path = view.state.facet(lspDoc)?.path; + this.unsubscribe = path ? subscribeDiagnostics(path, () => this.schedule()) : () => {}; + if (path && diagnosticsFor(path).length) this.schedule(); + } + schedule() { + if (this.pending) return; + this.pending = true; + queueMicrotask(() => { + this.pending = false; + const path = this.view.state.facet(lspDoc)?.path; + if (!path || !this.view.dom.isConnected) return; + const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path)); + const tr = setDiagnostics(this.view.state, lint); + this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] }); + }); + } + destroy() { + this.unsubscribe(); + } + }, +); + +// Effects follow the IDEA schemes: errors wavy red; warnings wavy yellow (dark) or +// a yellow background (light, via --editor-warning-*); weak warnings dotted. +const lintTheme = EditorView.theme({ + ".cm-lintRange": { backgroundImage: "none", paddingBottom: "0" }, + ".cm-lintRange-error": { + textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))", + textDecorationSkipInk: "none", + textUnderlineOffset: "3px", + }, + ".cm-lintRange-warning": { + textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))", + backgroundColor: "var(--editor-warning-background, transparent)", + textDecorationSkipInk: "none", + textUnderlineOffset: "3px", + }, + ".cm-lintRange-info": { + textDecoration: "underline dotted color-mix(in srgb, var(--fg-muted) 80%, transparent)", + textDecorationSkipInk: "none", + textUnderlineOffset: "3px", + }, + ".cm-lintRange-hint": { textDecoration: "none" }, + ".cm-lintRange.cm-lsp-unnecessary": { + textDecoration: "none", + backgroundColor: "transparent", + color: "var(--fg-unused, var(--fg-faint))", + }, + ".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" }, + ".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" }, + ".cm-lintRange-active": { backgroundColor: "transparent" }, + ".cm-lintRange-warning.cm-lintRange-active": { backgroundColor: "var(--editor-warning-background, transparent)" }, + ".cm-lintPoint:after": { display: "none" }, +}); + +export function diagnosticsExtension() { + return [ + linter(null, { tooltipFilter: () => [], autoPanel: false }), + marksField, + diagnosticsSync, + lintTheme, + ]; +} + +export interface PlacedDiagnostic { + from: number; + to: number; + diagnostic: LintDiagnostic; +} + +export function diagnosticsAt(view: EditorView, pos: number): PlacedDiagnostic[] { + const out: PlacedDiagnostic[] = []; + forEachDiagnostic(view.state, (d, from, to) => { + if (from <= pos && pos <= to) out.push({ from, to, diagnostic: d }); + }); + return out.sort((a, b) => SEVERITY_RANK[a.diagnostic.severity] - SEVERITY_RANK[b.diagnostic.severity]); +} + +/** + * F2 / ⇧F2: the next highlighted problem after (before) the caret, wrapping. + * Like IDEA, errors take priority: warnings are visited only when the file has + * no errors. Hints are skipped unless nothing else exists. + */ +export function gotoDiagnostic(view: EditorView, dir: 1 | -1): boolean { + const all: PlacedDiagnostic[] = []; + forEachDiagnostic(view.state, (d, from, to) => all.push({ from, to, diagnostic: d })); + if (!all.length) { + showHint(view, "No problems in this file"); + return false; + } + const best = Math.min(...all.map((p) => Math.min(SEVERITY_RANK[p.diagnostic.severity], 2))); + // "Next error action goes to high priority problems only" (Code Editing). + const highOnly = editorPrefs().codeEditing.nextErrorErrorsOnly; + const list = all + .filter((p) => (highOnly ? Math.min(SEVERITY_RANK[p.diagnostic.severity], 2) === best : true)) + .sort((a, b) => a.from - b.from); + const head = view.state.selection.main.head; + const target = + dir === 1 + ? (list.find((p) => p.from > head) ?? list[0]!) + : ([...list].reverse().find((p) => p.from < head && !(p.from <= head && head <= p.to)) ?? list[list.length - 1]!); + view.dispatch({ selection: EditorSelection.cursor(target.from), scrollIntoView: true, userEvent: "select" }); + showHint(view, target.diagnostic.message, { + pos: target.from, + tone: target.diagnostic.severity === "error" ? "error" : target.diagnostic.severity === "warning" ? "warning" : "info", + timeout: 6000, + }); + return true; +} diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts new file mode 100644 index 00000000..f870d7f2 --- /dev/null +++ b/src/editor/lsp/features.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import type { CodeAction } from "@/bindings"; +import { semanticRanges } from "./semantic"; +import { sortCodeActions } from "./intentions"; +import { splitSignature } from "./signature"; +import { dedupeLocations } from "./navigation"; + +describe("semanticRanges", () => { + const doc = Text.of(["let mut count = Foo::new();", "const MAX: u32 = 1;"]); + const tokens = { + tokenTypes: ["variable", "struct", "function", "keyword"], + tokenModifiers: ["mutable", "constant", "static", "deprecated"], + // [line, col, len, type, modifiers] + data: [0, 8, 5, 0, 0b0001, 0, 16, 3, 1, 0, 0, 21, 3, 2, 0b1000, 1, 6, 3, 0, 0b0010, 0, 0, 3, 3, 0, 9, 0, 1, 1, 0], + }; + + it("maps types and modifiers to classes and skips plain variables / unknown types", () => { + expect(semanticRanges(doc, tokens)).toEqual([ + { from: 8, to: 13, className: "cm-sem-mutable" }, + { from: 16, to: 19, className: "cm-sem-type" }, + { from: 21, to: 24, className: "cm-sem-function cm-sem-deprecated" }, + { from: 34, to: 37, className: "cm-sem-constant" }, + ]); + }); +}); + +describe("sortCodeActions", () => { + const a = (title: string, kind: string | null, isPreferred = false, disabledReason: string | null = null): CodeAction => ({ + title, + kind, + isPreferred, + disabledReason, + edit: null, + command: null, + data: null, + server: 1, + }); + + it("puts preferred quick fixes first and disabled actions last", () => { + const sorted = sortCodeActions([ + a("extract", "refactor.extract"), + a("disabled fix", "quickfix", false, "nope"), + a("fix", "quickfix"), + a("preferred fix", "quickfix.import", true), + a("preferred refactor", "refactor", true), + ]); + expect(sorted.map((x) => x.title)).toEqual(["preferred fix", "fix", "preferred refactor", "extract", "disabled fix"]); + }); +}); + +describe("splitSignature", () => { + it("splits around the active parameter", () => { + const label = "fn create(req: Request, user: Long) -> Order"; + expect(splitSignature(label, [[10, 22], [24, 34]], 1)).toEqual({ + before: "fn create(req: Request, ", + param: "user: Long", + after: ") -> Order", + }); + expect(splitSignature(label, [], 0).param).toBe(""); + }); +}); + +describe("dedupeLocations", () => { + it("drops duplicate targets", () => { + const loc = (path: string, line: number) => ({ path, range: { start: { line, col: 0 }, end: { line, col: 1 } } }); + expect(dedupeLocations([loc("/a", 1), loc("/a", 1), loc("/b", 1)])).toHaveLength(2); + }); +}); diff --git a/src/editor/lsp/files.ts b/src/editor/lsp/files.ts new file mode 100644 index 00000000..4eb574fa --- /dev/null +++ b/src/editor/lsp/files.ts @@ -0,0 +1,36 @@ +/** Reading other files' text for previews (definitions, choosers). */ +import { editorsFor } from "@/editor/api"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; + +/** + * Lines of `path`: from an open editor when there is one (includes unsaved + * edits), otherwise a read-only backend read. + */ +export async function readFileLines(path: string): Promise { + const open = editorsFor(path)[0]; + if (open) return open.view.state.doc.toJSON(); + if (!isTauri()) return null; + if (isVirtualUri(path)) + return virtualDocument(path).then( + (d) => d.text.split(/\r\n?|\n/), + () => null, + ); + try { + const text = await call(commands.fsReadText(path)); + return text.split(/\r\n?|\n/); + } catch { + return null; + } +} + +/** Reads several files with bounded concurrency; missing files map to `null`. */ +export async function readManyFileLines(paths: readonly string[], concurrency = 4): Promise> { + const out = new Map(); + const queue = [...new Set(paths)]; + const worker = async () => { + for (let p = queue.shift(); p !== undefined; p = queue.shift()) out.set(p, await readFileLines(p)); + }; + await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker)); + return out; +} diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts new file mode 100644 index 00000000..3399a583 --- /dev/null +++ b/src/editor/lsp/format.ts @@ -0,0 +1,76 @@ +/** + * Reformat Code (⌥⌘L): `lsp_format` for the selection (or the whole file), + * applied locally as one CodeMirror transaction and one undo step; editor/core + * syncs it to the backend like any user edit. + */ +import { indentUnit } from "@codemirror/language"; +import { isolateHistory } from "@codemirror/commands"; +import type { EditorView } from "@codemirror/view"; +import { commands } from "@/platform/ipc"; +import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; +import { docOf, lspRequest, reportLspError } from "./lsp"; +import { offsetToPos, textEditsToChanges } from "./positions"; +import { showHint } from "./hint"; + +export interface FormatResult { + applied: boolean; + /** Number of changed regions. */ + changes: number; +} + +export async function formatDocument(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }): Promise { + const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); + if (own) return applyContributedFormat(view, own, opts); + const doc = docOf(view); + if (!doc || doc.readOnly) return { applied: false, changes: 0 }; + // The server formats the backend's copy: let pending keystrokes reach it first. + await whenSynced(view); + const state = view.state; + const sel = state.selection.main; + const useSelection = (opts.selectionOnly ?? true) && !sel.empty; + const range = useSelection ? { start: offsetToPos(state.doc, sel.from), end: offsetToPos(state.doc, sel.to) } : null; + const options = { tabSize: state.tabSize, insertSpaces: !state.facet(indentUnit).includes("\t") }; + let edits; + try { + edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); + } catch (e) { + reportLspError(view, e, opts.explicit); + return { applied: false, changes: 0 }; + } + if (view.state.doc !== state.doc) { + if (opts.explicit) showHint(view, "The file changed while formatting; try again"); + return { applied: false, changes: 0 }; + } + const changes = textEditsToChanges(state.doc, edits); + if (!changes.length) { + if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); + return { applied: false, changes: 0 }; + } + view.dispatch({ + changes, + userEvent: "format", + annotations: isolateHistory.of("full"), + scrollIntoView: false, + }); + return { applied: true, changes: changes.length }; +} + +function applyContributedFormat( + view: EditorView, + format: ContributedFormatter, + opts: { explicit: boolean; selectionOnly?: boolean }, +): FormatResult { + const state = view.state; + if (state.readOnly) return { applied: false, changes: 0 }; + const sel = state.selection.main; + const useSelection = (opts.selectionOnly ?? true) && !sel.empty; + const changes = format(state, useSelection ? { from: sel.from, to: sel.to } : null).filter( + (c) => state.sliceDoc(c.from, c.to) !== c.insert, + ); + if (!changes.length) { + if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); + return { applied: false, changes: 0 }; + } + view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false }); + return { applied: true, changes: changes.length }; +} diff --git a/src/editor/lsp/hint.ts b/src/editor/lsp/hint.ts new file mode 100644 index 00000000..bc9e8974 --- /dev/null +++ b/src/editor/lsp/hint.ts @@ -0,0 +1,76 @@ +/** + * Transient editor hints (IDEA's balloon at the caret): "No language server + * available", "Cannot find declaration to go to", error messages on F2… + * Dismissed by the next edit / caret move, Escape, or after a timeout. + */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, keymap, showTooltip, type Tooltip } from "@codemirror/view"; +import { Prec } from "@codemirror/state"; + +interface Hint { + pos: number; + content: string | (() => HTMLElement); + tone: "info" | "error" | "warning"; +} + +const setHint = StateEffect.define(); + +const hintField = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setHint)) return e.value; + if (value && (tr.docChanged || tr.selection)) return null; + return value; + }, + provide: (f) => + showTooltip.from(f, (h): Tooltip | null => + h + ? { + pos: h.pos, + above: true, + strictSide: false, + create: () => { + const dom = document.createElement("div"); + dom.className = `cm-lsp-hint cm-lsp-hint-${h.tone}`; + dom.setAttribute("role", "status"); + if (typeof h.content === "string") dom.textContent = h.content; + else dom.appendChild(h.content()); + return { dom }; + }, + } + : null, + ), +}); + +const timers = new WeakMap>(); + +export function showHint( + view: EditorView, + content: Hint["content"], + opts: { pos?: number; tone?: Hint["tone"]; timeout?: number } = {}, +) { + const pos = opts.pos ?? view.state.selection.main.head; + const effects: StateEffect[] = [setHint.of({ pos, content, tone: opts.tone ?? "info" })]; + if (!view.state.field(hintField, false)) effects.push(StateEffect.appendConfig.of(hintExtension())); + view.dispatch({ effects }); + clearTimeout(timers.get(view)); + timers.set( + view, + setTimeout(() => { + if (view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) }); + }, opts.timeout ?? 4000), + ); +} + +export function hideHint(view: EditorView): boolean { + if (!view.state.field(hintField, false)) return false; + view.dispatch({ effects: setHint.of(null) }); + return true; +} + +export function hintExtension(): Extension { + return [ + hintField, + Prec.high(keymap.of([{ key: "Escape", run: (view) => hideHint(view) }])), + ]; +} diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts new file mode 100644 index 00000000..82265189 --- /dev/null +++ b/src/editor/lsp/hover.ts @@ -0,0 +1,176 @@ +/** + * Hover tooltip (500ms): diagnostics under the pointer + quick documentation, + * plus the explicit actions code.quickDocumentation (F1, sticky popup) and + * code.quickDefinition (⌥Space, definition source popup). + */ +import { EditorSelection } from "@codemirror/state"; +import { hoverTooltip, type EditorView } from "@codemirror/view"; +import { commands, isTauri, nextRequestId, type Hover, type RequestId } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; +import { shortcutLabel } from "@/platform/keymap"; +import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics"; +import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown"; +import { docOf, lspRequest, reportLspError } from "./lsp"; +import { offsetToPos, rangeToOffsets } from "./positions"; +import { showHint } from "./hint"; +import { caretAnchor, openPopup } from "./popupStore"; +import { readFileLines } from "./files"; +import { iconNode } from "./iconMarkup"; +import { editorPrefs } from "@/platform/editorSettings"; +import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api"; + +let hoverRequest: RequestId | null = null; + +export async function requestHover(view: EditorView, pos: number): Promise { + const own = contributedAt(view.state, CONTRIBUTED_HOVER, pos); + if (own) { + const h = own(view.state, pos); + if (!h) return null; + const range = { start: offsetToPos(view.state.doc, h.from), end: offsetToPos(view.state.doc, h.to) }; + return { contents: { kind: "markdown", value: h.markdown }, range }; + } + const doc = docOf(view); + if (!doc) return null; + if (hoverRequest !== null && isTauri()) void commands.cancelRequest(hoverRequest); + const id = (hoverRequest = nextRequestId()); + try { + const h = await lspRequest(() => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos))); + return h && !isEmptyMarkup(h.contents) ? h : null; + } finally { + if (hoverRequest === id) hoverRequest = null; + } +} + +function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLElement { + const box = document.createElement("div"); + box.className = "cm-lsp-hover-diagnostics"; + for (const { diagnostic: d, from } of diags) { + const row = document.createElement("div"); + row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`; + row.appendChild(iconNode(d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", 16, "cm-lsp-diag-icon")); + const msg = document.createElement("div"); + msg.className = "cm-lsp-diag-message"; + msg.textContent = d.message; + row.appendChild(msg); + const meta = document.createElement("div"); + meta.className = "cm-lsp-diag-meta"; + if (d.source) { + const src = document.createElement("span"); + src.className = "cm-lsp-diag-source"; + src.textContent = d.source; + meta.appendChild(src); + } + const fix = document.createElement("button"); + fix.type = "button"; + fix.className = "cm-lsp-link-button"; + const keys = shortcutLabel("code.showIntentions"); + fix.textContent = keys ? `More actions… ${keys}` : "More actions…"; + fix.addEventListener("mousedown", (e) => e.preventDefault()); + fix.addEventListener("click", () => { + view.dispatch({ selection: EditorSelection.cursor(from) }); + view.focus(); + void executeAction("code.showIntentions"); + }); + meta.appendChild(fix); + row.appendChild(meta); + box.appendChild(row); + } + return box; +} + +/** Hover: problems always; quick documentation per Settings › Editor › Code Editing. */ +export function hoverExtension() { + return hoverTooltip( + async (view, pos) => { + const diags = diagnosticsAt(view, pos); + let hover: Hover | null = null; + if (editorPrefs().codeEditing.quickDocOnHover) + try { + hover = await requestHover(view, pos); + } catch (e) { + reportLspError(view, e, false); + } + if (!diags.length && !hover) return null; + let from = pos, + to = pos; + if (hover?.range) ({ from, to } = rangeToOffsets(view.state.doc, hover.range)); + else if (diags.length) ({ from, to } = diags[0]!); + else { + const word = view.state.wordAt(pos); + if (word) ({ from, to } = word); + } + const lang = docOf(view)?.uiMode ?? null; + return { + pos: from, + end: to, + above: true, + create: () => { + const dom = document.createElement("div"); + dom.className = "cm-lsp-hover"; + if (diags.length) dom.appendChild(renderDiagnostics(view, diags)); + if (hover) dom.appendChild(renderMarkup(hover.contents, lang)); + return { dom }; + }, + }; + }, + { hoverTime: Math.max(100, editorPrefs().codeEditing.quickDocDelay) }, + ); +} + +/** F1: documentation for the symbol at the caret in a sticky popup. */ +export async function quickDocumentation(view: EditorView) { + const pos = view.state.selection.main.head; + try { + const hover = await requestHover(view, pos); + if (!hover) { + showHint(view, "No documentation found"); + return; + } + openPopup({ + kind: "doc", + anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), + markup: hover.contents, + language: docOf(view)?.uiMode ?? null, + themeClasses: view.themeClasses, + }); + } catch (e) { + reportLspError(view, e, true); + } +} + +const DEFINITION_CONTEXT_BEFORE = 1; +const DEFINITION_MAX_LINES = 30; + +/** ⌥Space: the definition's source in a popup, without leaving the editor. */ +export async function quickDefinition(view: EditorView) { + const doc = docOf(view); + if (!doc) return; + const pos = view.state.selection.main.head; + try { + const locations = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition")); + const loc = locations[0]; + if (!loc) { + showHint(view, "No definition found"); + return; + } + const lines = await readFileLines(loc.path); + if (!lines) { + showHint(view, "Cannot read the definition's file"); + return; + } + const first = Math.max(0, loc.range.start.line - DEFINITION_CONTEXT_BEFORE); + const spanEnd = Math.max(loc.range.end.line, loc.range.start.line + 12); + const last = Math.min(lines.length - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)); + openPopup({ + kind: "definition", + anchor: caretAnchor(view, pos), + location: loc, + firstLine: first, + text: lines.slice(first, last + 1).join("\n"), + language: languageForPath(loc.path) ?? doc.uiMode, + themeClasses: view.themeClasses, + }); + } catch (e) { + reportLspError(view, e, true); + } +} diff --git a/src/editor/lsp/iconMarkup.ts b/src/editor/lsp/iconMarkup.ts new file mode 100644 index 00000000..fd5ad24b --- /dev/null +++ b/src/editor/lsp/iconMarkup.ts @@ -0,0 +1,10 @@ +/** Design-system icons as DOM nodes for CodeMirror widgets. */ +import { iconMarkupOrFallback, type IconName } from "@/ui"; + +export function iconNode(name: IconName | (string & {}), size = 16, className?: string): HTMLElement { + const span = document.createElement("span"); + span.className = className ? `cm-openidea-icon ${className}` : "cm-openidea-icon"; + span.setAttribute("aria-hidden", "true"); + span.innerHTML = iconMarkupOrFallback(name, size); + return span; +} diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts new file mode 100644 index 00000000..4f2760a2 --- /dev/null +++ b/src/editor/lsp/index.ts @@ -0,0 +1,63 @@ +/** + * LSP-backed editor features: completion, diagnostics, hover / quick docs, + * parameter info, navigation, document highlights, intentions, rename, + * formatting, inlay hints and semantic highlighting. Everything is contributed + * through `editor/api` as CodeMirror extensions plus actions; editor/core is + * never imported. + */ +import type { Extension } from "@codemirror/state"; +import { registerEditorContribution, type EditorDocContext } from "@/editor/api"; +import { registerOverlay } from "@/workbench/contributions"; +import { lspDoc } from "./lsp"; +import { hintExtension } from "./hint"; +import { completionExtension } from "./completion"; +import { diagnosticsExtension, installDiagnosticsEvents } from "./diagnostics"; +import { hoverExtension } from "./hover"; +import { signatureExtension } from "./signature"; +import { navigationExtension } from "./navigation"; +import { intentionsExtension } from "./intentions"; +import { inlayExtension } from "./inlay"; +import { semanticExtension } from "./semantic"; +import { usagesLensExtension } from "./codeLens"; +import { autoImportExtension } from "./autoImport"; +import { installAvailabilityEvents } from "./availability"; +import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from "./actions"; +import { lspTheme } from "./theme"; +import { inspectionWidgetExtension } from "./inspectionWidget"; +import { LspPopups } from "./Popups"; +import { prefetchMarkdown } from "./markdown"; +import { registerNavigationActions } from "@/editor/navigation"; + +export function lspExtensions(ctx: EditorDocContext): Extension[] { + return [ + lspDoc.of(ctx), + lspTheme, + hintExtension(), + diagnosticsExtension(), + inspectionWidgetExtension(), + hoverExtension(), + signatureExtension(), + completionExtension(), + navigationExtension(), + intentionsExtension(), + inlayExtension(), + semanticExtension(), + usagesLensExtension(), + autoImportExtension(), + ]; +} + +export function activate(): () => void { + const disposers = [ + installDiagnosticsEvents(), + installAvailabilityEvents(), + registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }), + registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), + registerLspActions(), + registerNavigationActions(), + registerFormatOnSave(), + registerActionsOnSave(), + ]; + prefetchMarkdown(); + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/editor/lsp/inlay.ts b/src/editor/lsp/inlay.ts new file mode 100644 index 00000000..d93f51b0 --- /dev/null +++ b/src/editor/lsp/inlay.ts @@ -0,0 +1,115 @@ +/** + * Inlay hints (settings.editor.inlayHints) for the visible range, refreshed + * after edits / scrolling (debounced) and rendered as subtle inline widgets. + */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { commands, type InlayHint } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { editorPrefs } from "@/platform/editorSettings"; +import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; +import { offsetsToRange, posToOffset } from "./positions"; +import { onLspRefresh, serverBackoff } from "./availability"; + +class InlayWidget extends WidgetType { + constructor(readonly hint: InlayHint) { + super(); + } + eq(other: InlayWidget) { + const a = this.hint, + b = other.hint; + return a.label === b.label && a.kind === b.kind && a.paddingLeft === b.paddingLeft && a.paddingRight === b.paddingRight; + } + toDOM() { + const el = document.createElement("span"); + el.className = `cm-lsp-inlay cm-lsp-inlay-${this.hint.kind}`; + if (this.hint.paddingLeft) el.classList.add("cm-lsp-inlay-pl"); + if (this.hint.paddingRight) el.classList.add("cm-lsp-inlay-pr"); + el.textContent = this.hint.label; + if (this.hint.tooltip) el.title = this.hint.tooltip; + el.setAttribute("aria-hidden", "true"); + return el; + } + ignoreEvent() { + return false; + } +} + +const setInlays = StateEffect.define(); + +const inlayField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + for (const e of tr.effects) if (e.is(setInlays)) return e.value; + return tr.docChanged ? deco.map(tr.changes) : deco; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +export function inlayDecorations(view: EditorView, hints: readonly InlayHint[]): DecorationSet { + const doc = view.state.doc; + return Decoration.set( + hints.map((h) => + Decoration.widget({ widget: new InlayWidget(h), side: h.kind === "parameter" ? 1 : -1 }).range(posToOffset(doc, h.position)), + ), + true, + ); +} + +const enabled = (language: string) => + useProject.getState().settings?.editor?.inlayHints !== false && !editorPrefs().inlay.disabledLanguages.includes(language); + +/** Inlay Hints settings: per kind (types / parameters / other). */ +function shownKind(kind: InlayHint["kind"]): boolean { + const p = editorPrefs().inlay; + return kind === "type" ? p.types : kind === "parameter" ? p.parameters : p.other; +} + +const inlayPlugin = ViewPlugin.fromClass( + class { + private refresh = debounce(() => void this.run(), 400); + private unsubscribe: () => void; + private offReady: () => void; + constructor(private view: EditorView) { + this.refresh(); + this.unsubscribe = useProject.subscribe((s, prev) => { + if (s.settings?.editor?.inlayHints !== prev.settings?.editor?.inlayHints || s.settings?.editor?.inlay !== prev.settings?.editor?.inlay) + this.refresh(); + }); + this.offReady = onLspRefresh("inlayHints", () => this.refresh()); + } + update(u: ViewUpdate) { + if (u.docChanged || u.viewportChanged) this.refresh(); + } + async run() { + const view = this.view; + if (!view.dom.isConnected) return; + const doc = docOf(view); + if (!doc) return; + if (!enabled(doc.language) || serverBackoff.active(doc.doc)) { + if (view.state.field(inlayField).size) view.dispatch({ effects: setInlays.of(Decoration.none) }); + return; + } + const state = view.state; + const { from, to } = view.viewport; + const first = state.doc.lineAt(from).from; + const last = state.doc.lineAt(to).to; + try { + const hints = await lspRequest(() => commands.lspInlayHints(doc.doc, offsetsToRange(state.doc, first, last))); + if (view.state.doc !== state.doc) return this.refresh(); + view.dispatch({ effects: setInlays.of(inlayDecorations(view, hints.filter((h) => shownKind(h.kind)))) }); + } catch (e) { + if (isUnavailable(e)) serverBackoff.mark(doc.doc); + } + } + destroy() { + this.refresh.cancel(); + this.unsubscribe(); + this.offReady(); + } + }, +); + +export function inlayExtension(): Extension { + return [inlayField, inlayPlugin]; +} diff --git a/src/editor/lsp/inspectionWidget.ts b/src/editor/lsp/inspectionWidget.ts new file mode 100644 index 00000000..9fdcae16 --- /dev/null +++ b/src/editor/lsp/inspectionWidget.ts @@ -0,0 +1,157 @@ +/** + * IDEA's inspection widget at the top-right of the editor: problem counts per + * severity (or a green check), click opens Problems; ˄ / ˅ jump to the + * previous / next highlighted problem. + */ +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { forEachDiagnostic } from "@codemirror/lint"; +import { shortcutLabel } from "@/platform/keymap"; +import { toolWindowService } from "@/workbench/contributions"; +import { gotoDiagnostic } from "./diagnostics"; +import { iconNode } from "./iconMarkup"; + +interface Counts { + error: number; + warning: number; + info: number; +} + +function countsOf(view: EditorView): Counts { + const c: Counts = { error: 0, warning: 0, info: 0 }; + forEachDiagnostic(view.state, (d) => { + if (d.markClass?.includes("cm-lsp-unnecessary")) return; + if (d.severity === "error") c.error++; + else if (d.severity === "warning") c.warning++; + else if (d.severity === "info") c.info++; + }); + return c; +} + +function button(label: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.type = "button"; + b.className = "cm-openidea-inspection-button"; + b.setAttribute("aria-label", label); + b.title = label; + b.addEventListener("mousedown", (e) => e.preventDefault()); + b.addEventListener("click", onClick); + return b; +} + +class InspectionWidget { + dom: HTMLElement; + private key = ""; + + constructor(private view: EditorView) { + this.dom = document.createElement("div"); + this.dom.className = "cm-openidea-inspection"; + this.dom.setAttribute("role", "toolbar"); + this.dom.setAttribute("aria-label", "Inspections"); + view.dom.appendChild(this.dom); + this.render(); + } + + update(u: ViewUpdate) { + if (u.transactions.some((tr) => tr.effects.length) || u.docChanged) this.render(); + } + + private render() { + const c = countsOf(this.view); + const key = `${c.error}/${c.warning}/${c.info}`; + if (key === this.key) return; + this.key = key; + const open = () => { + try { + toolWindowService().show("problems", true); + } catch { + // no Problems tool window registered + } + }; + const total = c.error + c.warning + c.info; + const summary = button( + total ? `${c.error} errors, ${c.warning} warnings, ${c.info} weak warnings — open Problems` : "No problems found", + open, + ); + summary.classList.add("cm-openidea-inspection-summary"); + if (!total) summary.appendChild(iconNode("check", 14, "cm-openidea-inspection-ok")); + const add = (n: number, icon: string, cls: string) => { + if (!n) return; + const item = document.createElement("span"); + item.className = `cm-openidea-inspection-count ${cls}`; + item.appendChild(iconNode(icon, 14)); + item.appendChild(document.createTextNode(String(n))); + summary.appendChild(item); + }; + add(c.error, "error", "cm-openidea-inspection-error"); + add(c.warning, "warning", "cm-openidea-inspection-warning"); + add(c.info, "warning", "cm-openidea-inspection-weak"); + const children: HTMLElement[] = [summary]; + if (total) { + const prev = button(`Previous Highlighted Error${shortcutLabel("navigate.prevError") ? ` (${shortcutLabel("navigate.prevError")})` : ""}`, () => { + gotoDiagnostic(this.view, -1); + this.view.focus(); + }); + prev.appendChild(iconNode("chevron-up", 14)); + const next = button(`Next Highlighted Error${shortcutLabel("navigate.nextError") ? ` (${shortcutLabel("navigate.nextError")})` : ""}`, () => { + gotoDiagnostic(this.view, 1); + this.view.focus(); + }); + next.appendChild(iconNode("chevron-down", 14)); + children.push(prev, next); + } + this.dom.replaceChildren(...children); + } + + destroy() { + this.dom.remove(); + } +} + +const inspectionPlugin = ViewPlugin.fromClass(InspectionWidget); + +const inspectionTheme = EditorView.theme({ + ".cm-openidea-inspection": { + position: "absolute", + top: "4px", + right: "16px", + zIndex: "5", + display: "flex", + alignItems: "center", + gap: "1px", + padding: "1px", + borderRadius: "var(--radius-small)", + backgroundColor: "var(--bg-editor, var(--bg-island, var(--bg-app)))", + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-ui)", + color: "var(--fg-muted)", + WebkitUserSelect: "none", + userSelect: "none", + }, + ".cm-openidea-inspection-button": { + display: "inline-flex", + alignItems: "center", + gap: "6px", + height: "22px", + minWidth: "22px", + justifyContent: "center", + padding: "0 3px", + border: "none", + borderRadius: "var(--radius-small)", + background: "none", + color: "inherit", + font: "inherit", + }, + ".cm-openidea-inspection-button:hover": { backgroundColor: "var(--bg-toolbar-hover)" }, + ".cm-openidea-inspection-button:focus-visible": { outline: "1px solid var(--focus-ring)", outlineOffset: "-1px" }, + ".cm-openidea-inspection-summary": { padding: "0 5px" }, + ".cm-openidea-inspection-count": { display: "inline-flex", alignItems: "center", gap: "3px" }, + ".cm-openidea-icon": { display: "inline-flex", alignItems: "center" }, + ".cm-openidea-inspection-error .cm-openidea-icon": { color: "var(--danger)" }, + ".cm-openidea-inspection-warning .cm-openidea-icon": { color: "var(--warning)" }, + ".cm-openidea-inspection-weak .cm-openidea-icon": { color: "var(--fg-weak-warning, var(--fg-faint))" }, + ".cm-openidea-inspection-ok": { color: "var(--success)" }, +}); + +export function inspectionWidgetExtension() { + return [inspectionPlugin, inspectionTheme]; +} diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts new file mode 100644 index 00000000..f6f9c184 --- /dev/null +++ b/src/editor/lsp/intentions.ts @@ -0,0 +1,309 @@ +/** + * Code actions: the lightbulb on the caret line (debounced lookup), the ⌥↵ + * intentions popup (quick fixes first, preferred in bold), Optimize Imports and + * Generate. Refactor This and the Refactor menu live in `@/editor/refactor`. + */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { forEachDiagnostic } from "@codemirror/lint"; +import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; +import { whenSynced } from "@/editor/api"; +import { shortcutLabel } from "@/platform/keymap"; +import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; +import { offsetsToRange } from "./positions"; +import { showHint } from "./hint"; +import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { serverBackoff } from "./availability"; +import { iconNode } from "./iconMarkup"; +import { editorPrefs } from "@/platform/editorSettings"; + +const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix.")); + +/** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */ +export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] { + const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1); + return actions + .map((a, i) => ({ a, i })) + .sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i) + .map((x) => x.a); +} + +async function fetchActions(view: EditorView, only: string[], whole = false): Promise { + const doc = docOf(view); + if (!doc) return []; + await whenSynced(view); + const sel = view.state.selection.main; + const range = whole ? offsetsToRange(view.state.doc, 0, view.state.doc.length) : offsetsToRange(view.state.doc, sel.from, sel.to); + return lspRequest(() => commands.lspCodeActions(doc.doc, range, only)); +} + +export async function applyCodeAction(view: EditorView, action: CodeAction) { + const doc = docOf(view); + if (!doc) return; + if (action.disabledReason) { + showHint(view, action.disabledReason); + return; + } + try { + await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action)); + } catch (e) { + reportLspError(view, e, true); + } +} + +/** + * Applies the first enabled whole-file action of `kind` without UI (Actions on + * Save). Waits until the backend edit reached this editor; false when none. + */ +export async function applyKindSilently(view: EditorView, kind: string): Promise { + const doc = docOf(view); + if (!doc || doc.readOnly) return false; + let actions: CodeAction[]; + try { + actions = (await fetchActions(view, [kind], true)).filter((a) => a.kind?.startsWith(kind) && !a.disabledReason); + } catch { + return false; + } + const action = actions.find((a) => a.isPreferred) ?? actions[0]; + if (!action) return false; + const before = view.state.doc; + try { + await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action)); + } catch { + return false; + } + for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20)); + return true; +} + +function actionItems(view: EditorView, actions: CodeAction[]): ListPopupItem[] { + return sortCodeActions(actions).map((a, i) => ({ + id: `${i}`, + label: a.title, + icon: isQuickFix(a) ? "bulb-fix" : "bulb", + emphasis: a.isPreferred, + disabled: a.disabledReason, + run: () => applyCodeAction(view, a), + })); +} + +/** ⌥↵ */ +export async function showIntentions(view: EditorView) { + try { + // Like IDEA, only applicable intentions are listed (servers also report disabled refactorings). + const actions = (await fetchActions(view, [])).filter((a) => !a.disabledReason); + if (!actions.length) { + showHint(view, "No intentions available"); + return; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, view.state.selection.main.head), + title: "Intentions", + items: actionItems(view, actions), + }); + } catch (e) { + reportLspError(view, e, true); + } +} + +/** Runs the single matching action of `kind` (optionally filtered by title), or offers a choice. */ +export async function runKindAction( + view: EditorView, + /** LSP `CodeActionKind` prefix; `""` for any kind. */ + kind: string, + opts: { + title?: RegExp; + whole?: boolean; + none: string; + label: string; + /** + * Syntax-based fallback when the server offers no enabled action + * (`empty`) or cannot answer; true when it handled the request. + */ + fallback?: (reason: "notReady" | "error" | "empty") => Promise; + }, +) { + let actions: CodeAction[]; + try { + // An empty kind asks for every action (filtered by title only). + actions = await fetchActions(view, kind ? [kind] : [], opts.whole); + } catch (e) { + if (!isCancelled(e) && opts.fallback && (await opts.fallback(isUnavailable(e) ? "notReady" : "error"))) return; + reportLspError(view, e, true); + return; + } + try { + if (kind) actions = actions.filter((a) => a.kind?.startsWith(kind) ?? false); + if (opts.title) actions = actions.filter((a) => opts.title!.test(a.title)); + const enabled = actions.filter((a) => !a.disabledReason); + if (!enabled.length) { + if (opts.fallback && (await opts.fallback("empty"))) return; + showHint(view, actions[0]?.disabledReason ?? noActionReason(view, opts.none)); + return; + } + if (enabled.length === 1) { + await applyCodeAction(view, enabled[0]!); + return; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, view.state.selection.main.head), + title: opts.label, + items: actionItems(view, enabled), + }); + } catch (e) { + reportLspError(view, e, true); + } +} + +/** Servers offer no refactorings over broken code; say so instead of a bare "cannot". */ +function noActionReason(view: EditorView, none: string): string { + const sel = view.state.selection.main; + const from = view.state.doc.lineAt(sel.from).from; + const to = view.state.doc.lineAt(sel.to).to; + let error: string | null = null; + forEachDiagnostic(view.state, (d, a, b) => { + if (!error && d.severity === "error" && a <= to && b >= from) error = d.message; + }); + return error ? `${none}: fix the error first (${error})` : none; +} + +// --------------------------------------------------------------------------- +// Lightbulb + +interface Bulb { + line: number; + quickFix: boolean; +} + +const setBulb = StateEffect.define(); + +const bulbField = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setBulb)) return e.value; + if (value && (tr.docChanged || tr.selection)) { + const line = tr.state.doc.lineAt(tr.state.selection.main.head).number; + return line === value.line && !tr.docChanged ? value : null; + } + return value; + }, +}); + +const bulbPlugin = ViewPlugin.fromClass( + class { + private lookup = debounce(() => void this.run(), 500); + constructor(private view: EditorView) { + this.lookup(); + } + update(u: ViewUpdate) { + if (u.docChanged || u.selectionSet) this.lookup(); + } + async run() { + const doc = docOf(this.view); + if (!doc || doc.readOnly || serverBackoff.active(doc.doc) || !this.view.dom.isConnected) return; + const state = this.view.state; + try { + const actions = (await fetchActions(this.view, [])).filter((a) => !a.disabledReason); + if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return; + const line = state.doc.lineAt(state.selection.main.head).number; + this.view.dispatch({ + effects: setBulb.of(actions.length ? { line, quickFix: actions.some(isQuickFix) } : null), + }); + } catch (e) { + if (isUnavailable(e)) serverBackoff.mark(doc.doc); + } + } + destroy() { + this.lookup.cancel(); + } + }, +); + +/** + * New-UI bulb: floats at the start of the text on the caret line, or on the + * line below / above when the caret line's text starts at the left edge. + */ +const BULB_COLUMNS = 3; + +function bulbLine(state: EditorView["state"], line: number): number { + const doc = state.doc; + const free = (n: number) => { + if (n < 1 || n > doc.lines) return false; + const text = doc.line(n).text; + return text.length - text.trimStart().length >= BULB_COLUMNS || !text.trim(); + }; + if (free(line)) return line; + if (free(line + 1)) return line + 1; + if (free(line - 1)) return line - 1; + return line; +} + +class FloatingBulb { + dom: HTMLElement; + private shown: Bulb | null = null; + + constructor(private view: EditorView) { + this.dom = document.createElement("span"); + this.dom.className = "cm-lsp-bulb"; + this.dom.setAttribute("role", "button"); + this.dom.setAttribute("aria-label", "Show intentions"); + this.dom.style.display = "none"; + this.dom.appendChild(iconNode("bulb", 16)); + this.dom.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + void showIntentions(this.view); + }); + view.scrollDOM.appendChild(this.dom); + this.place(); + } + + update(u: ViewUpdate) { + const bulb = u.state.field(bulbField, false) ?? null; + if (bulb !== this.shown || u.geometryChanged || u.viewportChanged) this.place(); + } + + private place() { + const bulb = this.view.state.field(bulbField, false) ?? null; + this.shown = bulb; + if (!bulb || bulb.line > this.view.state.doc.lines || !editorPrefs().appearance.intentionBulb) { + this.dom.style.display = "none"; + return; + } + const keys = shortcutLabel("code.showIntentions"); + this.dom.title = `Show intentions${keys ? ` (${keys})` : ""}`; + this.dom.classList.toggle("cm-lsp-bulb-fix", bulb.quickFix); + this.view.requestMeasure({ + key: this, + read: (view) => { + const line = view.state.doc.line(bulbLine(view.state, bulb.line)); + const block = view.lineBlockAt(line.from); + const content = view.contentDOM.getBoundingClientRect(); + const scroller = view.scrollDOM.getBoundingClientRect(); + return { + top: block.top + (view.documentTop - scroller.top) + view.scrollDOM.scrollTop, + left: content.left - scroller.left + view.scrollDOM.scrollLeft + 4, + height: block.height, + }; + }, + write: ({ top, left, height }) => { + this.dom.style.display = ""; + this.dom.style.top = `${top}px`; + this.dom.style.left = `${left}px`; + this.dom.style.height = `${Math.min(height, this.view.defaultLineHeight)}px`; + }, + }); + } + + destroy() { + this.dom.remove(); + } +} + +const floatingBulb = ViewPlugin.fromClass(FloatingBulb); + +export function intentionsExtension(): Extension { + return [bulbField, bulbPlugin, floatingBulb]; +} diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts new file mode 100644 index 00000000..63287a87 --- /dev/null +++ b/src/editor/lsp/lsp.ts @@ -0,0 +1,62 @@ +/** Shared plumbing: per-editor document facet, IPC wrapper and error policy. */ +import { Facet } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { IpcError, call, isCancelled, isTauri } from "@/platform/ipc"; +import type { IdeError } from "@/bindings"; +import { showHint } from "./hint"; + +/** The backend document an editor shows (provided by this module's contribution). */ +export const lspDoc = Facet.define({ + combine: (values) => values[0] ?? null, +}); + +export function docOf(view: EditorView): EditorDocContext | null { + return view.state.facet(lspDoc); +} + +type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError }; + +/** + * Runs an LSP command. Synchronous throws (no Tauri runtime in a plain browser) + * become `unavailable` rejections so callers have a single error path. + */ +export async function lspRequest(run: () => Promise>): Promise { + if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" }); + try { + return await call(run()); + } catch (e) { + if (e instanceof IpcError) throw e; + throw new IpcError({ kind: "internal", message: e instanceof Error ? e.message : String(e) }); + } +} + +export function isUnavailable(e: unknown): boolean { + return e instanceof IpcError && e.kind === "unavailable"; +} + +export const NO_SERVER = "No language server available"; + +/** + * Error policy: background requests fail silently; explicit actions show a + * small hint at the caret ("No language server available" for missing servers). + */ +export function reportLspError(view: EditorView | null, e: unknown, explicit: boolean) { + if (isCancelled(e)) return; + if (!explicit || !view) { + if (!isUnavailable(e)) console.debug("[lsp]", e); + return; + } + showHint(view, isUnavailable(e) ? NO_SERVER : e instanceof Error ? e.message : String(e)); +} + +/** Runs `fn` once `ms` after the last call; `cancel()` drops a pending run. */ +export function debounce(fn: (...args: A) => void, ms: number) { + let t: ReturnType | undefined; + const run = (...args: A) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; + run.cancel = () => clearTimeout(t); + return run; +} diff --git a/src/editor/lsp/markdown.test.ts b/src/editor/lsp/markdown.test.ts new file mode 100644 index 00000000..d73575a6 --- /dev/null +++ b/src/editor/lsp/markdown.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +// A fresh module per test: the parser is loaded lazily and cached module-wide. +async function freshMarkdown() { + vi.resetModules(); + return import("./markdown"); +} + +describe("markdown rendering", () => { + it("shows plain text until the parser loads, then the rendered markup", async () => { + const md = await freshMarkdown(); + const el = md.renderMarkup({ kind: "markdown", value: "**bold** " }); + expect(el.querySelector(".cm-lsp-plain")?.textContent).toBe("**bold** "); + await md.loadMarkdown(); + await vi.waitFor(() => expect(el.querySelector("strong")?.textContent).toBe("bold")); + expect(el.querySelector("img")?.getAttribute("onerror")).toBeNull(); + }); + + it("renders synchronously once loaded", async () => { + const md = await freshMarkdown(); + await md.loadMarkdown(); + expect(md.markupToHtml({ kind: "markdown", value: "`x`" })).toBe("

x

\n"); + expect(md.markupToHtml({ kind: "plaintext", value: "" })).toBe('

<b>

'); + }); +}); diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts new file mode 100644 index 00000000..7b988880 --- /dev/null +++ b/src/editor/lsp/markdown.ts @@ -0,0 +1,171 @@ +/** + * Markdown (hover, documentation, signature docs) → sanitized DOM. Fenced + * code blocks are highlighted with the matching Lezer grammar (falling back + * to the editor's language), using `tok-*` classes styled in `theme.ts`. + * + * The Markdown parser (marked + DOMPurify, ~40 KB gzip) loads on first use and + * is prefetched when the app goes idle; until then markup shows as plain text. + */ +import { useMemo, useSyncExternalStore } from "react"; +import { LanguageDescription } from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; +import { classHighlighter, highlightCode } from "@lezer/highlight"; +import type { Markup } from "@/bindings"; +import { maybeEditorService } from "@/workbench/contributions"; +import { isTauri } from "@/platform/ipc"; +import { isVirtualUri } from "@/platform/virtualDocs"; + +type Parser = typeof import("./markdownParser"); + +let parser: Parser | null = null; +let loading: Promise | null = null; +const readyListeners = new Set<() => void>(); + +export function loadMarkdown(): Promise { + return (loading ??= import("./markdownParser").then((m) => { + parser = m; + readyListeners.forEach((l) => l()); + return m; + })); +} + +/** Loads the parser once the app is idle, so the first hover does not wait for it. */ +export function prefetchMarkdown() { + const load = () => void loadMarkdown().catch(() => {}); + if (typeof requestIdleCallback === "function") requestIdleCallback(load, { timeout: 5000 }); + else setTimeout(load, 2000); +} + +function subscribeReady(cb: () => void) { + readyListeners.add(cb); + if (!parser) void loadMarkdown().catch(() => {}); + return () => void readyListeners.delete(cb); +} + +/** `markupToHtml` for React views: re-renders with the Markdown once the parser has loaded. */ +export function useMarkupHtml(m: Markup): string { + const ready = useSyncExternalStore(subscribeReady, () => parser !== null); + const { kind, value } = m; + return useMemo(() => (ready ? markupToHtml({ kind, value }) : plainHtml(value)), [ready, kind, value]); +} + +function plainHtml(value: string): string { + return `

${escapeHtml(value)}

`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +} + +/** Sanitized HTML; plain text while the Markdown parser is still loading. */ +export function markupToHtml(m: Markup): string { + if (m.kind === "markdown" && parser) return parser.markdownToSafeHtml(m.value); + if (m.kind === "markdown") void loadMarkdown().catch(() => {}); + return plainHtml(m.value); +} + +export function isEmptyMarkup(m: Markup | null | undefined): boolean { + return !m || !m.value.trim(); +} + +const langCache = new Map(); + +function findLanguage(name: string): LanguageDescription | null { + const key = name.toLowerCase(); + if (!langCache.has(key)) langCache.set(key, LanguageDescription.matchLanguageName(languages, key, true)); + return langCache.get(key) ?? null; +} + +export function languageForPath(path: string): string | null { + const name = path.slice(path.lastIndexOf("/") + 1); + return LanguageDescription.matchFilename(languages, name)?.name ?? null; +} + +/** Replaces `el`'s text with highlighted spans (no-op when the language is unknown). */ +export async function highlightInto(el: HTMLElement, text: string, language: string | null): Promise { + const desc = language ? findLanguage(language) : null; + if (!desc) return; + try { + const support = desc.support ?? (await desc.load()); + const tree = support.language.parser.parse(text); + const frag = document.createDocumentFragment(); + highlightCode( + text, + tree, + classHighlighter, + (t, cls) => { + if (!cls) frag.appendChild(document.createTextNode(t)); + else { + const span = document.createElement("span"); + span.className = cls; + span.textContent = t; + frag.appendChild(span); + } + }, + () => frag.appendChild(document.createTextNode("\n")), + ); + el.replaceChildren(frag); + } catch { + // Grammar failed to load: keep the plain text. + } +} + +function highlightBlocks(root: HTMLElement, fallback: string | null) { + root.querySelectorAll("pre > code").forEach((code) => { + const cls = [...code.classList].find((c) => c.startsWith("language-")); + const lang = cls ? cls.slice("language-".length) : fallback; + const text = (code.textContent ?? "").replace(/\n+$/, ""); + code.textContent = text; + void highlightInto(code, text, lang); + }); +} + +function onLinkClick(e: MouseEvent) { + const a = (e.target as Element | null)?.closest?.("a"); + if (!a) return; + e.preventDefault(); + const href = a.getAttribute("href") ?? ""; + if (href.startsWith("file://")) { + const url = new URL(href); + const m = /^#?L?(\d+)(?:[,:](\d+))?/.exec(url.hash.slice(1)); + const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: m[2] ? Math.max(0, Number(m[2]) - 1) : 0 } : undefined; + void maybeEditorService()?.openFile(decodeURIComponent(url.pathname), { selection, focus: true }); + } else if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) { + // Library class links (jdtls hover: `jdt://contents/…#L42`). + const hash = href.indexOf("#"); + const m = hash >= 0 ? /^L?(\d+)/.exec(href.slice(hash + 1)) : null; + const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: 0 } : undefined; + void maybeEditorService()?.openFile(hash >= 0 ? href.slice(0, hash) : href, { selection, focus: true }); + } else if (/^https?:\/\//.test(href) && isTauri()) { + void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {}); + } +} + +/** Renders markup into a `.cm-lsp-markdown` element. */ +export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): HTMLElement { + const dom = document.createElement("div"); + dom.className = "cm-lsp-markdown"; + dom.innerHTML = markupToHtml(m); + highlightBlocks(dom, fallbackLanguage); + if (m.kind === "markdown" && !parser) { + void loadMarkdown() + .then(() => { + dom.innerHTML = markupToHtml(m); + highlightBlocks(dom, fallbackLanguage); + }) + .catch(() => {}); + } + dom.addEventListener("click", onLinkClick); + return dom; +} + +/** Renders a code snippet (e.g. a signature or definition) as highlighted mono text. */ +export function renderCode(text: string, language: string | null, className = "cm-lsp-code"): HTMLElement { + const pre = document.createElement("pre"); + pre.className = className; + const code = document.createElement("code"); + code.textContent = text; + pre.appendChild(code); + void highlightInto(code, text, language); + return pre; +} diff --git a/src/editor/lsp/markdownParser.ts b/src/editor/lsp/markdownParser.ts new file mode 100644 index 00000000..2f4698aa --- /dev/null +++ b/src/editor/lsp/markdownParser.ts @@ -0,0 +1,13 @@ +/** Markdown → sanitized HTML (marked + DOMPurify); loaded on demand by `markdown.ts`. */ +import { Marked } from "marked"; +import DOMPurify from "dompurify"; + +/** DOMPurify's default link schemes plus `file:` and jdtls' `jdt:` (library classes). */ +const ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|file|jdt):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i; + +const marked = new Marked({ gfm: true, breaks: false }); + +export function markdownToSafeHtml(markdown: string): string { + const html = marked.parse(markdown, { async: false }) as string; + return DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, ALLOWED_URI_REGEXP: ALLOWED_URI }); +} diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts new file mode 100644 index 00000000..c92105ff --- /dev/null +++ b/src/editor/lsp/navigation.ts @@ -0,0 +1,367 @@ +/** + * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B and + * ⌘-click with a link underline while ⌘ is held), the multiple-targets + * chooser, and document highlights (occurrences of the symbol at the caret, + * plus the sticky find.highlightUsages variant). + */ +import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, keymap, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { commands, isCancelled, type GotoKind, type Highlight, type Location, type Pos } from "@/platform/ipc"; +import { + CONTRIBUTED_DEFINITION, + activeEditor, + contributedAt, + whenSynced, + type ContributedDefinition, + type EditorDocContext, +} from "@/editor/api"; +import { structure } from "@/editor/structure"; +import { executeAction } from "@/platform/actions"; +import { basename, relativePath, useProject } from "@/platform/project"; +import { editorService } from "@/workbench/contributions"; +import { fileIconName } from "@/ui"; +import { NO_SERVER, debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; +import { offsetToPos, rangeToOffsets } from "./positions"; +import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type DeclarationTarget } from "./syntaxFallback"; +import { showHint } from "./hint"; +import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { readManyFileLines } from "./files"; +import { serverBackoff } from "./availability"; +import { editorPrefs } from "@/platform/editorSettings"; +import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs"; +import { hasPrimaryModifier, isMacHost } from "@/platform/keymap"; + +export async function navigateTo(loc: Location) { + await editorService().openFile(loc.path, { + selection: { line: loc.range.start.line, col: loc.range.start.col }, + focus: true, + }); +} + +const NOT_FOUND: Record = { + definition: "Cannot find declaration to go to", + declaration: "Cannot find declaration to go to", + implementation: "No implementations found", + typeDefinition: "Cannot find type declaration", +}; + +const TITLES: Record = { + definition: "Choose Declaration", + declaration: "Choose Declaration", + implementation: "Choose Implementation", + typeDefinition: "Choose Type Declaration", +}; + +export function dedupeLocations(locs: readonly T[]): T[] { + const seen = new Set(); + return locs.filter((l) => { + const k = `${l.path}:${l.range.start.line}:${l.range.start.col}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +/** Popup items for locations, with a one-line source preview each. */ +export async function locationItems(locs: readonly DeclarationTarget[]): Promise { + const files = await readManyFileLines(locs.map((l) => l.path)); + const root = useProject.getState().project?.root ?? ""; + return locs.map((l, i) => { + const where = `${isVirtualUri(l.path) ? virtualContainer(l.path) : relativePath(root, l.path)}:${l.range.start.line + 1}`; + const preview = files.get(l.path)?.[l.range.start.line]?.trim(); + // Index symbols (syntax fallback): "name container · file:line", like IDEA's chooser. + if (l.kind && l.name) + return { + id: `${i}`, + label: l.name, + detail: l.container ? `${l.container} · ${where}` : where, + preview, + symbol: l.kind, + run: () => navigateTo(l), + }; + return { + id: `${i}`, + label: basename(l.path), + detail: where, + preview, + icon: fileIconName(basename(l.path)), + run: () => navigateTo(l), + }; + }); +} + +export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) { + const offset = at ?? view.state.selection.main.head; + if (kind === "definition" || kind === "declaration") { + const own = contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); + if (own?.(view, offset)) return; + } + const doc = docOf(view); + if (!doc) return; + const pos = offsetToPos(view.state.doc, offset); + let locs: DeclarationTarget[]; + let fromSyntax = false; + try { + locs = dedupeLocations(await lspRequest(() => commands.lspGoto(doc.doc, pos, kind))); + } catch (e) { + if (isCancelled(e) || (kind !== "definition" && kind !== "declaration")) { + reportLspError(view, e, true); + return; + } + // Dumb mode: the server is not ready / failing — answer from the syntax tree. + const fb = await gotoFallback(view, doc, pos); + if (!fb) { + reportLspError(view, e, true); + return; + } + locs = fb; + fromSyntax = true; + } + if (!locs.length) { + showHint(view, NOT_FOUND[kind]); + return; + } + // IDEA: ⌘B on a declaration shows its usages. + if (kind === "definition" && isSelfTarget(locs, doc.path, pos)) { + if (!(await executeAction("find.showUsages"))) showHint(view, "No usages found"); + return; + } + if (locs.length === 1) { + await navigateTo(locs[0]!); + // After the editor applied the navigation's selection (which dismisses hints). + if (fromSyntax) + setTimeout(() => { + const target = activeEditor()?.view; + if (target) showHint(target, SYNTAX_NOTE, { timeout: 2500 }); + }, 150); + return; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, offset), + title: `${TITLES[kind]} (${locs.length} found${fromSyntax ? ", based on syntax" : ""})`, + items: await locationItems(locs), + }); +} + +/** Declaration targets from the structure service; null when it cannot answer for this file. */ +async function gotoFallback(view: EditorView, doc: EditorDocContext, pos: Pos): Promise { + try { + await whenSynced(view); + const text = view.state.doc; + const fb = await declarationFallback(doc, text, pos); + return fb.kind === "unsupported" ? null : fb.targets; + } catch (e) { + console.debug("[syntax] declaration fallback", e); + return null; + } +} + +// --------------------------------------------------------------------------- +// ⌘-hover link + ⌘-click + +const setLink = StateEffect.define<{ from: number; to: number } | null>(); +const linkMark = Decoration.mark({ class: "cm-lsp-link" }); + +const linkField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + for (const e of tr.effects) + if (e.is(setLink)) return e.value ? Decoration.set([linkMark.range(e.value.from, e.value.to)]) : Decoration.none; + return tr.docChanged ? Decoration.none : deco; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +function linkAt(view: EditorView, x: number, y: number) { + const pos = view.posAtCoords({ x, y }, false); + if (pos == null) return null; + const word = view.state.wordAt(pos); + return word && word.from < word.to ? word : null; +} + +const linkPlugin = ViewPlugin.fromClass( + class { + last: { x: number; y: number } | null = null; + constructor(readonly view: EditorView) {} + show(meta: boolean) { + const current = this.view.state.field(linkField); + const target = meta && this.last ? linkAt(this.view, this.last.x, this.last.y) : null; + let same = false; + current.between(0, this.view.state.doc.length, (from, to) => { + same = !!target && target.from === from && target.to === to; + }); + if (same || (!target && current.size === 0)) return; + this.view.dispatch({ effects: setLink.of(target) }); + } + }, + { + eventHandlers: { + mousemove(e) { + this.last = { x: e.clientX, y: e.clientY }; + this.show(hasPrimaryModifier(e) && !e.altKey && !e.shiftKey); + }, + mouseleave() { + this.last = null; + this.show(false); + }, + keydown(e) { + if (e.key === (isMacHost() ? "Meta" : "Control")) this.show(true); + }, + keyup(e) { + if (e.key === (isMacHost() ? "Meta" : "Control")) this.show(false); + }, + blur() { + this.show(false); + }, + mousedown(e, view) { + if (e.button !== 0 || !hasPrimaryModifier(e) || e.altKey || e.shiftKey) return false; + if (isMacHost() ? e.ctrlKey : e.metaKey) return false; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + if (pos == null) return false; + e.preventDefault(); + view.dispatch({ selection: EditorSelection.cursor(pos), effects: setLink.of(null) }); + view.focus(); + void gotoTargets(view, "definition", pos); + return true; + }, + }, + }, +); + +// --------------------------------------------------------------------------- +// Document highlights + +interface HighlightState { + deco: DecorationSet; + sticky: boolean; +} + +const setHighlights = StateEffect.define<{ items: Array<{ from: number; to: number; kind: Highlight["kind"] }>; sticky: boolean }>(); + +const marks = { + text: Decoration.mark({ class: "cm-lsp-occurrence" }), + read: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-read" }), + write: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-write" }), +}; + +const highlightField = StateField.define({ + create: () => ({ deco: Decoration.none, sticky: false }), + update(value, tr) { + for (const e of tr.effects) + if (e.is(setHighlights)) + return { + deco: Decoration.set( + e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)), + true, + ), + sticky: e.value.sticky, + }; + if (tr.docChanged) return { deco: Decoration.none, sticky: false }; + return value; + }, + provide: (f) => EditorView.decorations.from(f, (v) => v.deco), +}); + +async function fetchHighlights(view: EditorView, sticky: boolean) { + const doc = docOf(view); + if (!doc) return; + const state = view.state; + const head = state.selection.main.head; + const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head; + const apply = (items: Array<{ from: number; to: number; kind: Highlight["kind"] }>) => + view.dispatch({ effects: setHighlights.of({ items, sticky }) }); + let lspError: unknown = null; + if (!serverBackoff.active(doc.doc)) { + try { + const items = await lspRequest(() => commands.lspDocumentHighlights(doc.doc, offsetToPos(state.doc, head))); + if (stale()) return; + if (sticky && !items.length) showHint(view, "No usages found in file"); + apply(items.map((h) => ({ ...rangeToOffsets(state.doc, h.range), kind: h.kind }))); + return; + } catch (e) { + if (isCancelled(e)) return; + if (isUnavailable(e)) serverBackoff.mark(doc.doc); + lspError = e; + } + } + // No usable server: only a certain local's same-scope occurrences (anything wider is too noisy to guess). + const local = await syntaxHighlights(view, doc, head); + if (stale()) return; + if (local) { + if (sticky) showHint(view, SYNTAX_NOTE, { timeout: 2500 }); + apply(local.map((h) => ({ ...rangeToOffsets(state.doc, h.range), kind: h.kind }))); + return; + } + if (lspError) reportLspError(view, lspError, sticky); + else if (sticky) showHint(view, NO_SERVER); + // Drop stale marks from an earlier caret position. + if (view.state.field(highlightField).deco.size) apply([]); +} + +async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) { + try { + if (!(await structure.support(doc.language)).locals) return null; + await whenSynced(view); + if (view.state.selection.main.head !== head) return null; + return localHighlights(await structure.locals(doc.doc, offsetToPos(view.state.doc, head))); + } catch (e) { + if (!isCancelled(e)) console.debug("[syntax] highlight fallback", e); + return null; + } +} + +/** ⌘⇧F7: highlight usages of the symbol at the caret until Escape. */ +export function highlightUsages(view: EditorView) { + void fetchHighlights(view, true); +} + +const highlightPlugin = ViewPlugin.fromClass( + class { + private request = debounce(() => void fetchHighlights(this.view, false), 300); + constructor(private view: EditorView) {} + update(u: ViewUpdate) { + if (u.docChanged) this.request.cancel(); + if (!u.selectionSet || u.docChanged) return; + const st = u.state.field(highlightField); + if (st.sticky) return; + // Code Editing › "Usages of element at caret". + if (!editorPrefs().codeEditing.highlightUsages) { + if (st.deco.size) queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) })); + return; + } + const sel = u.state.selection.main; + if (!sel.empty) { + this.request.cancel(); + if (st.deco.size) queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) })); + return; + } + this.request(); + } + destroy() { + this.request.cancel(); + } + }, +); + +export function navigationExtension(): Extension { + return [ + linkField, + linkPlugin, + highlightField, + highlightPlugin, + Prec.high( + keymap.of([ + { + key: "Escape", + run: (view) => { + const st = view.state.field(highlightField, false); + if (!st?.sticky) return false; + view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }); + return true; + }, + }, + ]), + ), + ]; +} diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts new file mode 100644 index 00000000..ba582d32 --- /dev/null +++ b/src/editor/lsp/popupStore.ts @@ -0,0 +1,113 @@ +/** State of the React popups the LSP editor features open (rendered by `Popups.tsx`). */ +import { create } from "zustand"; +import type { EditorView } from "@codemirror/view"; +import type { Location, Markup } from "@/bindings"; +import type { WorkspaceEditSummary } from "./workspaceEdit"; + +export interface PopupPoint { + x: number; + y: number; +} + +export interface ListPopupItem { + id: string; + label: string; + /** Muted trailing text (file:line, kind…). */ + detail?: string; + /** Second line / muted text after the label (code preview). */ + preview?: string; + icon?: string; + /** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */ + symbol?: string; + emphasis?: boolean; + /** Disabled reason; disabled rows are shown but not activatable. */ + disabled?: string | null; + shortcut?: string; + run: () => void | Promise; +} + +export type LspPopup = + | { kind: "doc"; anchor: PopupPoint; markup: Markup; language: string | null; themeClasses: string } + | { + kind: "definition"; + anchor: PopupPoint; + location: Location; + firstLine: number; + text: string; + language: string | null; + themeClasses: string; + } + | { kind: "list"; anchor: PopupPoint; title: string; items: ListPopupItem[] } + | { + kind: "rename"; + anchor: PopupPoint; + title: string; + initial: string; + /** Muted line under the input, e.g. that the rename is based on syntax only. */ + note?: string; + /** Name suggestions listed under the input (↑↓ to pick), e.g. for Introduce Variable. */ + suggestions?: string[]; + /** Replaces the default "↵ Rename · Esc Cancel" footer. */ + hint?: string; + onSubmit: (name: string) => void; + } + | { + kind: "preview"; + title: string; + summary: WorkspaceEditSummary; + confirmLabel: string; + onConfirm: () => void; + /** Why confirmation is needed (shown as warnings above the changes). */ + warnings?: string[]; + /** Individual occurrences with line previews (single-file syntax-based renames). */ + occurrences?: PreviewOccurrence[]; + } + | { + /** Modal question with several answers, e.g. Safe Delete's "Usages Detected". */ + kind: "choice"; + title: string; + message: string; + detail?: string; + choices: PopupChoice[]; + /** Escape / close button / replaced by another popup. */ + onDismiss?: () => void; + }; + +export interface PopupChoice { + label: string; + /** The default button (Enter). */ + primary?: boolean; + run: () => void; +} + +export interface PreviewOccurrence { + line: number; + /** The line's text and the occurrence within it. */ + text: string; + from: number; + to: number; +} + +interface PopupState { + popup: LspPopup | null; + /** Bumped per open so React remounts a popup of the same kind. */ + seq: number; +} + +export const useLspPopup = create(() => ({ popup: null, seq: 0 })); + +export function openPopup(popup: LspPopup) { + useLspPopup.setState((s) => ({ popup, seq: s.seq + 1 })); +} + +export function closePopup() { + useLspPopup.setState({ popup: null }); +} + +/** Screen point just below `pos` (falls back to the editor's top-left). */ +export function caretAnchor(view: EditorView, pos: number, side: "below" | "above" = "below"): PopupPoint { + const c = view.coordsAtPos(pos) ?? view.coordsAtPos(pos, -1); + if (c) return { x: c.left, y: side === "below" ? c.bottom : c.top }; + const r = view.dom.getBoundingClientRect(); + return { x: r.left + 40, y: r.top + 40 }; +} diff --git a/src/editor/lsp/positions.test.ts b/src/editor/lsp/positions.test.ts new file mode 100644 index 00000000..bc367b64 --- /dev/null +++ b/src/editor/lsp/positions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { offsetToPos, offsetsToRange, posToOffset, rangeContains, rangeToOffsets, textEditsToChanges } from "./positions"; + +const doc = Text.of(["let a = 1;", "// 😀 émoji", "", "end"]); + +describe("positions", () => { + it("maps 0-based line + UTF-16 column to offsets and back", () => { + expect(posToOffset(doc, { line: 0, col: 4 })).toBe(4); + // Line 1 starts at 11; the emoji is two UTF-16 units. + expect(posToOffset(doc, { line: 1, col: 3 })).toBe(14); + expect(posToOffset(doc, { line: 1, col: 6 })).toBe(17); + expect(offsetToPos(doc, 17)).toEqual({ line: 1, col: 6 }); + expect(offsetToPos(doc, doc.length)).toEqual({ line: 3, col: 3 }); + }); + + it("clamps out-of-range positions", () => { + expect(posToOffset(doc, { line: 0, col: 99 })).toBe(10); + expect(posToOffset(doc, { line: 99, col: 0 })).toBe(doc.length); + expect(posToOffset(doc, { line: -1, col: 3 })).toBe(0); + expect(offsetToPos(doc, -5)).toEqual({ line: 0, col: 0 }); + }); + + it("converts ranges in both directions and normalises order", () => { + const r = offsetsToRange(doc, 4, 14); + expect(r).toEqual({ start: { line: 0, col: 4 }, end: { line: 1, col: 3 } }); + expect(rangeToOffsets(doc, { start: r.end, end: r.start })).toEqual({ from: 4, to: 14 }); + }); + + it("checks containment with inclusive ends", () => { + const r = { start: { line: 1, col: 2 }, end: { line: 1, col: 5 } }; + expect(rangeContains(r, { line: 1, col: 5 })).toBe(true); + expect(rangeContains(r, { line: 1, col: 6 })).toBe(false); + expect(rangeContains(r, { line: 0, col: 9 })).toBe(false); + }); + + it("turns text edits into sorted, non-overlapping changes", () => { + const changes = textEditsToChanges(doc, [ + { range: { start: { line: 3, col: 0 }, end: { line: 3, col: 3 } }, new_text: "END" }, + { range: { start: { line: 0, col: 0 }, end: { line: 0, col: 3 } }, new_text: "const" }, + { range: { start: { line: 0, col: 1 }, end: { line: 0, col: 2 } }, new_text: "overlap" }, + { range: { start: { line: 0, col: 4 }, end: { line: 0, col: 5 } }, new_text: "a" }, + ]); + expect(changes).toEqual([ + { from: 0, to: 3, insert: "const" }, + { from: doc.length - 3, to: doc.length, insert: "END" }, + ]); + }); +}); diff --git a/src/editor/lsp/positions.ts b/src/editor/lsp/positions.ts new file mode 100644 index 00000000..6c560d72 --- /dev/null +++ b/src/editor/lsp/positions.ts @@ -0,0 +1,60 @@ +/** + * IPC ⇄ CodeMirror coordinate conversion. The backend speaks 0-based lines and + * UTF-16 columns; CodeMirror offsets are UTF-16 code units too, so a column is + * simply an index into the line's text. + */ +import type { Text } from "@codemirror/state"; +import type { Pos, Range, TextEdit } from "@/bindings"; + +export function posToOffset(doc: Text, pos: Pos): number { + if (pos.line < 0) return 0; + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return line.from + Math.max(0, Math.min(pos.col, line.length)); +} + +export function offsetToPos(doc: Text, offset: number): Pos { + const o = Math.max(0, Math.min(offset, doc.length)); + const line = doc.lineAt(o); + return { line: line.number - 1, col: o - line.from }; +} + +export function rangeToOffsets(doc: Text, range: Range): { from: number; to: number } { + const a = posToOffset(doc, range.start); + const b = posToOffset(doc, range.end); + return a <= b ? { from: a, to: b } : { from: b, to: a }; +} + +export function offsetsToRange(doc: Text, from: number, to: number): Range { + return { start: offsetToPos(doc, from), end: offsetToPos(doc, to) }; +} + +export function comparePos(a: Pos, b: Pos): number { + return a.line - b.line || a.col - b.col; +} + +/** Whether `pos` lies inside `range` (end inclusive, like a caret at a word end). */ +export function rangeContains(range: Range, pos: Pos): boolean { + return comparePos(range.start, pos) <= 0 && comparePos(pos, range.end) <= 0; +} + +/** + * Converts LSP text edits (all relative to the same document version) into + * sorted, non-overlapping CodeMirror changes. Overlapping edits are dropped + * rather than producing a corrupt change set. + */ +export function textEditsToChanges(doc: Text, edits: readonly TextEdit[]): Array<{ from: number; to: number; insert: string }> { + const changes = edits + .map((e, i) => ({ ...rangeToOffsets(doc, e.range), insert: e.new_text, i })) + .sort((a, b) => a.from - b.from || a.to - b.to || a.i - b.i); + const out: Array<{ from: number; to: number; insert: string }> = []; + let end = -1; + for (const c of changes) { + if (c.from < end) continue; + const current = doc.sliceString(c.from, c.to); + if (current === c.insert) continue; + out.push({ from: c.from, to: c.to, insert: c.insert }); + end = c.to; + } + return out; +} diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts new file mode 100644 index 00000000..96645a92 --- /dev/null +++ b/src/editor/lsp/rename.ts @@ -0,0 +1,212 @@ +/** + * Rename (⇧F6): `lsp_prepare_rename` → inline name popup at the symbol → + * `lsp_rename` → preview dialog when the edit spans several files (or moves + * files), otherwise applied directly via `apply_workspace_edit`. + * + * Dumb mode: when the server is not ready / failing, or answers nothing over + * code with syntax errors, a *local* variable is renamed in place from the + * syntax tree (definition + same-scope references, `{ x }` shorthands keep + * their key). Anything else — members, types, top-level or cross-file + * symbols — is left to the server. Ambiguous cases ask first. + */ +import type { EditorView } from "@codemirror/view"; +import { commands, isCancelled, type Pos, type WorkspaceEdit } from "@/platform/ipc"; +import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { structure } from "@/editor/structure"; +import { docOf, lspRequest, reportLspError } from "./lsp"; +import { comparePos, offsetToPos, rangeToOffsets } from "./positions"; +import { showHint } from "./hint"; +import { caretAnchor, openPopup, type PreviewOccurrence } from "./popupStore"; +import { needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; +import { + SYNTAX_NOTE, + VALID_NAME, + planLocalRename, + renameChanges, + renameRisks, + type LocalRenamePlan, +} from "./syntaxFallback"; + +async function applyEdit(view: EditorView, edit: WorkspaceEdit) { + try { + await lspRequest(() => commands.applyWorkspaceEdit(edit)); + } catch (e) { + reportLspError(view, e, true); + } +} + +async function hasSyntaxErrors(doc: number): Promise { + try { + return (await structure.syntaxErrors(doc)).errors.length > 0; + } catch { + return false; + } +} + +export async function startRename(view: EditorView) { + const doc = docOf(view); + if (!doc) return; + if (doc.readOnly) { + showHint(view, "The file is read-only"); + return; + } + await whenSynced(view); + const head = view.state.selection.main.head; + const pos = offsetToPos(view.state.doc, head); + let range; + try { + range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); + } catch (e) { + if (isCancelled(e)) return; + if (!(await startSyntaxRename(view, doc, pos))) reportLspError(view, e, true); + return; + } + if (!range) { + // A normal "can't rename" is respected; over broken code the server may just have given up. + if ((await hasSyntaxErrors(doc.doc)) && (await startSyntaxRename(view, doc, pos))) return; + showHint(view, "This element can't be renamed"); + return; + } + const { from, to } = rangeToOffsets(view.state.doc, range); + const oldName = view.state.sliceDoc(from, to); + openPopup({ + kind: "rename", + anchor: caretAnchor(view, from), + title: `Rename '${oldName}'`, + initial: oldName, + onSubmit: (newName) => void finishRename(view, doc, pos, oldName, newName), + }); +} + +async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, oldName: string, newName: string) { + if (!newName || newName === oldName) return; + let edit: WorkspaceEdit; + try { + edit = await lspRequest(() => commands.lspRename(doc.doc, pos, newName)); + } catch (e) { + reportLspError(view, e, true); + return; + } + const summary = summarizeWorkspaceEdit(edit); + if (!summary.files) { + if (await hasSyntaxErrors(doc.doc)) { + const plan = await syntaxPlan(view, doc, pos); + if (plan && plan.symbol.name === oldName) { + await commitSyntaxRename(view, doc, plan, newName); + return; + } + } + showHint(view, "Nothing to rename"); + return; + } + if (!needsPreview(summary)) { + await applyEdit(view, edit); + return; + } + openPopup({ + kind: "preview", + title: `Rename '${oldName}' to '${newName}'`, + summary, + confirmLabel: "Do Refactor", + onConfirm: () => void applyEdit(view, edit), + }); +} + +// --------------------------------------------------------------------------- +// Syntax-based local rename + +/** The local rename plan at `pos`, or null when the element is not a local the fallback may rename. */ +async function syntaxPlan(view: EditorView, doc: EditorDocContext, pos: Pos): Promise { + try { + await whenSynced(view); + const result = await planLocalRename(doc, pos); + return result.kind === "plan" ? result.plan : null; + } catch (e) { + if (!isCancelled(e)) console.debug("[syntax] rename fallback", e); + return null; + } +} + +/** Opens the rename popup for a syntax-based local rename. False when the fallback can't help. */ +async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: Pos): Promise { + let result; + try { + await whenSynced(view); + result = await planLocalRename(doc, pos); + } catch (e) { + if (!isCancelled(e)) console.debug("[syntax] rename fallback", e); + return false; + } + if (result.kind === "unsupported") return false; + if (result.kind === "refused") { + showHint(view, result.message); + return true; + } + const { plan } = result; + const at = plan.occurrences.find((o) => comparePos(o.range.start, pos) <= 0 && comparePos(pos, o.range.end) <= 0); + openPopup({ + kind: "rename", + anchor: caretAnchor(view, rangeToOffsets(view.state.doc, (at ?? plan.occurrences[0]!).range).from), + title: `Rename '${plan.symbol.name}'`, + initial: plan.symbol.name, + note: `${SYNTAX_NOTE}: local occurrences only`, + onSubmit: (newName) => void commitSyntaxRename(view, doc, plan, newName), + }); + return true; +} + +/** Applies a local rename in place, asking first when the syntax answer is not certain. */ +export async function commitSyntaxRename(view: EditorView, doc: EditorDocContext, plan: LocalRenamePlan, newName: string) { + const oldName = plan.symbol.name; + if (!newName || newName === oldName) return; + if (VALID_NAME.test(oldName) && !VALID_NAME.test(newName)) { + showHint(view, `'${newName}' is not a valid identifier`, { tone: "error" }); + return; + } + const state = view.state; + let risks: string[]; + try { + risks = await renameRisks(doc.doc, plan, newName); + } catch (e) { + if (isCancelled(e)) return; + risks = ["The name could not be checked for clashes."]; + } + const apply = () => { + if (view.state.doc !== state.doc) { + showHint(view, "The file changed; rename cancelled", { tone: "warning" }); + return; + } + const changes = renameChanges(view.state.doc, plan, newName); + if (!changes) { + showHint(view, "The file changed; rename cancelled", { tone: "warning" }); + return; + } + view.dispatch({ changes, userEvent: "rename", scrollIntoView: true }); + showHint(view, `${SYNTAX_NOTE}: renamed ${changes.length} ${changes.length === 1 ? "occurrence" : "occurrences"}`, { + timeout: 2500, + }); + }; + if (!risks.length) { + apply(); + return; + } + const occurrences: PreviewOccurrence[] = plan.occurrences.map((o) => { + const { from, to } = rangeToOffsets(state.doc, o.range); + const line = state.doc.lineAt(from); + return { line: line.number - 1, text: line.text, from: from - line.from, to: Math.min(to, line.to) - line.from }; + }); + openPopup({ + kind: "preview", + title: `Rename '${oldName}' to '${newName}'`, + summary: { + files: 1, + occurrences: occurrences.length, + hasFileOperations: false, + entries: [{ path: doc.path, edits: occurrences.length }], + }, + warnings: [...risks, `${SYNTAX_NOTE}. Only the occurrences below will be renamed.`], + occurrences, + confirmLabel: "Rename Anyway", + onConfirm: apply, + }); +} diff --git a/src/editor/lsp/semantic.ts b/src/editor/lsp/semantic.ts new file mode 100644 index 00000000..2a11eeee --- /dev/null +++ b/src/editor/lsp/semantic.ts @@ -0,0 +1,145 @@ +/** + * Semantic highlighting: `lsp_semantic_tokens` → mark decorations with + * `cm-sem-*` classes (styled with the --syn-* tokens) layered over the Lezer + * highlighting. Refreshed after edits (debounced); in between, existing marks + * are mapped through the changes. + */ +import { Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { commands, type SemanticTokens } from "@/platform/ipc"; +import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; +import { onLspRefresh, serverBackoff } from "./availability"; + +const TYPE_CLASS: Record = { + type: "type", + class: "type", + enum: "type", + interface: "type", + struct: "type", + typeAlias: "type", + builtinType: "type", + selfTypeKeyword: "keyword", + typeParameter: "typeParameter", + function: "function", + method: "function", + macro: "macro", + decorator: "annotation", + // jdtls extensions + annotation: "annotation", + annotationMember: "function", + record: "type", + recordComponent: "property", + attribute: "annotation", + enumMember: "constant", + property: "property", + parameter: "parameter", + variable: "variable", + namespace: "namespace", + lifetime: "annotation", + selfParameter: "self", + clsParameter: "self", + selfKeyword: "self", +}; + +const MODIFIER_CLASS: Record = { + deprecated: "deprecated", + static: "static", + mutable: "mutable", + declaration: "declaration", + definition: "declaration", + defaultLibrary: "builtin", +}; + +export interface SemanticRange { + from: number; + to: number; + className: string; +} + +/** Decodes absolute `[line, col, len, type, modifiers]` quintuples into styled ranges. */ +export function semanticRanges(doc: Text, tokens: SemanticTokens): SemanticRange[] { + const out: SemanticRange[] = []; + const { data, tokenTypes, tokenModifiers } = tokens; + for (let i = 0; i + 4 < data.length; i += 5) { + const line = data[i]!, + col = data[i + 1]!, + len = data[i + 2]!, + typeIdx = data[i + 3]!, + mods = data[i + 4]!; + if (line >= doc.lines || len <= 0) continue; + const type = tokenTypes[typeIdx] ?? ""; + const names: string[] = []; + for (let b = 0; b < tokenModifiers.length; b++) if (mods & (1 << b)) names.push(tokenModifiers[b]!); + const classes: string[] = []; + let base = TYPE_CLASS[type]; + const constant = + names.includes("constant") || (type === "variable" && names.includes("readonly") && names.includes("static")); + if (constant && (type === "variable" || type === "property")) base = "constant"; + if (base && base !== "variable") classes.push(`cm-sem-${base}`); + for (const n of names) if (MODIFIER_CLASS[n]) classes.push(`cm-sem-${MODIFIER_CLASS[n]}`); + if (!classes.length) continue; + const l = doc.line(line + 1); + const from = l.from + Math.min(col, l.length); + const to = Math.min(from + len, l.to); + if (from < to) out.push({ from, to, className: classes.join(" ") }); + } + return out.sort((a, b) => a.from - b.from || a.to - b.to); +} + +const markCache = new Map(); +const markFor = (cls: string) => { + let m = markCache.get(cls); + if (!m) markCache.set(cls, (m = Decoration.mark({ class: cls }))); + return m; +}; + +const setSemantic = StateEffect.define(); + +const semanticField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + for (const e of tr.effects) if (e.is(setSemantic)) return e.value; + return tr.docChanged ? deco.map(tr.changes) : deco; + }, +}); + +const semanticPlugin = ViewPlugin.fromClass( + class { + private refresh = debounce(() => void this.run(), 600); + private offReady: () => void; + constructor(private view: EditorView) { + this.refresh(); + this.offReady = onLspRefresh("semanticTokens", () => this.refresh()); + } + update(u: ViewUpdate) { + if (u.docChanged) this.refresh(); + } + async run() { + const view = this.view; + const doc = docOf(view); + if (!doc || serverBackoff.active(doc.doc) || !view.dom.isConnected) return; + const state = view.state; + try { + const tokens = await lspRequest(() => commands.lspSemanticTokens(doc.doc)); + if (view.state.doc !== state.doc) return this.refresh(); + const ranges = tokens ? semanticRanges(state.doc, tokens) : []; + view.dispatch({ + effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)), + }); + } catch (e) { + if (isUnavailable(e)) serverBackoff.mark(doc.doc); + } + } + destroy() { + this.refresh.cancel(); + this.offReady(); + } + }, +); + +export function semanticExtension(): Extension { + // Higher-precedence decoration sources create the *inner* DOM nodes, so this + // must outrank Lezer's highlighter (default precedence) for semantic colours + // to win; they only set colour / style, never backgrounds. + return [semanticField, semanticPlugin, Prec.high(EditorView.decorations.from(semanticField))]; +} diff --git a/src/editor/lsp/signature.test.ts b/src/editor/lsp/signature.test.ts new file mode 100644 index 00000000..5b3cd65f --- /dev/null +++ b/src/editor/lsp/signature.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { closeBrackets } from "@codemirror/autocomplete"; + +const help = { signatures: [{ label: "f(a: number)", documentation: null, parameters: [[2, 11]] }], activeSignature: 0, activeParameter: 0 }; +const lspSignatureHelp = vi.fn(async () => ({ status: "ok" as const, data: help })); + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspSignatureHelp } }; +}); + +const { signatureExtension, acceptsParameter } = await import("./signature"); +const { lspDoc } = await import("./lsp"); + +// jsdom has no layout; CodeMirror measures text when the tooltip is positioned. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + lspSignatureHelp.mockClear(); +}); + +function typeChar(view: EditorView, ch: string) { + const { from, to } = view.state.selection.main; + const insert = () => view.state.update({ changes: { from, to, insert: ch }, selection: { anchor: from + 1 }, userEvent: "input.type" }); + if (!view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, ch, insert))) view.dispatch(insert()); +} + +describe("parameter info auto-popup", () => { + it("opens after typing `(` even when the closing paren is auto-inserted", async () => { + vi.useFakeTimers(); + try { + const view = new EditorView({ + state: EditorState.create({ + doc: "f", + selection: { anchor: 1 }, + extensions: [closeBrackets(), lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }), signatureExtension()], + }), + parent: document.body, + }); + views.push(view); + typeChar(view, "("); + expect(view.state.doc.toString()).toBe("f()"); + await vi.advanceTimersByTimeAsync(1000); + expect(lspSignatureHelp).toHaveBeenCalledTimes(1); + expect(view.dom.querySelector(".cm-lsp-signature b")?.textContent).toBe("a: number"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("acceptsParameter", () => { + it("rejects overloads with too few parameters unless the last is variadic", () => { + expect(acceptsParameter({ label: "println() : void", parameters: [] }, 0)).toBe(false); + expect(acceptsParameter({ label: "println(Object x) : void", parameters: [[8, 16]] }, 0)).toBe(true); + expect(acceptsParameter({ label: "println(Object x) : void", parameters: [[8, 16]] }, 1)).toBe(false); + expect(acceptsParameter({ label: "format(String f, Object... a)", parameters: [[7, 15], [17, 28]] }, 4)).toBe(true); + }); +}); diff --git a/src/editor/lsp/signature.ts b/src/editor/lsp/signature.ts new file mode 100644 index 00000000..bd865003 --- /dev/null +++ b/src/editor/lsp/signature.ts @@ -0,0 +1,164 @@ +/** + * Parameter info (⌘P, and automatically after `(` / `,`): a tooltip above the + * caret listing the signatures with the active parameter in bold. While open + * it follows the caret and closes when the server reports no signature. + */ +import { Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view"; +import { commands, type SignatureHelp } from "@/platform/ipc"; +import { docOf, lspRequest, reportLspError, debounce } from "./lsp"; +import { offsetToPos } from "./positions"; +import { showHint } from "./hint"; +import { renderMarkup, isEmptyMarkup } from "./markdown"; +import { editorPrefs } from "@/platform/editorSettings"; + +interface SigState { + pos: number; + help: SignatureHelp; +} + +const setSignature = StateEffect.define(); + +const signatureField = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setSignature)) return e.value; + if (value && tr.docChanged) return { ...value, pos: tr.changes.mapPos(value.pos) }; + return value; + }, + provide: (f) => showTooltip.from(f, (s) => (s ? signatureTooltip(s) : null)), +}); + +/** Splits a signature label around the active parameter's UTF-16 range. */ +export function splitSignature(label: string, params: ReadonlyArray<[number, number]>, active: number) { + const range = params[active]; + if (!range) return { before: label, param: "", after: "" }; + const [s, e] = range; + return { before: label.slice(0, s), param: label.slice(s, e), after: label.slice(e) }; +} + +const MAX_ROWS = 12; + +/** Whether an overload can take an argument at `index` (varargs take any number). */ +export function acceptsParameter(sig: { label: string; parameters: ReadonlyArray<[number, number]> }, index: number) { + if (index < sig.parameters.length) return true; + const last = sig.parameters[sig.parameters.length - 1]; + return !!last && /\.\.\.|^\s*\*/.test(sig.label.slice(last[0], last[1])); +} + +function signatureTooltip(s: SigState): Tooltip { + return { + pos: s.pos, + above: true, + strictSide: false, + create: () => { + const dom = document.createElement("div"); + dom.className = "cm-lsp-signature"; + const { signatures, activeSignature, activeParameter } = s.help; + const start = Math.max(0, Math.min(activeSignature - 6, signatures.length - MAX_ROWS)); + signatures.slice(start, start + MAX_ROWS).forEach((sig, j) => { + const i = start + j; + const row = document.createElement("div"); + row.className = "cm-lsp-signature-row"; + if (i === activeSignature) row.classList.add("cm-lsp-signature-active"); + else if (!acceptsParameter(sig, activeParameter)) row.classList.add("cm-lsp-signature-disabled"); + const parts = splitSignature(sig.label, sig.parameters, activeParameter); + row.append(parts.before); + if (parts.param) { + const b = document.createElement("b"); + b.textContent = parts.param; + row.appendChild(b); + } + row.append(parts.after); + dom.appendChild(row); + }); + const doc = signatures[activeSignature]?.documentation; + if (doc && !isEmptyMarkup(doc) && signatures.length === 1) { + const d = renderMarkup(doc); + d.classList.add("cm-lsp-signature-doc"); + dom.appendChild(d); + } + return { dom }; + }, + }; +} + +async function fetchSignature(view: EditorView, explicit: boolean) { + const doc = docOf(view); + if (!doc) return; + const state = view.state; + const head = state.selection.main.head; + try { + const help = await lspRequest(() => commands.lspSignatureHelp(doc.doc, offsetToPos(state.doc, head))); + if (view.state.doc !== state.doc && !explicit) return; + const current = view.state.field(signatureField, false); + if (!help || !help.signatures.length) { + if (current) view.dispatch({ effects: setSignature.of(null) }); + if (explicit) showHint(view, "No parameters"); + return; + } + view.dispatch({ effects: setSignature.of({ pos: Math.min(head, view.state.doc.length), help }) }); + } catch (e) { + if (view.state.field(signatureField, false)) view.dispatch({ effects: setSignature.of(null) }); + reportLspError(view, e, explicit); + } +} + +/** ⌘P or the completion command `editor.action.triggerParameterHints`. */ +export function triggerSignatureHelp(view: EditorView, explicit = false) { + void fetchSignature(view, explicit); +} + +const signaturePlugin = ViewPlugin.fromClass( + class { + private refresh = debounce(() => void fetchSignature(this.view, false), 120); + private popupTimer: ReturnType | undefined; + constructor(private view: EditorView) {} + /** Code Completion › "Show the parameter info popup in N ms". */ + private autoPopup() { + const p = editorPrefs().completion; + if (!p.parameterInfo) return; + clearTimeout(this.popupTimer); + this.popupTimer = setTimeout(() => void fetchSignature(this.view, false), Math.max(0, p.parameterInfoDelay)); + } + update(u: ViewUpdate) { + const active = u.state.field(signatureField, false); + let typedTrigger = false; + if (u.docChanged && u.transactions.some((tr) => tr.isUserEvent("input.type") || tr.isUserEvent("input.complete"))) { + // The character before the caret: `(` is followed by an auto-inserted `)`. + const head = u.state.selection.main.head; + const before = u.state.sliceDoc(Math.max(0, head - 1), head); + typedTrigger = before === "(" || before === ","; + } + if (typedTrigger && !active) this.autoPopup(); + else if (typedTrigger || (active && (u.docChanged || u.selectionSet))) this.refresh(); + } + destroy() { + clearTimeout(this.popupTimer); + this.refresh.cancel(); + } + }, +); + +export function signatureExtension(): Extension { + return [ + signatureField, + signaturePlugin, + // Highest: Escape closes parameter info before it leaves a snippet. + Prec.highest( + keymap.of([ + { + key: "Escape", + run: (view) => { + if (!view.state.field(signatureField, false)) return false; + view.dispatch({ effects: setSignature.of(null) }); + return true; + }, + }, + ]), + ), + EditorView.focusChangeEffect.of((state, focusing) => + !focusing && state.field(signatureField, false) ? setSignature.of(null) : null, + ), + ]; +} diff --git a/src/editor/lsp/snippet.test.ts b/src/editor/lsp/snippet.test.ts new file mode 100644 index 00000000..336d9353 --- /dev/null +++ b/src/editor/lsp/snippet.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { snippet } from "@codemirror/autocomplete"; +import { lspSnippetToCm, lspSnippetToPlain, snippetVariables } from "./snippet"; + +/** Instantiates a CM template to check the produced text and field positions. */ +function expand(template: string) { + let state = EditorState.create({ doc: "" }); + snippet(template)({ state, dispatch: (tr) => (state = tr.state) }, null, 0, 0); + return { text: state.doc.toString(), sel: state.selection.main }; +} + +describe("lspSnippetToCm", () => { + it("converts tab stops, placeholders and the final position", () => { + expect(lspSnippetToCm("foo($1, ${2:bar})$0")).toBe("foo(${1}, ${2:bar})${0}"); + expect(lspSnippetToCm("${1}")).toBe("${1}"); + }); + + it("escapes literal braces for CodeMirror", () => { + const t = lspSnippetToCm("fn main() {\n\t$0\n}"); + expect(t).toBe("fn main() \\{\n\t${0}\n\\}"); + expect(expand(t).text).toBe("fn main() {\n \n}"); + }); + + it("handles LSP escapes", () => { + expect(lspSnippetToCm("cost \\$5 \\} \\\\")).toBe("cost $5 \\} \\"); + expect(expand(lspSnippetToCm("a \\${1}")).text).toBe("a ${1}"); + }); + + it("flattens nested placeholders into the outer field", () => { + expect(lspSnippetToCm("${1:foo(${2:bar})}")).toBe("${1:foo(bar)}"); + }); + + it("uses the first choice", () => { + expect(lspSnippetToCm("${1|public,private|} x")).toBe("${1:public} x"); + }); + + it("resolves variables, defaults and unknown names", () => { + const vars = snippetVariables("/p/src/Main.kt"); + expect(lspSnippetToCm("class $TM_FILENAME_BASE", vars)).toBe("class Main"); + expect(lspSnippetToCm("${NOPE:dflt}", vars)).toBe("dflt"); + expect(lspSnippetToCm("$NOPE", vars)).toBe("${NOPE}"); + }); + + it("ignores transforms", () => { + expect(lspSnippetToCm("${1/(.*)/${1:/upcase}/}x")).toBe("${1}x"); + }); + + it("keeps placeholder text whose braces CodeMirror can't name", () => { + expect(lspSnippetToCm("${1:{}}")).toBe("${1}\\{\\}"); + }); + + it("treats a lone dollar as text", () => { + expect(lspSnippetToCm("$ x $")).toBe("$ x $"); + }); + + it("selects the first field when instantiated", () => { + const { text, sel } = expand(lspSnippetToCm("call(${1:arg}, $2)$0")); + expect(text).toBe("call(arg, )"); + expect([sel.from, sel.to]).toEqual([5, 8]); + }); + + it("produces plain text previews", () => { + expect(lspSnippetToPlain("foo(${1:a}, ${2|x,y|})$0")).toBe("foo(a, x)"); + }); +}); diff --git a/src/editor/lsp/snippet.ts b/src/editor/lsp/snippet.ts new file mode 100644 index 00000000..f7535a1e --- /dev/null +++ b/src/editor/lsp/snippet.ts @@ -0,0 +1,236 @@ +/** + * LSP snippet syntax → `@codemirror/autocomplete` snippet templates. + * + * LSP: `$1`, `${1}`, `${1:default}` (nestable), `${1|a,b|}`, `$0`, `$VAR`, + * `${VAR:default}`, transforms `${1/re/fmt/}`, backslash escapes for `$ } \`. + * CodeMirror: `${1}`, `${1:name}`, `${name}`, `${0}` (final caret); literal + * braces must be escaped as `\{` / `\}`; field names cannot contain braces or + * span lines and nested fields are not supported, so nested placeholders are + * flattened into their parent's text. + */ + +type Node = + | { t: "text"; v: string } + | { t: "tab"; n: number; children: Node[] | null } + | { t: "choice"; n: number; options: string[] } + | { t: "var"; name: string; children: Node[] | null }; + +function parse(src: string): Node[] { + let i = 0; + + const parseSeq = (inside: boolean): Node[] => { + const out: Node[] = []; + let text = ""; + const flush = () => { + if (text) out.push({ t: "text", v: text }); + text = ""; + }; + while (i < src.length) { + const c = src[i]!; + if (c === "\\" && i + 1 < src.length && "$}\\".includes(src[i + 1]!)) { + text += src[i + 1]; + i += 2; + continue; + } + if (inside && c === "}") break; + if (c === "$") { + const node = parseDollar(); + if (node) { + flush(); + out.push(node); + continue; + } + } + text += c; + i++; + } + flush(); + return out; + }; + + const readInt = (): number | null => { + const m = /^\d+/.exec(src.slice(i)); + if (!m) return null; + i += m[0].length; + return Number(m[0]); + }; + const readName = (): string | null => { + const m = /^[_a-zA-Z][_a-zA-Z0-9]*/.exec(src.slice(i)); + if (!m) return null; + i += m[0].length; + return m[0]; + }; + /** Skips a `/regex/format/options}` transform, leaving `i` after the closing brace. */ + const skipTransform = (): boolean => { + i++; // leading slash + let segment = 0; // 0 regex, 1 format, 2 options + let depth = 0; + while (i < src.length) { + const c = src[i]!; + if (c === "\\") { + i += 2; + continue; + } + if (segment < 2 && c === "/" && depth === 0) segment++; + else if (segment === 1 && c === "{") depth++; + else if (segment === 1 && c === "}" && depth > 0) depth--; + else if (segment === 2 && c === "}") { + i++; + return true; + } + i++; + } + return false; + }; + + const parseDollar = (): Node | null => { + const start = i; + i++; // $ + if (src[i] !== "{") { + const n = readInt(); + if (n !== null) return { t: "tab", n, children: null }; + const name = readName(); + if (name !== null) return { t: "var", name, children: null }; + i = start; + return null; + } + i++; // { + const fail = () => { + i = start; + return null; + }; + const n = readInt(); + if (n !== null) { + const c = src[i]; + if (c === "}") { + i++; + return { t: "tab", n, children: null }; + } + if (c === ":") { + i++; + const children = parseSeq(true); + if (src[i] !== "}") return fail(); + i++; + return { t: "tab", n, children }; + } + if (c === "|") { + i++; + const options: string[] = []; + let cur = ""; + while (i < src.length) { + const ch = src[i]!; + if (ch === "\\" && i + 1 < src.length && "$}\\,|".includes(src[i + 1]!)) { + cur += src[i + 1]; + i += 2; + } else if (ch === ",") { + options.push(cur); + cur = ""; + i++; + } else if (ch === "|" && src[i + 1] === "}") { + options.push(cur); + i += 2; + return { t: "choice", n, options }; + } else { + cur += ch; + i++; + } + } + return fail(); + } + if (c === "/") return skipTransform() ? { t: "tab", n, children: null } : fail(); + return fail(); + } + const name = readName(); + if (name === null) return fail(); + const c = src[i]; + if (c === "}") { + i++; + return { t: "var", name, children: null }; + } + if (c === ":") { + i++; + const children = parseSeq(true); + if (src[i] !== "}") return fail(); + i++; + return { t: "var", name, children }; + } + if (c === "/") return skipTransform() ? { t: "var", name, children: null } : fail(); + return fail(); + }; + + return parseSeq(false); +} + +export type SnippetVariables = Record; + +const escapeText = (s: string) => s.replace(/[{}]/g, "\\$&"); + +/** Plain text of nodes (nested fields contribute their default text). */ +function flatten(nodes: Node[], vars: SnippetVariables): string { + let out = ""; + for (const n of nodes) { + if (n.t === "text") out += n.v; + else if (n.t === "tab") out += n.children ? flatten(n.children, vars) : ""; + else if (n.t === "choice") out += n.options[0] ?? ""; + else { + const v = vars[n.name]; + out += v !== undefined ? v : n.children ? flatten(n.children, vars) : n.name; + } + } + return out; +} + +function field(n: number | string, text: string): string { + if (!text) return `\${${n}}`; + // CodeMirror field names cannot contain braces or newlines: keep the text, lose the selection. + if (/[{}\n\r]/.test(text)) return `\${${n}}${escapeText(text)}`; + return `\${${n}:${text}}`; +} + +/** Converts an LSP snippet into a CodeMirror snippet template. */ +export function lspSnippetToCm(snippet: string, vars: SnippetVariables = {}): string { + let out = ""; + for (const n of parse(snippet)) { + if (n.t === "text") out += escapeText(n.v); + else if (n.t === "tab") out += field(n.n, n.children ? flatten(n.children, vars) : ""); + else if (n.t === "choice") out += field(n.n, n.options[0] ?? ""); + else { + const v = vars[n.name]; + if (v !== undefined) out += escapeText(v); + else if (n.children) out += escapeText(flatten(n.children, vars)); + // Unknown variable: LSP inserts its name as a placeholder. + else out += field(n.name, ""); + } + } + return out; +} + +/** The snippet's text with every field replaced by its default (for previews / plain insertion). */ +export function lspSnippetToPlain(snippet: string, vars: SnippetVariables = {}): string { + return flatten(parse(snippet), vars); +} + +/** Standard LSP snippet variables for a file. */ +export function snippetVariables(path: string, extra: SnippetVariables = {}): SnippetVariables { + const slash = path.lastIndexOf("/"); + const name = path.slice(slash + 1); + const dot = name.lastIndexOf("."); + const now = new Date(); + const pad = (v: number) => String(v).padStart(2, "0"); + return { + TM_FILENAME: name, + TM_FILENAME_BASE: dot > 0 ? name.slice(0, dot) : name, + TM_DIRECTORY: slash > 0 ? path.slice(0, slash) : "", + TM_FILEPATH: path, + TM_SELECTED_TEXT: "", + CLIPBOARD: "", + CURRENT_YEAR: String(now.getFullYear()), + CURRENT_YEAR_SHORT: String(now.getFullYear()).slice(-2), + CURRENT_MONTH: pad(now.getMonth() + 1), + CURRENT_DATE: pad(now.getDate()), + CURRENT_HOUR: pad(now.getHours()), + CURRENT_MINUTE: pad(now.getMinutes()), + CURRENT_SECOND: pad(now.getSeconds()), + ...extra, + }; +} diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts new file mode 100644 index 00000000..052deb2f --- /dev/null +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@/bindings"; + +type R = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>; +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const unavailable = () => Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError }); + +const cmd = { + lspGoto: vi.fn((): R => unavailable()), + lspDocumentHighlights: vi.fn((): R => unavailable()), + lspPrepareRename: vi.fn((): R => unavailable()), + lspRename: vi.fn((): R => unavailable()), + syntaxSupport: vi.fn(() => ok({ tree: true, locals: true })), + syntaxLocals: vi.fn((): R => ok({ status: "noIdentifier" })), + syntaxNodeAt: vi.fn((): R => ok(null)), + syntaxIdentifiers: vi.fn((): R => ok([])), + syntaxErrors: vi.fn((): R => ok({ errors: [], truncated: false })), + findSymbols: vi.fn((): R => ok([])), +}; + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; +}); + +const openFile = vi.fn(async () => {}); +const { provideEditorService } = await import("@/workbench/contributions"); +provideEditorService({ openFile } as never); + +const fb = await import("./syntaxFallback"); +const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation"); +const { startRename } = await import("./rename"); +const { lspDoc } = await import("./lsp"); +const { useLspPopup, closePopup } = await import("./popupStore"); + +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } }); +const node = (kind: string, range: Range, field: string | null = null): SyntaxNode => ({ + kind, + range, + field, + named: true, + error: false, + missing: false, + hasError: false, +}); +const sym = (name: string, kind: SymbolMatch["kind"], path: string, container: string | null = null): SymbolMatch => ({ + name, + kind, + container, + path, + relPath: path.slice(1), + range: r(3, 4, 4 + name.length), + score: 1, + indices: [], +}); +const local = (over: Partial["symbol"]> = {}): Locals => ({ + status: "local", + symbol: { + name: "x", + kind: "var", + definition: r(0, 6, 7), + scope: { start: { line: 0, col: 0 }, end: { line: 9, col: 0 } }, + references: [r(1, 2, 3)], + certain: true, + conflicts: [], + ...over, + }, +}); + +const views: EditorView[] = []; +function editor(doc: string, language = "typescript", anchor = 0) { + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor }, + extensions: [lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }), navigationExtension()], + }), + parent: document.body, + }); + views.push(view); + return view; +} + +beforeEach(() => { + for (const f of Object.values(cmd)) f.mockClear(); + openFile.mockClear(); + closePopup(); +}); +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +describe("usageRole / filterSymbols", () => { + it("reads the role from the tree", () => { + const id = r(0, 0, 3); + expect(fb.usageRole({ node: node("type_identifier", id), ancestors: [] })).toBe("type"); + expect(fb.usageRole({ node: node("identifier", id, "function"), ancestors: [node("call_expression", id)] })).toBe("call"); + expect(fb.usageRole({ node: node("identifier", id, "name"), ancestors: [node("method_invocation", id)] })).toBe("call"); + expect(fb.usageRole({ node: node("identifier", id, "constructor"), ancestors: [node("new_expression", id)] })).toBe("new"); + expect(fb.usageRole({ node: node("identifier", id), ancestors: [node("binary_expression", id)] })).toBe("other"); + expect(fb.usageRole(null)).toBe("other"); + }); + + it("keeps exact names whose kind fits the role", () => { + const all = [sym("Foo", "class", "/a"), sym("Foo", "function", "/b"), sym("Foo", "field", "/c"), sym("Foobar", "class", "/d"), sym("Foo", "file", "/e")]; + expect(fb.filterSymbols(all, "Foo", "type").map((s) => s.path)).toEqual(["/a"]); + expect(fb.filterSymbols(all, "Foo", "call").map((s) => s.path)).toEqual(["/a", "/b"]); + expect(fb.filterSymbols(all, "Foo", "other").map((s) => s.path)).toEqual(["/a", "/b", "/c"]); + }); + + it("only keeps declarations from the same language family", () => { + expect(fb.compatibleFiles("/a/Main.kt", "/b/Util.java")).toBe(true); + expect(fb.compatibleFiles("/a/app.tsx", "/b/util.js")).toBe(true); + expect(fb.compatibleFiles("/a/pets.rb", "/b/util.lua")).toBe(false); + expect(fb.compatibleFiles("/a/x.lua", "/b/y.lua")).toBe(true); + expect(fb.compatibleFiles("/a/Makefile", "/b/y.lua")).toBe(true); + }); + + it("ranks the current file and same-extension files first", () => { + const t = (path: string) => ({ path, range: r(0, 0, 1) }); + expect(fb.rankTargets([t("/z.kt"), t("/b.ts"), t("/a.py"), t("/me.ts")], "/me.ts").map((x) => x.path)).toEqual([ + "/me.ts", + "/b.ts", + "/a.py", + "/z.kt", + ]); + }); +}); + +describe("declarationFallback", () => { + const doc = { doc: 7, path: "/p/a.ts", language: "typescript" }; + const text = Text.of(["const x = 1;", " x + foo();"]); + + it("jumps to a certain local definition without asking the index", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local())); + expect(await fb.declarationFallback(doc, text, { line: 1, col: 2 })).toEqual({ + kind: "targets", + targets: [{ path: "/p/a.ts", range: r(0, 6, 7), name: "x" }], + ambiguous: false, + }); + expect(cmd.findSymbols).not.toHaveBeenCalled(); + }); + + it("offers every same-scope declaration when the local is ambiguous", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ certain: false, conflicts: [r(4, 4, 5)] }))); + const res = await fb.declarationFallback(doc, text, { line: 1, col: 2 }); + expect(res).toMatchObject({ kind: "targets", ambiguous: true }); + expect(res.kind === "targets" && res.targets.map((t) => t.range.start.line)).toEqual([0, 4]); + }); + + it("looks non-locals up in the index by exact name and role", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(1, 6, 9), reason: "unresolved" })); + cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("identifier", r(1, 6, 9), "function"), ancestors: [node("call_expression", r(1, 6, 11))] })); + cmd.findSymbols.mockReturnValueOnce(ok([sym("foo", "function", "/p/b.ts", "util"), sym("foo", "field", "/p/c.ts"), sym("foobar", "function", "/p/d.ts")])); + const res = await fb.declarationFallback(doc, text, { line: 1, col: 7 }); + expect(cmd.findSymbols).toHaveBeenCalledWith("foo", "all", 500); + expect(res).toEqual({ + kind: "targets", + targets: [{ path: "/p/b.ts", range: r(3, 4, 7), name: "foo", kind: "function", container: "util" }], + ambiguous: false, + }); + }); + + it("does not filter members by role", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(1, 6, 9), reason: "member" })); + cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("property_identifier", r(1, 6, 9)), ancestors: [] })); + cmd.findSymbols.mockReturnValueOnce(ok([sym("foo", "method", "/p/b.ts", "A"), sym("foo", "field", "/p/c.ts", "B")])); + const res = await fb.declarationFallback(doc, text, { line: 1, col: 7 }); + expect(res).toMatchObject({ kind: "targets", ambiguous: true }); + }); + + it("uses the node text for languages without locals, and gives up without a grammar", async () => { + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: true, locals: false })); + cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("identifier", r(1, 6, 9)), ancestors: [] })); + await fb.declarationFallback({ ...doc, language: "nolocals" }, text, { line: 1, col: 7 }); + expect(cmd.findSymbols).toHaveBeenCalledWith("foo", "all", 500); + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false })); + expect(await fb.declarationFallback({ ...doc, language: "nogrammar" }, text, { line: 0, col: 0 })).toEqual({ kind: "unsupported" }); + }); +}); + +describe("rename helpers", () => { + it("expands shorthands and refuses stale text", () => { + const text = Text.of(["const x = 1;", "f({ x }, x);"]); + const plan = { + symbol: (local() as Extract).symbol, + occurrences: [ + { range: r(0, 6, 7), shorthand: false }, + { range: r(1, 4, 5), shorthand: true }, + { range: r(1, 9, 10), shorthand: false }, + ], + }; + const changes = fb.renameChanges(text, plan, "y"); + expect(EditorState.create({ doc: text }).update({ changes: changes! }).state.doc.toString()).toBe( + "const y = 1;\nf({ x: y }, y);", + ); + expect(fb.renameChanges(Text.of(["const z = 1;", "f({ x }, x);"]), plan, "y")).toBeNull(); + }); + + it("detects JS and Rust shorthand nodes", () => { + const id = r(0, 0, 1); + expect(fb.isShorthand({ node: node("shorthand_property_identifier", id), ancestors: [] })).toBe(true); + expect(fb.isShorthand({ node: node("shorthand_property_identifier_pattern", id), ancestors: [] })).toBe(true); + expect(fb.isShorthand({ node: node("identifier", id), ancestors: [node("shorthand_field_initializer", id)] })).toBe(true); + expect(fb.isShorthand({ node: node("identifier", id), ancestors: [node("arguments", id)] })).toBe(false); + }); + + it("refuses non-locals and plans locals with shorthand detection", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "foo", range: r(0, 0, 3), reason: "member" })); + expect(await fb.planLocalRename({ doc: 7, language: "typescript" }, { line: 0, col: 1 })).toMatchObject({ kind: "refused" }); + cmd.syntaxLocals.mockReturnValueOnce(ok(local())); + cmd.syntaxNodeAt + .mockReturnValueOnce(ok({ node: node("identifier", r(0, 6, 7)), ancestors: [] })) + .mockReturnValueOnce(ok({ node: node("shorthand_property_identifier", r(1, 2, 3)), ancestors: [] })); + const res = await fb.planLocalRename({ doc: 7, language: "typescript" }, { line: 0, col: 6 }); + expect(res.kind === "plan" && res.plan.occurrences.map((o) => o.shorthand)).toEqual([false, true]); + // No shorthand syntax in Python: no node queries. + cmd.syntaxNodeAt.mockClear(); + cmd.syntaxLocals.mockReturnValueOnce(ok(local())); + await fb.planLocalRename({ doc: 7, language: "python" }, { line: 0, col: 6 }); + expect(cmd.syntaxNodeAt).not.toHaveBeenCalled(); + }); + + it("flags ambiguity and name clashes inside the scope", async () => { + const plan = { symbol: (local({ certain: false, conflicts: [r(3, 0, 1)] }) as Extract).symbol, occurrences: [] }; + cmd.syntaxIdentifiers.mockReturnValueOnce( + ok([ + { range: r(2, 0, 1), kind: "identifier", qualified: false }, + { range: r(2, 4, 5), kind: "property_identifier", qualified: true }, + { range: r(20, 0, 1), kind: "identifier", qualified: false }, + ]), + ); + const risks = await fb.renameRisks(7, plan, "y"); + expect(risks).toHaveLength(2); + expect(risks[0]).toContain("declared 2 times"); + expect(risks[1]).toContain("1 occurrence"); + }); + + it("only highlights certain locals", () => { + expect(fb.localHighlights(local())).toEqual([ + { range: r(0, 6, 7), kind: "write" }, + { range: r(1, 2, 3), kind: "text" }, + ]); + expect(fb.localHighlights(local({ certain: false }))).toBeNull(); + expect(fb.localHighlights({ status: "notLocal", name: "x", range: r(0, 0, 1), reason: "topLevel" })).toBeNull(); + }); +}); + +describe("navigation wiring", () => { + it("respects an empty server answer", async () => { + cmd.lspGoto.mockReturnValueOnce(ok([])); + const view = editor("const x = 1;\nx;", "typescript", 13); + await gotoTargets(view, "definition"); + expect(cmd.syntaxLocals).not.toHaveBeenCalled(); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Cannot find declaration to go to"); + }); + + it("falls back to the local definition when the server is not ready", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 0, 1)] }))); + const view = editor("const x = 1;\nx;", "typescript", 13); + await gotoTargets(view, "definition"); + expect(openFile).toHaveBeenCalledWith("/p/a.ts", { selection: { line: 0, col: 6 }, focus: true }); + }); + + it("opens a chooser for several index candidates", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "run", range: r(0, 4, 7), reason: "member" })); + cmd.syntaxNodeAt.mockReturnValueOnce(ok({ node: node("property_identifier", r(0, 4, 7)), ancestors: [] })); + cmd.findSymbols.mockReturnValueOnce(ok([sym("run", "method", "/p/a.ts", "A"), sym("run", "method", "/p/b.ts", "B")])); + const view = editor("obj.run();", "typescript", 5); + await gotoTargets(view, "definition"); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("list"); + if (popup?.kind !== "list") return; + expect(popup.title).toBe("Choose Declaration (2 found, based on syntax)"); + expect(popup.items.map((i) => [i.label, i.symbol, i.detail?.split(" · ")[0]])).toEqual([ + ["run", "method", "A"], + ["run", "method", "B"], + ]); + expect(openFile).not.toHaveBeenCalled(); + }); + + it("keeps the server error when the language has no grammar", async () => { + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false })); + const view = editor("x", "plain-nogrammar", 0); + await gotoTargets(view, "definition"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No language server available"); + }); + + it("highlights a certain local when the server is not ready", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 0, 1)] }))); + const view = editor("const x = 1;\nx;", "typescript", 13); + highlightUsages(view); + await vi.waitFor(() => expect(view.dom.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2)); + expect(view.dom.querySelectorAll(".cm-lsp-occurrence-write")).toHaveLength(1); + }); + + it("does not highlight non-locals from syntax", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "x", range: r(0, 0, 1), reason: "topLevel" })); + const view = editor("x;", "typescript", 0); + highlightUsages(view); + await vi.waitFor(() => expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No language server available")); + expect(view.dom.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0); + }); +}); + +describe("rename wiring", () => { + it("renames a certain local in place when the server is not ready", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 4, 5)] }))); + cmd.syntaxNodeAt + .mockReturnValueOnce(ok({ node: node("identifier", r(0, 6, 7)), ancestors: [] })) + .mockReturnValueOnce(ok({ node: node("shorthand_property_identifier", r(1, 4, 5)), ancestors: [] })); + const view = editor("const x = 1;\nf({ x });", "typescript", 6); + await startRename(view); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("rename"); + if (popup?.kind !== "rename") return; + expect(popup.note).toContain("Based on syntax"); + popup.onSubmit("y"); + await vi.waitFor(() => expect(view.state.doc.toString()).toBe("const y = 1;\nf({ x: y });")); + expect(cmd.lspRename).not.toHaveBeenCalled(); + }); + + it("asks before renaming an ambiguous local", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 0, 1)], certain: false, conflicts: [r(2, 4, 5)] }))); + const view = editor("const x = 1;\nx;\nvar x;", "typescript", 6); + await startRename(view); + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "rename") throw new Error("no rename popup"); + popup.onSubmit("y"); + await vi.waitFor(() => expect(useLspPopup.getState().popup?.kind).toBe("preview")); + const preview = useLspPopup.getState().popup; + if (preview?.kind !== "preview") return; + expect(preview.occurrences?.map((o) => o.line)).toEqual([0, 1]); + expect(view.state.doc.toString()).toBe("const x = 1;\nx;\nvar x;"); + preview.onConfirm(); + expect(view.state.doc.toString()).toBe("const y = 1;\ny;\nvar x;"); + }); + + it("never renames non-locals from syntax", async () => { + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "run", range: r(0, 4, 7), reason: "member" })); + const view = editor("obj.run();", "typescript", 5); + await startRename(view); + expect(useLspPopup.getState().popup).toBeNull(); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("only local variables"); + }); + + it("respects a server refusal over valid code, but falls back over broken code", async () => { + cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); + const view = editor("const x = 1;", "typescript", 6); + await startRename(view); + expect(cmd.syntaxLocals).not.toHaveBeenCalled(); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("This element can't be renamed"); + + cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); + cmd.syntaxErrors.mockReturnValueOnce(ok({ errors: [{ kind: "missing", range: r(0, 12, 12), expected: ";", message: "missing `;`" }], truncated: false })); + cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [] }))); + await startRename(view); + expect(useLspPopup.getState().popup?.kind).toBe("rename"); + }); +}); diff --git a/src/editor/lsp/syntaxFallback.ts b/src/editor/lsp/syntaxFallback.ts new file mode 100644 index 00000000..0c54aa37 --- /dev/null +++ b/src/editor/lsp/syntaxFallback.ts @@ -0,0 +1,277 @@ +/** + * Syntax fallbacks for navigation, highlighting and rename (IDEA's dumb mode): + * used only when the language server is not ready / failing (or, for rename, + * answers nothing over code with syntax errors). Everything here claims only + * what the structure service is certain about; ambiguity becomes candidates. + */ +import type { ChangeSpec, Text } from "@codemirror/state"; +import type { Location, Pos, Range, SymbolKind, SymbolMatch } from "@/bindings"; +import { call, commands } from "@/platform/ipc"; +import { structure, type Locals, type LocalSymbol, type NodeAt } from "@/editor/structure"; +import { comparePos, rangeContains, rangeToOffsets } from "./positions"; + +/** Subtle marker for answers that did not come from the language server. */ +export const SYNTAX_NOTE = "Based on syntax (language server not ready)"; + +/** A declaration candidate; index symbols carry their name, kind and container. */ +export interface DeclarationTarget extends Location { + name?: string; + kind?: SymbolKind; + container?: string | null; +} + +export type DeclarationFallback = + /** No grammar for the language: the fallback has nothing to say. */ + | { kind: "unsupported" } + /** Certain local definition, or every index symbol of that name and role. */ + | { kind: "targets"; targets: DeclarationTarget[]; ambiguous: boolean }; + +const TYPE_KINDS: ReadonlySet = new Set([ + "class", + "interface", + "struct", + "enum", + "trait", + "typeAlias", + "typeParameter", +]); +const CALLABLE_KINDS: ReadonlySet = new Set(["function", "method", "constructor", "macro", ...TYPE_KINDS]); +const NON_DECLARATIONS: ReadonlySet = new Set(["file", "package", "module", "namespace", "unknown"]); + +export type UsageRole = "type" | "call" | "new" | "other"; + +const TYPE_NODE = /(^|_)type_identifier$|^user_type$|^scoped_type_identifier$/; +const TYPE_PARENT = /^(generic_type|user_type|nullable_type|type_annotation|type_arguments|scoped_type_identifier|qualified_type|array_type)$/; +const CALL_PARENT = /(^|_)(call|invocation)(_expression)?$|^call$/; +const NEW_PARENT = /^(new_expression|object_creation_expression|instance_creation_expression|constructor_invocation)$/; + +/** What the syntax tree tells about the identifier: a type reference, a call, a `new`… */ +export function usageRole(at: NodeAt | null): UsageRole { + if (!at) return "other"; + const { node } = at; + const parent = at.ancestors[0]; + if (parent && NEW_PARENT.test(parent.kind)) return "new"; + if (TYPE_NODE.test(node.kind) || node.field === "type" || (parent && TYPE_PARENT.test(parent.kind))) return "type"; + if (parent && CALL_PARENT.test(parent.kind) && (node.field === "function" || node.field === "name" || node.field == null)) + return "call"; + return "other"; +} + +/** Index symbols that may be the declaration of `name` used in `role`. */ +export function filterSymbols(matches: readonly SymbolMatch[], name: string, role: UsageRole): SymbolMatch[] { + return matches.filter((m) => { + if (m.name !== name || NON_DECLARATIONS.has(m.kind)) return false; + switch (role) { + case "type": + case "new": + return TYPE_KINDS.has(m.kind) || m.kind === "constructor"; + case "call": + return CALLABLE_KINDS.has(m.kind); + default: + return true; + } + }); +} + +function extensionOf(path: string): string { + const base = path.slice(path.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + return dot > 0 ? base.slice(dot + 1) : ""; +} + +/** Extensions whose code can reference each other's declarations. */ +const FAMILIES: readonly string[][] = [ + ["java", "kt", "kts", "scala", "groovy"], + ["js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts", "vue", "svelte"], + ["c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "m", "mm"], + ["py", "pyi"], + ["rb", "rake"], + ["ex", "exs"], + ["hs", "lhs"], +]; + +/** Whether a declaration in `target` can be referenced from code in `from` (same language family). */ +export function compatibleFiles(from: string, target: string): boolean { + const a = extensionOf(from); + const b = extensionOf(target); + if (!a || a === b) return true; + const family = FAMILIES.find((f) => f.includes(a)); + return family ? family.includes(b) : false; +} + +/** Current file first, then files of the same extension, then by path / position. */ +export function rankTargets(targets: readonly T[], currentPath: string): T[] { + const ext = extensionOf(currentPath); + const rank = (t: T) => (t.path === currentPath ? 0 : extensionOf(t.path) === ext ? 1 : 2); + return [...targets].sort( + (a, b) => rank(a) - rank(b) || a.path.localeCompare(b.path) || comparePos(a.range.start, b.range.start), + ); +} + +function sameRange(a: Range, b: Range) { + return comparePos(a.start, b.start) === 0 && comparePos(a.end, b.end) === 0; +} + +/** Local declaration targets: the definition when certain, else every same-scope declaration. */ +export function localTargets(path: string, symbol: LocalSymbol): { targets: DeclarationTarget[]; ambiguous: boolean } { + const ranges = symbol.certain ? [symbol.definition] : [symbol.definition, ...symbol.conflicts]; + const unique = ranges.filter((r, i) => ranges.findIndex((o) => sameRange(o, r)) === i); + unique.sort((a, b) => comparePos(a.start, b.start)); + return { + targets: unique.map((range) => ({ path, range, name: symbol.name })), + ambiguous: unique.length > 1, + }; +} + +const SYMBOL_LIMIT = 500; + +/** + * Go to Declaration without a language server: a certain local definition, + * otherwise project index symbols with exactly that name (filtered by the + * syntactic role when the tree tells). Call after `whenSynced`. + */ +export async function declarationFallback( + doc: { doc: number; path: string; language: string }, + text: Text, + pos: Pos, + signal?: AbortSignal, +): Promise { + const support = await structure.support(doc.language); + if (!support.tree) return { kind: "unsupported" }; + let locals: Locals | null = null; + if (support.locals) locals = await structure.locals(doc.doc, pos, { signal }); + if (locals?.status === "noIdentifier") return { kind: "targets", targets: [], ambiguous: false }; + if (locals?.status === "local") return { kind: "targets", ...localTargets(doc.path, locals.symbol) }; + + const at = await structure.nodeAt(doc.doc, { start: pos, end: pos }, { signal }); + const name = locals?.status === "notLocal" ? locals.name : identifierText(text, at); + if (!name) return { kind: "targets", targets: [], ambiguous: false }; + // A member (`obj.name`) can be any member of that name; only a plain name has a syntactic role. + const role = locals?.status === "notLocal" && locals.reason === "member" ? "other" : usageRole(at); + const matches = await call(commands.findSymbols(name, "all", SYMBOL_LIMIT)); + const symbols = filterSymbols(matches, name, role).filter((s) => compatibleFiles(doc.path, s.path)); + const targets = rankTargets( + symbols.map((s) => ({ path: s.path, range: s.range, name: s.name, kind: s.kind, container: s.container })), + doc.path, + ); + return { kind: "targets", targets, ambiguous: targets.length > 1 }; +} + +const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u; + +/** Name of a leaf identifier node (languages without `locals.scm`). */ +function identifierText(text: Text, at: NodeAt | null): string | null { + if (!at || !/identifier|^name$|^constant$|^word$/.test(at.node.kind)) return null; + const { from, to } = rangeToOffsets(text, at.node.range); + const name = text.sliceString(from, to); + return IDENT.test(name) ? name : null; +} + +/** Whether `targets` is just the declaration the caret is on (IDEA then shows usages). */ +export function isSelfTarget(targets: readonly Location[], path: string, pos: Pos): boolean { + return targets.length === 1 && targets[0]!.path === path && rangeContains(targets[0]!.range, pos); +} + +// --------------------------------------------------------------------------- +// Highlight usages + +export interface LocalHighlight { + range: Range; + kind: "write" | "text"; +} + +/** Same-scope occurrences of a certain local (the definition marked as a write). */ +export function localHighlights(locals: Locals): LocalHighlight[] | null { + if (locals.status !== "local" || !locals.symbol.certain) return null; + const { symbol } = locals; + return [ + { range: symbol.definition, kind: "write" as const }, + ...symbol.references.map((range) => ({ range, kind: "text" as const })), + ].sort((a, b) => comparePos(a.range.start, b.range.start)); +} + +// --------------------------------------------------------------------------- +// Rename + +/** Languages whose shorthand fields (`{ x }`, `S { x }`) name both a key and a variable. */ +const SHORTHAND_LANGUAGES = new Set(["javascript", "javascriptreact", "typescript", "typescriptreact", "rust"]); +const SHORTHAND_NODES = new Set([ + "shorthand_property_identifier", + "shorthand_property_identifier_pattern", + "shorthand_field_identifier", +]); + +export interface RenameOccurrence { + range: Range; + /** `{ x }` shorthand: renaming must keep the key (`{ x: y }`). */ + shorthand: boolean; +} + +export function isShorthand(at: NodeAt | null): boolean { + if (!at) return false; + if (SHORTHAND_NODES.has(at.node.kind)) return true; + return at.ancestors[0]?.kind === "shorthand_field_initializer"; +} + +export interface LocalRenamePlan { + symbol: LocalSymbol; + occurrences: RenameOccurrence[]; +} + +export type RenamePlanResult = + | { kind: "plan"; plan: LocalRenamePlan } + /** Not something the syntax fallback may rename; `message` says why. */ + | { kind: "refused"; message: string } + | { kind: "unsupported" }; + +/** Plans an in-file rename of the local at `pos` (definition + same-scope references). Call after `whenSynced`. */ +export async function planLocalRename( + doc: { doc: number; language: string }, + pos: Pos, + signal?: AbortSignal, +): Promise { + const support = await structure.support(doc.language); + if (!support.locals) return { kind: "unsupported" }; + const locals = await structure.locals(doc.doc, pos, { signal }); + if (locals.status === "noIdentifier") return { kind: "refused", message: "This element can't be renamed" }; + if (locals.status === "notLocal") + return { + kind: "refused", + message: `Can't rename '${locals.name}' without the language server: only local variables can be renamed based on syntax`, + }; + const { symbol } = locals; + const ranges = [symbol.definition, ...symbol.references].sort((a, b) => comparePos(a.start, b.start)); + const shorthand = SHORTHAND_LANGUAGES.has(doc.language) + ? await Promise.all(ranges.map((r) => structure.nodeAt(doc.doc, r, { signal }).then(isShorthand))) + : ranges.map(() => false); + return { kind: "plan", plan: { symbol, occurrences: ranges.map((range, i) => ({ range, shorthand: shorthand[i]! })) } }; +} + +/** CodeMirror changes renaming every occurrence; shorthands keep their key. Null when the text no longer matches. */ +export function renameChanges(doc: Text, plan: LocalRenamePlan, newName: string): ChangeSpec[] | null { + const out: ChangeSpec[] = []; + for (const o of plan.occurrences) { + const { from, to } = rangeToOffsets(doc, o.range); + if (doc.sliceString(from, to) !== plan.symbol.name) return null; + out.push({ from, to, insert: o.shorthand ? `${plan.symbol.name}: ${newName}` : newName }); + } + return out; +} + +/** Why a syntax-based rename needs confirmation (empty: safe to apply). */ +export async function renameRisks(doc: number, plan: LocalRenamePlan, newName: string, signal?: AbortSignal): Promise { + const risks: string[] = []; + const { symbol } = plan; + if (!symbol.certain) + risks.push( + `'${symbol.name}' is declared ${symbol.conflicts.length + 1} times in this scope; occurrences were assigned by position.`, + ); + const clashes = (await structure.identifiers(doc, newName, { signal })).filter( + (o) => !o.qualified && rangeContains(symbol.scope, o.range.start), + ); + if (clashes.length) + risks.push(`'${newName}' is already used in this scope (${clashes.length} ${clashes.length === 1 ? "occurrence" : "occurrences"}).`); + return risks; +} + +export const VALID_NAME = IDENT; diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts new file mode 100644 index 00000000..95220c81 --- /dev/null +++ b/src/editor/lsp/theme.ts @@ -0,0 +1,308 @@ +/** Editor-side styling for LSP features (CodeMirror theme; tokens only). */ +import { EditorView } from "@codemirror/view"; + +const tooltipSurface = { + backgroundColor: "var(--bg-popup)", + border: "1px solid var(--border-popup)", + borderRadius: "var(--radius-popup)", + boxShadow: "var(--shadow-popup)", + color: "var(--fg)", +}; + +const markdown = { + ".cm-lsp-markdown": { + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-ui)", + lineHeight: "1.55", + color: "var(--fg-strong)", + overflowWrap: "anywhere", + whiteSpace: "normal", + }, + ".cm-lsp-markdown > :first-child": { marginTop: "0" }, + ".cm-lsp-markdown > :last-child": { marginBottom: "0" }, + ".cm-lsp-markdown p, .cm-lsp-markdown ul, .cm-lsp-markdown ol": { margin: "6px 0" }, + ".cm-lsp-markdown ul, .cm-lsp-markdown ol": { paddingLeft: "18px" }, + ".cm-lsp-markdown h1, .cm-lsp-markdown h2, .cm-lsp-markdown h3, .cm-lsp-markdown h4": { + fontSize: "var(--fs-ui)", + fontWeight: "600", + color: "var(--fg-strong)", + margin: "8px 0 4px", + }, + ".cm-lsp-markdown hr": { border: "none", borderTop: "1px solid var(--border-popup)", margin: "10px -14px" }, + ".cm-lsp-markdown a": { color: "var(--link)", textDecoration: "none", cursor: "pointer" }, + ".cm-lsp-markdown a:hover": { color: "var(--link-hover)", textDecoration: "underline" }, + ".cm-lsp-markdown code": { + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-small)", + color: "var(--fg-emphasis)", + }, + ".cm-lsp-markdown pre, .cm-lsp-code": { + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-ui)", + lineHeight: "1.5", + margin: "6px 0", + whiteSpace: "pre-wrap", + color: "var(--fg-emphasis)", + }, + ".cm-lsp-markdown pre": { + backgroundColor: "var(--bg-popup-inline, var(--bg-elev))", + borderRadius: "var(--radius-small)", + padding: "6px 8px", + }, + ".cm-lsp-markdown pre code": { fontSize: "inherit" }, + ".cm-lsp-plain": { whiteSpace: "pre-wrap" }, + ".cm-lsp-markdown table": { borderCollapse: "collapse" }, + ".cm-lsp-markdown td, .cm-lsp-markdown th": { border: "1px solid var(--border-strong)", padding: "2px 6px" }, + // Lezer classHighlighter tokens inside documentation code. + ".tok-keyword, .tok-modifier, .tok-controlKeyword, .tok-operatorKeyword": { color: "var(--syn-keyword)" }, + ".tok-string, .tok-string2": { color: "var(--syn-string)" }, + ".tok-number, .tok-bool, .tok-atom": { color: "var(--syn-number)" }, + ".tok-typeName, .tok-className, .tok-namespace": { color: "var(--syn-type)" }, + ".tok-comment": { color: "var(--syn-comment)", fontStyle: "italic" }, + ".tok-meta, .tok-macroName": { color: "var(--syn-annotation)" }, + ".tok-propertyName": { color: "var(--syn-property)" }, + ".tok-variableName.tok-definition": { color: "var(--syn-function)" }, + ".tok-punctuation, .tok-operator": { color: "var(--syn-punct)" }, + ".tok-invalid": { color: "var(--syn-invalid)" }, +}; + +export const lspTheme = EditorView.theme({ + // --- completion (IDEA lookup: icon column, bold blue match, grey tail/type, advertiser) --- + ".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion": { + ...tooltipSurface, + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-code)", + overflow: "hidden", + }, + ".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion > ul": { + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + minWidth: "420px", + maxWidth: "min(720px, 80vw)", + maxHeight: "calc(var(--h-list-row) * 11 + 8px)", + padding: "4px 0", + }, + ".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion > ul > li": { + display: "flex", + alignItems: "center", + gap: "6px", + height: "var(--h-list-row)", + lineHeight: "var(--h-list-row)", + margin: "0 4px", + padding: "0 8px 0 6px", + borderRadius: "var(--radius-small)", + color: "var(--fg-editor, var(--fg-strong))", + whiteSpace: "nowrap", + }, + ".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion > ul > li[aria-selected]": { + backgroundColor: "var(--bg-list-selected, var(--bg-tree-selected, var(--bg-selected-strong)))", + color: "var(--fg-editor, var(--fg-strong))", + }, + ".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion > ul > completion-section": { + color: "var(--fg-subtle)", + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-small)", + padding: "4px 12px 2px", + borderBottom: "none", + }, + ".cm-lsp-completion .cm-lsp-kind": { + flex: "none", + width: "16px", + height: "16px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, + ".cm-lsp-completion .cm-completionLabel": { flex: "none", overflow: "hidden", textOverflow: "ellipsis" }, + ".cm-lsp-completion .cm-completionDetail": { + flex: "0 1 auto", + marginLeft: "0", + fontStyle: "normal", + color: "var(--fg-subtle)", + overflow: "hidden", + textOverflow: "ellipsis", + }, + ".cm-lsp-completion .cm-completionMatchedText": { + textDecoration: "none", + fontWeight: "700", + color: "var(--fg-completion-match, var(--link))", + }, + // A server's label detail (`(a: string)`) hugs the name; template descriptions keep the gap. + ".cm-lsp-completion .cm-lsp-option .cm-completionDetail": { marginLeft: "-6px" }, + ".cm-lsp-completion .cm-lsp-type": { + flex: "1 0 auto", + maxWidth: "60%", + marginLeft: "auto", + paddingLeft: "16px", + textAlign: "right", + color: "var(--fg-subtle)", + overflow: "hidden", + textOverflow: "ellipsis", + }, + ".cm-lsp-completion .cm-lsp-keyword-option .cm-completionLabel": { fontWeight: "700" }, + ".cm-lsp-completion .cm-lsp-deprecated-option .cm-completionLabel": { + textDecoration: "line-through", + color: "var(--fg-muted)", + }, + ".cm-lsp-completion-footer": { + display: "flex", + alignItems: "center", + padding: "5px 12px", + backgroundColor: "var(--bg-popup-inline, var(--bg-elev))", + color: "var(--fg-subtle)", + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-small)", + whiteSpace: "nowrap", + }, + ".cm-tooltip.cm-completionInfo": { + ...tooltipSurface, + padding: "8px 12px", + maxWidth: "440px", + maxHeight: "320px", + overflow: "auto", + }, + ".cm-lsp-completion-doc": { whiteSpace: "normal" }, + ".cm-lsp-doc-signature": { marginTop: "0", color: "var(--fg-emphasis)" }, + + // --- hover / quick documentation / signature / hints --------------------- + ".cm-tooltip.cm-tooltip-hover": { ...tooltipSurface }, + ".cm-lsp-hover": { maxWidth: "580px", maxHeight: "380px", overflow: "auto", padding: "10px 14px" }, + ".cm-lsp-hover-diagnostics + .cm-lsp-markdown": { + borderTop: "1px solid var(--border-popup)", + margin: "10px -14px 0", + padding: "10px 14px 0", + }, + ".cm-lsp-diag": { + display: "flex", + flexDirection: "column", + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-ui)", + paddingLeft: "22px", + position: "relative", + }, + ".cm-lsp-diag-icon": { position: "absolute", left: "0", top: "1px", display: "inline-flex" }, + ".cm-lsp-diag + .cm-lsp-diag": { marginTop: "8px" }, + ".cm-lsp-diag-error .cm-lsp-diag-icon": { color: "var(--danger)" }, + ".cm-lsp-diag-warning .cm-lsp-diag-icon": { color: "var(--warning)" }, + ".cm-lsp-diag-info .cm-lsp-diag-icon, .cm-lsp-diag-hint .cm-lsp-diag-icon": { color: "var(--info)" }, + ".cm-lsp-diag-message": { color: "var(--fg-strong)", whiteSpace: "pre-wrap", lineHeight: "18px" }, + ".cm-lsp-diag-meta": { display: "flex", gap: "12px", marginTop: "6px", alignItems: "baseline" }, + ".cm-lsp-diag-source": { color: "var(--fg-subtle)", fontSize: "var(--fs-small)" }, + ".cm-lsp-link-button": { + border: "none", + background: "none", + padding: "0", + font: "inherit", + fontSize: "var(--fs-ui)", + color: "var(--link)", + cursor: "pointer", + }, + ".cm-lsp-link-button:hover": { color: "var(--link-hover)", textDecoration: "underline" }, + ".cm-lsp-signature": { + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-code)", + padding: "6px 10px", + maxWidth: "640px", + }, + ".cm-lsp-signature-row": { color: "var(--fg-subtle)", whiteSpace: "pre-wrap", lineHeight: "20px" }, + ".cm-lsp-signature-active": { + color: "var(--fg-strong)", + backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))", + margin: "0 -10px", + padding: "0 10px", + }, + ".cm-lsp-signature-disabled": { opacity: "0.55" }, + ".cm-lsp-signature b": { fontWeight: "700", color: "var(--fg-strong)" }, + ".cm-lsp-signature-doc": { + borderTop: "1px solid var(--border-popup)", + marginTop: "6px", + paddingTop: "6px", + maxHeight: "160px", + overflow: "auto", + }, + ".cm-tooltip:has(> .cm-lsp-hint)": { background: "none", border: "none", boxShadow: "none" }, + ".cm-lsp-hint": { + backgroundColor: "var(--bg-popup)", + color: "var(--fg-strong)", + border: "1px solid var(--border-popup)", + fontFamily: "var(--font-ui)", + fontSize: "var(--fs-ui)", + padding: "6px 10px", + borderRadius: "var(--radius-popup)", + boxShadow: "var(--shadow-popup)", + maxWidth: "480px", + whiteSpace: "pre-wrap", + }, + ".cm-lsp-hint-error": { + backgroundColor: "var(--bg-feedback-error, color-mix(in srgb, var(--danger) 8%, var(--bg-popup)))", + borderColor: "color-mix(in srgb, var(--danger) 35%, var(--border-popup))", + }, + ".cm-lsp-hint-warning": { + backgroundColor: "var(--bg-feedback-warning, color-mix(in srgb, var(--warning) 9%, var(--bg-popup)))", + borderColor: "color-mix(in srgb, var(--warning) 35%, var(--border-popup))", + }, + + // --- navigation --------------------------------------------------------- + ".cm-lsp-link": { + color: "var(--link)", + textDecoration: "underline", + textUnderlineOffset: "2px", + cursor: "pointer", + }, + ".cm-lsp-link *": { color: "var(--link)" }, + ".cm-lsp-occurrence": { + backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))", + }, + ".cm-lsp-occurrence-write": { + backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))", + }, + + // --- floating lightbulb (intentions.ts) ----------------------------------- + ".cm-lsp-bulb": { + position: "absolute", + zIndex: "2", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "18px", + color: "var(--fg-bulb, var(--warning))", + cursor: "pointer", + }, + ".cm-lsp-bulb .cm-openidea-icon": { display: "inline-flex", borderRadius: "var(--radius-small)", padding: "1px" }, + ".cm-lsp-bulb:hover .cm-openidea-icon": { backgroundColor: "var(--bg-toolbar-hover)" }, + ".cm-lsp-bulb-fix": { color: "var(--danger)" }, + + // --- inlay hints (rounded grey chips) ------------------------------------- + ".cm-lsp-inlay": { + color: "var(--fg-inlay, var(--syn-inline-value))", + backgroundColor: "var(--bg-inlay, color-mix(in srgb, var(--fg-subtle) 12%, transparent))", + borderRadius: "var(--radius-small)", + padding: "0 4px", + fontSize: "0.9em", + fontFamily: "var(--font-ui)", + verticalAlign: "1px", + WebkitUserSelect: "none", + userSelect: "none", + }, + ".cm-lsp-inlay-pl": { marginLeft: "4px" }, + ".cm-lsp-inlay-pr": { marginRight: "4px" }, + + // --- semantic tokens ----------------------------------------------------- + ".cm-sem-type, .cm-sem-typeParameter": { color: "var(--syn-type)" }, + // IDEA colours declarations, calls stay (nearly) plain text. + ".cm-sem-function": { color: "var(--syn-function-call, var(--syn-function))" }, + ".cm-sem-function.cm-sem-declaration": { color: "var(--syn-function)" }, + ".cm-sem-function.cm-sem-builtin": { color: "var(--syn-builtin, var(--syn-function))" }, + ".cm-sem-macro": { color: "var(--syn-annotation)" }, + ".cm-sem-annotation": { color: "var(--syn-decorator, var(--syn-annotation))" }, + ".cm-sem-constant": { color: "var(--syn-constant)" }, + ".cm-sem-property": { color: "var(--syn-property)" }, + ".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" }, + ".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" }, + ".cm-sem-keyword": { color: "var(--syn-keyword)" }, + ".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" }, + ".cm-sem-static": { fontStyle: "italic" }, + ".cm-sem-mutable": { textDecoration: "underline", textDecorationColor: "var(--fg-faint)", textUnderlineOffset: "3px" }, + ".cm-sem-deprecated": { textDecoration: "line-through" }, + + ...markdown, +}); diff --git a/src/editor/lsp/typeInfo.test.ts b/src/editor/lsp/typeInfo.test.ts new file mode 100644 index 00000000..a334ca8f --- /dev/null +++ b/src/editor/lsp/typeInfo.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { extractTypeInfo, typeFromSignature } from "./typeInfo"; +import { IMPLEMENT_TITLE, OVERRIDE_TITLE } from "./actions"; + +const md = (value: string) => ({ kind: "markdown", value }); + +describe("expression type info", () => { + it("splits the type off variable signatures", () => { + expect(typeFromSignature("(local var) x: number")).toBe("number"); + expect(typeFromSignature("let mut v: Vec")).toBe("Vec"); + expect(typeFromSignature("(property) a.b?: string")).toBe("string"); + expect(typeFromSignature("const f: (a: number) => void")).toBe("(a: number) => void"); + expect(typeFromSignature("x: int = 3")).toBe("int"); + expect(typeFromSignature("var count int")).toBe("int"); + expect(typeFromSignature("fn add(a: i32, b: i32) -> i32")).toBe("fn add(a: i32, b: i32) -> i32"); + expect(typeFromSignature("String name")).toBe("String name"); + }); + + it("reads the signature block of a hover, skipping module paths", () => { + expect(extractTypeInfo(md("```rust\ncrate::util\n```\n\n```rust\nlet n: usize\n```\n---\nDocs"))).toEqual({ + text: "usize", + language: "rust", + }); + expect(extractTypeInfo(md("```typescript\nconst a: string[]\n```"))).toEqual({ + text: "string[]", + language: "typescript", + }); + expect(extractTypeInfo(md("Just prose with `T` inline"))).toEqual({ text: "T", language: null }); + expect(extractTypeInfo({ kind: "plaintext", value: "\nint x\nmore" })).toEqual({ text: "int x", language: null }); + expect(extractTypeInfo(md(" "))).toBeNull(); + expect(extractTypeInfo(null)).toBeNull(); + }); +}); + +describe("implement / override titles", () => { + it.each([ + ["Implement missing members", true, false], + ["Implement interface 'Runnable'", true, false], + ["Add missing trait items", true, false], + ["Override/Implement Methods...", true, true], + ["Override toString()", false, true], + ["Extract method", false, false], + ["Add import", false, false], + ])("%s", (title, implement, override) => { + expect(IMPLEMENT_TITLE.test(title)).toBe(implement); + expect(OVERRIDE_TITLE.test(title)).toBe(override); + }); +}); diff --git a/src/editor/lsp/typeInfo.ts b/src/editor/lsp/typeInfo.ts new file mode 100644 index 00000000..389c4b09 --- /dev/null +++ b/src/editor/lsp/typeInfo.ts @@ -0,0 +1,77 @@ +/** + * View › Expression Type Info (⌃⇧P): the type of the symbol / expression at + * the caret, taken from the language server's hover (its signature code + * block), shown in a small popup. + */ +import type { EditorView } from "@codemirror/view"; +import type { Markup } from "@/bindings"; +import { docOf, reportLspError } from "./lsp"; +import { requestHover } from "./hover"; +import { rangeToOffsets } from "./positions"; +import { showHint } from "./hint"; +import { caretAnchor, openPopup } from "./popupStore"; + +export interface TypeInfo { + /** The type, or the whole signature when no type can be split off. */ + text: string; + /** Fence language of the hover's code block, if any. */ + language: string | null; +} + +const FENCE = /```([\w+#-]*)[^\n]*\n([\s\S]*?)```/g; +/** A lone module / package path (rust-analyzer's first hover block). */ +const PATH_ONLY = /^[\w$]+(?:(?:::|\.)[\w$]+)*$/; +const MODIFIERS = + "(?:let|const|var|val|mut|static|readonly|export|declare|public|private|protected|internal|final|ref)"; +/** `(local var) x: T`, `let mut x: T`, `(property) a.b?: T`, `x: T`. */ +const COLON_TYPED = new RegExp(`^(?:\\([\\w ]+\\)\\s*)?(?:${MODIFIERS}\\s+)*[\\w$.]+\\??\\s*:\\s*([\\s\\S]+)$`); +/** Go: `var x int`, `field x string`, `const x untyped int`. */ +const GO_TYPED = /^(?:var|field|const)\s+[\w$]+\s+([\s\S]+?)(?:\s*=.*)?$/; + +/** Signature → type for variables / properties / parameters; other signatures as they are. */ +export function typeFromSignature(signature: string): string { + const sig = signature.replace(/\s+/g, " ").trim(); + const colon = COLON_TYPED.exec(sig); + if (colon) return colon[1]!.replace(/\s=(?!>)\s[\s\S]*$/, "").trim(); + const go = GO_TYPED.exec(sig); + if (go) return go[1]!.trim(); + return sig; +} + +/** Extracts the type shown by Expression Type Info from hover markup, or null. */ +export function extractTypeInfo(m: Markup | null | undefined): TypeInfo | null { + if (!m || !m.value.trim()) return null; + if (m.kind !== "markdown") { + const first = m.value.split(/\r?\n/).find((l) => l.trim()); + return first ? { text: typeFromSignature(first), language: null } : null; + } + const blocks = [...m.value.matchAll(FENCE)] + .map((b) => ({ language: b[1] || null, code: b[2]!.trim() })) + .filter((b) => b.code); + const block = blocks.find((b) => !PATH_ONLY.test(b.code)) ?? blocks[0]; + if (block) return { text: typeFromSignature(block.code), language: block.language }; + const inline = /`([^`]+)`/.exec(m.value); + return inline ? { text: typeFromSignature(inline[1]!), language: null } : null; +} + +export async function expressionTypeInfo(view: EditorView) { + const pos = view.state.selection.main.head; + try { + const hover = await requestHover(view, pos); + const info = extractTypeInfo(hover?.contents); + if (!hover || !info) { + showHint(view, "No type information found"); + return; + } + const language = info.language ?? docOf(view)?.uiMode ?? ""; + openPopup({ + kind: "doc", + anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), + markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, + language: docOf(view)?.uiMode ?? null, + themeClasses: view.themeClasses, + }); + } catch (e) { + reportLspError(view, e, true); + } +} diff --git a/src/editor/lsp/workspaceEdit.test.ts b/src/editor/lsp/workspaceEdit.test.ts new file mode 100644 index 00000000..b5c537f3 --- /dev/null +++ b/src/editor/lsp/workspaceEdit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { describeSummary, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; + +const edit = (line: number) => ({ range: { start: { line, col: 0 }, end: { line, col: 3 } }, new_text: "new" }); + +describe("summarizeWorkspaceEdit", () => { + it("counts files and occurrences", () => { + const s = summarizeWorkspaceEdit({ + files: [ + { kind: "edit", path: "/p/b.rs", edits: [edit(1), edit(2)] }, + { kind: "edit", path: "/p/a.rs", edits: [edit(4)] }, + { kind: "edit", path: "/p/b.rs", edits: [edit(9)] }, + { kind: "edit", path: "/p/empty.rs", edits: [] }, + ], + }); + expect(s.files).toBe(2); + expect(s.occurrences).toBe(4); + expect(s.hasFileOperations).toBe(false); + expect(s.entries.map((e) => [e.path, e.edits])).toEqual([ + ["/p/a.rs", 1], + ["/p/b.rs", 3], + ]); + expect(describeSummary(s)).toBe("4 occurrences in 2 files"); + expect(needsPreview(s)).toBe(true); + }); + + it("previews file operations even in a single file", () => { + const s = summarizeWorkspaceEdit({ + files: [ + { kind: "edit", path: "/p/a.ts", edits: [edit(0)] }, + { kind: "rename", from: "/p/a.ts", to: "/p/b.ts" }, + ], + }); + expect(s.files).toBe(1); + expect(s.entries[0]).toMatchObject({ operation: "rename", to: "/p/b.ts", edits: 1 }); + expect(needsPreview(s)).toBe(true); + expect(describeSummary(s)).toBe("1 occurrence in 1 file"); + }); + + it("applies single-file renames directly", () => { + const s = summarizeWorkspaceEdit({ files: [{ kind: "edit", path: "/p/a.ts", edits: [edit(0), edit(3)] }] }); + expect(needsPreview(s)).toBe(false); + }); + + it("handles empty edits", () => { + const s = summarizeWorkspaceEdit({ files: [] }); + expect(s).toMatchObject({ files: 0, occurrences: 0 }); + }); +}); diff --git a/src/editor/lsp/workspaceEdit.ts b/src/editor/lsp/workspaceEdit.ts new file mode 100644 index 00000000..6543dc9e --- /dev/null +++ b/src/editor/lsp/workspaceEdit.ts @@ -0,0 +1,72 @@ +/** Summaries of workspace edits for rename / refactoring previews. */ +import type { WorkspaceEdit } from "@/bindings"; + +export interface WorkspaceEditFileSummary { + path: string; + /** Text edits in this file. */ + edits: number; + /** Non-text operations (create/rename/delete) touching this path. */ + operation?: "create" | "rename" | "delete"; + /** Rename target, for `operation: "rename"`. */ + to?: string; +} + +export interface WorkspaceEditSummary { + /** Distinct files touched (text edits or operations). */ + files: number; + /** Total text edits. */ + occurrences: number; + /** Whether the edit creates, renames or deletes files. */ + hasFileOperations: boolean; + entries: WorkspaceEditFileSummary[]; +} + +export function summarizeWorkspaceEdit(edit: WorkspaceEdit): WorkspaceEditSummary { + const byPath = new Map(); + const entry = (path: string) => { + let e = byPath.get(path); + if (!e) byPath.set(path, (e = { path, edits: 0 })); + return e; + }; + let occurrences = 0; + let hasFileOperations = false; + for (const f of edit.files) { + switch (f.kind) { + case "edit": { + entry(f.path).edits += f.edits.length; + occurrences += f.edits.length; + break; + } + case "create": + hasFileOperations = true; + entry(f.path).operation = "create"; + break; + case "delete": + hasFileOperations = true; + entry(f.path).operation = "delete"; + break; + case "rename": { + hasFileOperations = true; + const e = entry(f.from); + e.operation = "rename"; + e.to = f.to; + break; + } + } + } + const entries = [...byPath.values()].filter((e) => e.edits > 0 || e.operation); + entries.sort((a, b) => a.path.localeCompare(b.path)); + return { files: entries.length, occurrences, hasFileOperations, entries }; +} + +/** "3 occurrences in 2 files". */ +export function describeSummary(s: WorkspaceEditSummary): string { + const occ = `${s.occurrences} ${s.occurrences === 1 ? "occurrence" : "occurrences"}`; + const files = `${s.files} ${s.files === 1 ? "file" : "files"}`; + return `${occ} in ${files}`; +} + +/** Whether applying needs confirmation (IDEA previews multi-file refactorings). */ +export function needsPreview(s: WorkspaceEditSummary): boolean { + return s.files > 1 || s.hasFileOperations; +} diff --git a/src/editor/navigation/gotoTest.test.ts b/src/editor/navigation/gotoTest.test.ts new file mode 100644 index 00000000..6b0d80e9 --- /dev/null +++ b/src/editor/navigation/gotoTest.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import type { RootFolder } from "@/platform/languageSettings"; +import { gotoTestOrSubject, testDirectoryFor, type ChooserItem, type TestNavDeps } from "./gotoTest"; + +const root = (path: string, kind: RootFolder["kind"]): RootFolder => ({ path, kind, detectedFrom: "pom.xml" }); +const ROOTS = [root("src/main/java", "source"), root("src/test/java", "test")]; + +function deps(files: string[], roots = ROOTS) { + const chosen: { title: string; items: ChooserItem[] }[] = []; + const d: TestNavDeps & { chosen: typeof chosen } = { + chosen, + projectRoot: () => "/p", + roots: () => roots, + findByName: async (name) => files.filter((f) => f.endsWith(`/${name}`)), + open: vi.fn(async () => {}), + choose: (title, items) => void chosen.push({ title, items }), + hint: vi.fn(), + createTest: vi.fn(), + createRustTestModule: vi.fn(), + }; + return d; +} + +beforeEach(() => { + useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never, settings: {} as never }); +}); + +describe("Go to Test / Test Subject", () => { + it("opens the only test of a class", async () => { + const d = deps(["/p/src/main/java/a/Foo.java", "/p/src/test/java/a/FooTest.java"]); + await gotoTestOrSubject("/p/src/main/java/a/Foo.java", "", d); + expect(d.open).toHaveBeenCalledWith("/p/src/test/java/a/FooTest.java"); + }); + + it("goes back from a test to its subject", async () => { + const d = deps(["/p/src/main/java/a/Foo.java", "/p/src/test/java/a/FooTest.java"]); + await gotoTestOrSubject("/p/src/test/java/a/FooTest.java", "", d); + expect(d.open).toHaveBeenCalledWith("/p/src/main/java/a/Foo.java"); + }); + + it("shows a chooser with Create New Test… for several tests, mirrored package first", async () => { + const d = deps(["/p/src/test/java/b/FooTest.java", "/p/src/test/java/a/FooTests.java", "/p/src/main/java/a/Foo.java"]); + await gotoTestOrSubject("/p/src/main/java/a/Foo.java", "", d); + expect(d.chosen[0]!.title).toBe("Choose Test for Foo (2 found)"); + expect(d.chosen[0]!.items.map((i) => i.label)).toEqual(["Create New Test…", "FooTests.java", "FooTest.java"]); + }); + + it("offers Create New Test… in the mirrored test package when there is none", async () => { + const d = deps(["/p/src/main/java/a/Foo.java"]); + await gotoTestOrSubject("/p/src/main/java/a/Foo.java", "", d); + expect(d.chosen[0]!.items.map((i) => i.label)).toEqual(["Create New Test…"]); + await d.chosen[0]!.items[0]!.run(); + expect(d.createTest).toHaveBeenCalledWith({ directory: "/p/src/test/java/a", name: "FooTest", extension: "java", subject: "Foo.java" }); + }); + + it("does not take a non-test file of a test-like name as a test", async () => { + const d = deps(["/p/src/main/java/a/Foo.java", "/p/src/main/java/a/FooTests.java"], [root("src/main/java", "source")]); + // FooTests.java is a test by name, wherever it is. + await gotoTestOrSubject("/p/src/main/java/a/Foo.java", "", d); + expect(d.open).toHaveBeenCalledWith("/p/src/main/java/a/FooTests.java"); + }); + + it("Go / Python / TS conventions, next to the file", async () => { + const d = deps(["/p/pkg/foo.go", "/p/pkg/foo_test.go", "/p/m/test_bar.py", "/p/web/x.spec.ts"], []); + await gotoTestOrSubject("/p/pkg/foo.go", "", d); + expect(d.open).toHaveBeenLastCalledWith("/p/pkg/foo_test.go"); + await gotoTestOrSubject("/p/m/bar.py", "", d); + expect(d.open).toHaveBeenLastCalledWith("/p/m/test_bar.py"); + await gotoTestOrSubject("/p/web/x.ts", "", d); + expect(d.open).toHaveBeenLastCalledWith("/p/web/x.spec.ts"); + }); + + it("Rust: the in-file test module and integration tests", async () => { + const text = "pub fn lex() {}\n#[cfg(test)]\nmod tests {}\n"; + const d = deps(["/p/src/lexer.rs", "/p/tests/lexer.rs"], []); + await gotoTestOrSubject("/p/src/lexer.rs", text, d); + expect(d.chosen[0]!.items.map((i) => i.label)).toEqual(["Create New Test…", "mod tests", "lexer.rs"]); + await d.chosen[0]!.items[1]!.run(); + expect(d.open).toHaveBeenCalledWith("/p/src/lexer.rs", text.indexOf("#[cfg")); + await d.chosen[0]!.items[0]!.run(); + expect(d.createRustTestModule).toHaveBeenCalled(); + await gotoTestOrSubject("/p/tests/lexer.rs", "", d); + expect(d.open).toHaveBeenLastCalledWith("/p/src/lexer.rs"); + }); + + it("hints when a test has no subject", async () => { + const d = deps(["/p/src/test/java/a/FooTest.java"]); + await gotoTestOrSubject("/p/src/test/java/a/FooTest.java", "", d); + expect(d.hint).toHaveBeenCalledWith("No test subject found"); + }); + + it("puts new tests next to the file without source / test roots", () => { + expect(testDirectoryFor("/p/pkg/foo.go", "/p", [])).toBe("/p/pkg"); + expect(testDirectoryFor("/p/src/main/java/Foo.java", "/p", ROOTS)).toBe("/p/src/test/java"); + }); +}); diff --git a/src/editor/navigation/gotoTest.ts b/src/editor/navigation/gotoTest.ts new file mode 100644 index 00000000..58317911 --- /dev/null +++ b/src/editor/navigation/gotoTest.ts @@ -0,0 +1,114 @@ +/** + * Navigate › Test / Test Subject (⇧⌘T): from a class to its tests and back, + * by the naming conventions of `testNaming.ts` and the project's source / + * test roots. One match opens it; several show a chooser; with none (or + * always, in the chooser) "Create New Test…" creates one from the file templates. + */ +import { basename, dirname } from "@/platform/project"; +import { isTestFile, rootOfPath } from "@/platform/projectRoots"; +import type { RootFolder } from "@/platform/languageSettings"; +import { isTestName, newTestName, rankCandidates, rustTestModuleOffset, splitName, subjectNamesFor, testNamesFor } from "./testNaming"; + +export interface ChooserItem { + label: string; + detail?: string; + icon?: string; + emphasis?: boolean; + run: () => void | Promise; +} + +export interface TestNavDeps { + projectRoot: () => string | null; + roots: () => RootFolder[]; + /** Absolute paths of project files named exactly `name`. */ + findByName: (name: string) => Promise; + open: (path: string, offset?: number) => Promise; + choose: (title: string, items: ChooserItem[]) => void; + hint: (text: string) => void; + /** Create New Test… in `directory` (file templates), or a Rust test module in the file itself. */ + createTest: (req: { directory: string; name: string; extension: string; subject: string }) => void; + createRustTestModule: () => void; +} + +const isRustIntegrationTest = (path: string) => path.endsWith(".rs") && /\/tests\/[^/]+\.rs$/.test(path); + +export function isTestSide(path: string, roots: readonly RootFolder[]): boolean { + return isTestFile(path, [...roots]) || isTestName(basename(path)) || isRustIntegrationTest(path); +} + +/** Path below the innermost root (`com/x/Foo.java` for `src/main/java/com/x/Foo.java`). */ +function belowRoot(root: string | null, roots: readonly RootFolder[]) { + return (path: string): string | null => { + if (!root || !path.startsWith(root + "/")) return null; + const rel = path.slice(root.length + 1); + const r = rootOfPath(path, [...roots]); + return r && r.path ? rel.slice(r.path.length + 1) : rel; + }; +} + +const commonSegments = (a: string, b: string) => { + const x = a.split("/"); + const y = b.split("/"); + let n = 0; + while (n < x.length && n < y.length && x[n] === y[n]) n++; + return n; +}; + +/** Where Create New Test… puts the test: the mirrored package under the closest test root, else next to the file. */ +export function testDirectoryFor(path: string, projectRoot: string | null, roots: readonly RootFolder[]): string { + const dir = dirname(path); + if (!projectRoot) return dir; + const own = rootOfPath(path, [...roots]); + const tests = roots.filter((r) => r.kind === "test"); + if (!own || own.kind !== "source" || !tests.length) return dir; + const best = [...tests].sort((a, b) => commonSegments(own.path, b.path) - commonSegments(own.path, a.path))[0]!; + const ownBase = own.path ? `${projectRoot}/${own.path}` : projectRoot; + const relDir = dir === ownBase ? "" : dir.slice(ownBase.length + 1); + const base = best.path ? `${projectRoot}/${best.path}` : projectRoot; + return relDir ? `${base}/${relDir}` : base; +} + +async function findAll(names: readonly string[], self: string, deps: TestNavDeps): Promise { + const found = await Promise.all(names.map((n) => deps.findByName(n).catch(() => [] as string[]))); + return [...new Set(found.flat())].filter((p) => p !== self); +} + +function relLabel(root: string | null, path: string) { + return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; +} + +export async function gotoTestOrSubject(path: string, text: string, deps: TestNavDeps): Promise { + const roots = deps.roots(); + const root = deps.projectRoot(); + const name = basename(path); + const rank = (paths: string[]) => rankCandidates(path, paths, belowRoot(root, roots)); + + if (isTestSide(path, roots)) { + const subjects = rank((await findAll(subjectNamesFor(name), path, deps)).filter((p) => !isTestSide(p, roots))); + if (subjects.length === 1) return deps.open(subjects[0]!); + if (!subjects.length) return deps.hint("No test subject found"); + return deps.choose(`Choose Test Subject for ${splitName(name).stem} (${subjects.length} found)`, subjects.map((p) => ({ label: basename(p), detail: relLabel(root, dirname(p)), run: () => deps.open(p) }))); + } + + const tests = rank((await findAll(testNamesFor(name), path, deps)).filter((p) => isTestSide(p, roots))); + const items: ChooserItem[] = tests.map((p) => ({ label: basename(p), detail: relLabel(root, dirname(p)), run: () => deps.open(p) })); + // Rust unit tests live in the file's own `#[cfg(test)]` module. + const inFile = path.endsWith(".rs") ? rustTestModuleOffset(text) : null; + if (inFile !== null) items.unshift({ label: "mod tests", detail: `${name} (#[cfg(test)])`, run: () => deps.open(path, inFile) }); + if (items.length === 1) return void (await items[0]!.run()); + const { ext } = splitName(name); + const create: ChooserItem = { + label: "Create New Test…", + emphasis: true, + run: () => + path.endsWith(".rs") + ? deps.createRustTestModule() + : deps.createTest({ + directory: testDirectoryFor(path, root, roots), + name: splitName(newTestName(name)).stem, + extension: ext.replace(/^\./, ""), + subject: name, + }), + }; + deps.choose(`Choose Test for ${splitName(name).stem} (${items.length} found)`, [create, ...items]); +} diff --git a/src/editor/navigation/index.ts b/src/editor/navigation/index.ts new file mode 100644 index 00000000..6cfb1e72 --- /dev/null +++ b/src/editor/navigation/index.ts @@ -0,0 +1,174 @@ +/** + * Navigate › Test / Test Subject (⇧⌘T), Super Method (⌘U), Next / Previous + * Method (⌃⇧↓ / ⌃⇧↑). Editor-side like editor/lsp (whose popups it uses); + * the file templates are reached through the `templates.newFromTemplate` action. + */ +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { activeEditor, editorsFor, whenSynced, type EditorDocContext } from "@/editor/api"; +import { docOf, lspRequest } from "@/editor/lsp/lsp"; +import { offsetToPos, posToOffset } from "@/editor/lsp/positions"; +import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { dedupeLocations, locationItems, navigateTo } from "@/editor/lsp/navigation"; +import { showHint } from "@/editor/lsp/hint"; +import { registerActions, executeAction, type Action } from "@/platform/actions"; +import { call, commands, isCancelled, type Location, type OutlineItem, type Pos } from "@/platform/ipc"; +import { basename, useProject } from "@/platform/project"; +import { currentRoots } from "@/platform/projectRoots"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { editorService } from "@/workbench/contributions"; +import { fileIconName } from "@/ui"; +import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest"; +import { baseName, memberAt, methodStop, superMethods } from "./structureNav"; + +const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; + +/** A backend document id for `path` while `fn` runs (open editors are reused). */ +async function withDoc(path: string, fn: (doc: number) => Promise): Promise { + const open = editorsFor(path)[0]; + if (open) return fn(open.ctx.doc); + const info = await call(isVirtualUri(path) ? commands.docOpenVirtual(path) : commands.docOpen(path)); + try { + return await fn(info.id); + } finally { + void commands.docClose(info.id); + } +} + +const outlineOf = (doc: number) => lspRequest(() => commands.docOutline(doc)); + +async function caretOutline(view: EditorView, ctx: EditorDocContext): Promise<{ outline: OutlineItem[]; caret: Pos } | null> { + await whenSynced(view); + const caret = offsetToPos(view.state.doc, view.state.selection.main.head); + try { + return { outline: await outlineOf(ctx.doc), caret }; + } catch (e) { + if (!isCancelled(e)) showHint(view, "No structure information for this file"); + return null; + } +} + +async function gotoMethod(view: EditorView, dir: 1 | -1) { + const ctx = docOf(view); + if (!ctx) return; + const at = await caretOutline(view, ctx); + if (!at) return; + const target = methodStop(at.outline, at.caret, dir); + if (!target) return; + const pos = posToOffset(view.state.doc, target); + view.dispatch({ selection: EditorSelection.cursor(pos), scrollIntoView: true, userEvent: "select" }); +} + +/** Without a type hierarchy: languages whose servers answer "super" through another request. */ +async function superFallback(ctx: EditorDocContext, pos: Pos): Promise { + const kind = ctx.language === "go" ? "implementation" : ctx.language === "rust" ? "declaration" : null; + if (!kind) return []; + const locs = await lspRequest(() => commands.lspGoto(ctx.doc, pos, kind)).catch(() => [] as Location[]); + return dedupeLocations(locs).filter((l) => !(l.path === ctx.path && l.range.start.line === pos.line)); +} + +async function superMethod(view: EditorView) { + const ctx = docOf(view); + if (!ctx) return; + const at = await caretOutline(view, ctx); + if (!at) return; + const { method, type } = memberAt(at.outline, at.caret); + const deps = { + supertypes: (path: string, pos: Pos) => withDoc(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))), + outline: (path: string) => withDoc(path, outlineOf), + }; + let locs: Location[] = []; + if (method && type) locs = await superMethods(ctx.path, type.selection_range.start, method.name, deps); + else if (type) { + const supers = await deps.supertypes(ctx.path, type.selection_range.start).catch(() => []); + locs = supers.map((s) => ({ path: s.path, range: s.selectionRange, uri: s.uri ?? null })); + } + if (!locs.length && method) locs = await superFallback(ctx, method.selection_range.start); + locs = dedupeLocations(locs); + const head = view.state.selection.main.head; + if (!locs.length) { + showHint(view, method ? "No super method found" : "No super class found"); + return; + } + if (locs.length === 1) { + await navigateTo(locs[0]!); + return; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, head), + title: method ? `Choose Super Method of ${baseName(method.name)} (${locs.length} found)` : `Choose Super Class or Interface (${locs.length} found)`, + items: await locationItems(locs), + }); +} + +const RUST_TEST_MODULE = "\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn it_works() {\n \n }\n}\n"; + +function testDeps(view: EditorView, ctx: EditorDocContext): TestNavDeps { + return { + projectRoot: () => useProject.getState().project?.root ?? null, + roots: () => currentRoots(), + findByName: async (name) => (await call(commands.findFiles(name, 50))).filter((m) => basename(m.path) === name).map((m) => m.path), + open: async (path, offset) => { + const selection = offset !== undefined && path === ctx.path ? offsetToPos(view.state.doc, offset) : undefined; + await editorService().openFile(path, { selection, focus: true }); + }, + choose: (title, items) => + openPopup({ + kind: "list", + anchor: caretAnchor(view, view.state.selection.main.head), + title, + items: items.map((it, i) => ({ + id: `${i}`, + label: it.label, + detail: it.detail, + emphasis: it.emphasis, + icon: it.emphasis ? "plus" : fileIconName(it.label), + run: it.run, + })), + }), + hint: (text) => showHint(view, text), + createTest: ({ directory, name, extension }) => void executeAction("templates.newFromTemplate", { directory, name, extension, kindId: "test" }), + createRustTestModule: () => { + if (view.state.readOnly) return; + const end = view.state.doc.length; + const insert = (view.state.doc.sliceString(end - 1) === "\n" ? "" : "\n") + RUST_TEST_MODULE; + const caret = end + insert.indexOf("fn it_works() {\n") + "fn it_works() {\n ".length; + view.dispatch({ changes: { from: end, insert }, selection: EditorSelection.cursor(caret), scrollIntoView: true, userEvent: "input" }); + view.focus(); + }, + }; +} + +async function gotoTest(view: EditorView) { + const ctx = docOf(view); + if (!ctx || isVirtualUri(ctx.path)) return; + try { + await gotoTestOrSubject(ctx.path, view.state.doc.toString(), testDeps(view, ctx)); + } catch (e) { + if (!isCancelled(e)) showHint(view, e instanceof Error ? e.message : String(e), { tone: "error" }); + } +} + +function editorAction(id: string, title: string, run: (view: EditorView) => unknown, extra: Partial = {}): Action { + return { + id, + title, + category: "Navigate", + when: EDITOR_WHEN, + run: () => { + const ed = activeEditor(); + if (ed) void run(ed.view); + }, + ...extra, + }; +} + +export function registerNavigationActions(): () => void { + return registerActions([ + editorAction("navigate.gotoTest", "Test", gotoTest, { keywords: ["go to test", "test subject", "create test"] }), + editorAction("navigate.superMethod", "Super Method", superMethod, { keywords: ["go to super", "overridden", "parent"] }), + editorAction("navigate.nextMethod", "Next Method", (v) => gotoMethod(v, 1)), + editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)), + ]); +} diff --git a/src/editor/navigation/structureNav.test.ts b/src/editor/navigation/structureNav.test.ts new file mode 100644 index 00000000..9d18e8bc --- /dev/null +++ b/src/editor/navigation/structureNav.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { HierarchyItem, OutlineItem, SymbolKind } from "@/platform/ipc"; +import { memberAt, methodStop, methodStops, superMethods } from "./structureNav"; + +const r = (l1: number, l2: number) => ({ start: { line: l1, col: 0 }, end: { line: l2, col: 1 } }); +function item(name: string, kind: SymbolKind, from: number, to: number, children: OutlineItem[] = []): OutlineItem { + return { name, kind, detail: null, range: r(from, to), selection_range: { start: { line: from, col: 4 }, end: { line: from, col: 4 + name.length } }, children }; +} + +const OUTLINE = [ + item("Foo", "class", 0, 20, [item("x", "field", 1, 1), item("a", "method", 2, 5), item("b", "method", 7, 9), item("Inner", "class", 11, 18, [item("c", "method", 12, 14)])]), + item("main", "function", 22, 25), +]; + +describe("Next / Previous Method", () => { + it("stops at methods and types in document order", () => { + expect(methodStops(OUTLINE).map((p) => p.line)).toEqual([0, 2, 7, 11, 12, 22]); + }); + + it("moves by line", () => { + expect(methodStop(OUTLINE, { line: 3, col: 0 }, 1)?.line).toBe(7); + expect(methodStop(OUTLINE, { line: 7, col: 0 }, 1)?.line).toBe(11); + expect(methodStop(OUTLINE, { line: 7, col: 8 }, -1)?.line).toBe(7); + expect(methodStop(OUTLINE, { line: 7, col: 2 }, -1)?.line).toBe(2); + expect(methodStop(OUTLINE, { line: 0, col: 0 }, -1)).toBeNull(); + expect(methodStop(OUTLINE, { line: 23, col: 0 }, 1)).toBeNull(); + }); +}); + +describe("Super Method", () => { + it("finds the method and type at the caret", () => { + expect(memberAt(OUTLINE, { line: 13, col: 2 })).toMatchObject({ method: { name: "c" }, type: { name: "Inner" } }); + expect(memberAt(OUTLINE, { line: 10, col: 0 })).toMatchObject({ method: null, type: { name: "Foo" } }); + expect(memberAt(OUTLINE, { line: 23, col: 0 })).toMatchObject({ method: { name: "main" }, type: null }); + }); + + it("looks for the method in supertypes, level by level", async () => { + const hier = (name: string, path: string, line: number): HierarchyItem => ({ + name, + kind: "class", + detail: null, + path, + range: r(line, line + 10), + selectionRange: { start: { line, col: 4 }, end: { line, col: 7 } }, + fromRanges: [], + }); + const outlines: Record = { + "/Base.java": [item("Base", "class", 0, 10, [item("other", "method", 2, 3)])], + "/Root.java": [item("Root", "interface", 0, 10, [item("a", "method", 4, 4)])], + }; + const supers: Record = { + "/Foo.java": [hier("Base", "/Base.java", 0)], + "/Base.java": [hier("Root", "/Root.java", 0)], + }; + const deps = { + supertypes: async (path: string) => supers[path] ?? [], + outline: async (path: string) => outlines[path] ?? [], + }; + expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "a", deps)).toEqual([ + { path: "/Root.java", range: { start: { line: 4, col: 4 }, end: { line: 4, col: 5 } } }, + ]); + expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "missing", deps)).toEqual([]); + // jdtls names methods with their parameters. + expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "a() : void", deps)).toHaveLength(1); + }); +}); diff --git a/src/editor/navigation/structureNav.ts b/src/editor/navigation/structureNav.ts new file mode 100644 index 00000000..f25bb1f0 --- /dev/null +++ b/src/editor/navigation/structureNav.ts @@ -0,0 +1,106 @@ +/** + * Outline-based navigation: Next / Previous Method (⌃⇧↓ / ⌃⇧↑) and Super + * Method (⌘U). The outline is the language server's document symbols, or + * tree-sitter's without a server (`doc_outline`). + */ +import type { HierarchyItem, Location, OutlineItem, Pos, SymbolKind } from "@/platform/ipc"; + +const METHODS = new Set(["function", "method", "constructor"]); +const TYPES = new Set(["class", "interface", "struct", "enum", "trait", "module", "namespace"]); + +const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col <= b.col); +const inside = (i: OutlineItem, p: Pos) => before(i.range.start, p) && before(p, i.range.end); + +/** Navigation stops of Next / Previous Method: methods and types, in document order. */ +export function methodStops(outline: readonly OutlineItem[]): Pos[] { + const out: Pos[] = []; + const walk = (items: readonly OutlineItem[]) => { + for (const i of items) { + if (METHODS.has(i.kind) || TYPES.has(i.kind)) out.push(i.selection_range.start); + walk(i.children); + } + }; + walk(outline); + const key = (p: Pos) => p.line * 1e6 + p.col; + return [...new Map(out.map((p) => [key(p), p])).values()].sort((a, b) => key(a) - key(b)); +} + +/** The next (dir 1) / previous (-1) stop from `caret`, comparing by line like IDEA. */ +export function methodStop(outline: readonly OutlineItem[], caret: Pos, dir: 1 | -1): Pos | null { + const stops = methodStops(outline); + if (dir === 1) return stops.find((p) => p.line > caret.line) ?? null; + for (let i = stops.length - 1; i >= 0; i--) { + const p = stops[i]!; + if (p.line < caret.line || (p.line === caret.line && p.col < caret.col)) return p; + } + return null; +} + +export interface CaretMember { + /** Innermost method at the caret. */ + method: OutlineItem | null; + /** Innermost type around the caret (around `method` when there is one). */ + type: OutlineItem | null; +} + +export function memberAt(outline: readonly OutlineItem[], caret: Pos): CaretMember { + let method: OutlineItem | null = null; + let type: OutlineItem | null = null; + let level: readonly OutlineItem[] = outline; + for (;;) { + const hit = level.find((i) => inside(i, caret)); + if (!hit) break; + if (METHODS.has(hit.kind)) method = hit; + else if (TYPES.has(hit.kind) && !method) type = hit; + level = hit.children; + } + return { method, type }; +} + +/** `beta(int)` (jdtls) and `beta` name the same method. */ +export const baseName = (name: string) => name.replace(/\s*[(<].*$/, ""); + +/** Methods named `name` directly inside the type declared at `item` (its outline node found by position). */ +export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] { + const at = item.selectionRange.start; + const find = (items: readonly OutlineItem[]): OutlineItem | null => { + for (const i of items) { + if (TYPES.has(i.kind) && i.selection_range.start.line === at.line) return i; + const deeper = inside(i, at) ? find(i.children) : null; + if (deeper) return deeper; + } + return null; + }; + const type = find(outline); + const bare = baseName(name); + return type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : []; +} + +export interface SuperDeps { + supertypes: (path: string, pos: Pos) => Promise; + outline: (path: string) => Promise; +} + +/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */ +export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise { + let level: Array<{ path: string; pos: Pos }> = [{ path, pos: typePos }]; + const seen = new Set(); + for (let d = 0; d < depth && level.length; d++) { + const next: Array<{ path: string; pos: Pos }> = []; + const found: Location[] = []; + for (const t of level) { + const supers = await deps.supertypes(t.path, t.pos).catch(() => [] as HierarchyItem[]); + for (const s of supers) { + const key = `${s.path}:${s.selectionRange.start.line}`; + if (seen.has(key)) continue; + seen.add(key); + const methods = methodsInType(await deps.outline(s.path).catch(() => [] as OutlineItem[]), s, method); + if (methods.length) for (const m of methods) found.push({ path: s.path, range: m.selection_range }); + else next.push({ path: s.path, pos: s.selectionRange.start }); + } + } + if (found.length) return found; + level = next; + } + return []; +} diff --git a/src/editor/navigation/testNaming.test.ts b/src/editor/navigation/testNaming.test.ts new file mode 100644 index 00000000..32eb7b31 --- /dev/null +++ b/src/editor/navigation/testNaming.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { isTestName, newTestName, rankCandidates, rustTestModuleOffset, subjectNamesFor, testNamesFor } from "./testNaming"; + +describe("test naming conventions (Go to Test)", () => { + it.each([ + ["Foo.java", ["FooTest.java", "FooTests.java", "TestFoo.java", "FooIT.java", "FooSpec.java"]], + ["foo.go", ["foo_test.go"]], + ["foo.py", ["test_foo.py", "foo_test.py"]], + ["foo.ts", ["foo.test.ts", "foo.spec.ts"]], + ["Button.tsx", ["Button.test.tsx", "Button.spec.tsx"]], + ["lexer.rs", ["lexer.rs", "lexer_test.rs", "lexer_tests.rs"]], + ["user.rb", ["user_spec.rb", "user_test.rb"]], + ])("%s → tests", (name, tests) => { + expect(testNamesFor(name)).toEqual(tests); + }); + + it.each([ + ["FooTest.java", ["Foo.java"]], + ["FooTests.kt", ["Foo.kt"]], + ["TestFoo.java", ["Foo.java"]], + ["foo_test.go", ["foo.go"]], + ["test_foo.py", ["foo.py"]], + ["foo_test.py", ["foo.py"]], + ["lexer.rs", ["lexer.rs"]], + ])("%s → subjects", (name, subjects) => { + expect(subjectNamesFor(name)).toEqual(subjects); + }); + + it("JS/TS tests may test any script extension", () => { + expect(subjectNamesFor("foo.spec.ts").slice(0, 3)).toEqual(["foo.ts", "foo.tsx", "foo.js"]); + }); + + it("recognises test file names", () => { + for (const n of ["FooTest.java", "TestFoo.java", "FooTests.cs", "foo_test.go", "test_foo.py", "foo.test.tsx", "foo.spec.js", "foo_spec.rb", "bar_test.cc"]) + expect(isTestName(n), n).toBe(true); + for (const n of ["Foo.java", "Test.java", "Testing.java", "foo.go", "foo.py", "foo.ts", "contest.py", "latest.ts"]) expect(isTestName(n), n).toBe(false); + expect(newTestName("Foo.java")).toBe("FooTest.java"); + }); + + it("ranks the mirrored package first, then the closest file", () => { + const below = (p: string) => p.replace(/^\/p\/(?:a|b)\/src\/(?:main|test)\/java\//, ""); + const ranked = rankCandidates( + "/p/a/src/main/java/com/x/Foo.java", + ["/p/b/src/test/java/com/y/FooTest.java", "/p/a/src/test/java/com/z/FooTest.java", "/p/a/src/test/java/com/x/FooTest.java"], + below, + ); + expect(ranked).toEqual([ + "/p/a/src/test/java/com/x/FooTest.java", + "/p/a/src/test/java/com/z/FooTest.java", + "/p/b/src/test/java/com/y/FooTest.java", + ]); + }); + + it("finds a Rust #[cfg(test)] module", () => { + const text = "fn a() {}\n\n#[cfg(test)]\nmod tests {\n}\n"; + expect(rustTestModuleOffset(text)).toBe(text.indexOf("#[cfg")); + expect(rustTestModuleOffset("fn a() {}")).toBeNull(); + }); +}); diff --git a/src/editor/navigation/testNaming.ts b/src/editor/navigation/testNaming.ts new file mode 100644 index 00000000..f33849ad --- /dev/null +++ b/src/editor/navigation/testNaming.ts @@ -0,0 +1,106 @@ +/** + * Go to Test / Go to Test Subject (⇧⌘T) naming conventions, per language: + * JVM / .NET `FooTest`, `FooTests`, `TestFoo`; Go `foo_test.go`; Python + * `test_foo.py`, `foo_test.py`; JS/TS `foo.test.ts`, `foo.spec.ts`; Ruby + * `foo_spec.rb`, `foo_test.rb`; C/C++ `foo_test.cc`, `test_foo.c`; Rust + * integration tests `tests/foo.rs` (unit tests live in the file's + * `#[cfg(test)] mod`). Pure: no IPC. + */ + +export interface SplitName { + stem: string; + /** Extension(s) after the stem, with the dot: `.java`, `.test.ts`. */ + ext: string; +} + +const JS_EXT = /\.(?:[cm]?[jt]sx?)$/; +const JVM_EXT = /\.(java|kt|kts|scala|groovy|cs|fs|vb|swift|dart|php)$/; +const C_EXT = /\.(c|cc|cpp|cxx|h|hpp|m|mm)$/; + +export function splitName(name: string): SplitName { + const dot = name.lastIndexOf("."); + return dot > 0 ? { stem: name.slice(0, dot), ext: name.slice(dot) } : { stem: name, ext: "" }; +} + +/** Whether `name` looks like a test by name alone (the test roots are checked separately). */ +export function isTestName(name: string): boolean { + const { stem, ext } = splitName(name); + if (/^\.(test|spec)$/.test(splitName(stem).ext) && JS_EXT.test(ext)) return true; + if (ext === ".go") return stem.endsWith("_test"); + if (ext === ".py") return stem.startsWith("test_") || stem.endsWith("_test") || stem === "conftest"; + if (ext === ".rb") return stem.endsWith("_spec") || stem.endsWith("_test"); + if (JVM_EXT.test(name)) return /^Test[A-Z0-9_]/.test(stem) || (/Tests?$/.test(stem) && stem !== "Test" && stem !== "Tests"); + if (C_EXT.test(name)) return stem.startsWith("test_") || stem.endsWith("_test") || stem.endsWith("_unittest"); + return false; +} + +/** File names a test of `name` may have, most conventional first. */ +export function testNamesFor(name: string): string[] { + const { stem, ext } = splitName(name); + if (JS_EXT.test(name)) return [`${stem}.test${ext}`, `${stem}.spec${ext}`]; + if (ext === ".go") return [`${stem}_test.go`]; + if (ext === ".py") return [`test_${stem}.py`, `${stem}_test.py`]; + if (ext === ".rb") return [`${stem}_spec.rb`, `${stem}_test.rb`]; + if (ext === ".rs") return [`${stem}.rs`, `${stem}_test.rs`, `${stem}_tests.rs`]; + if (JVM_EXT.test(name)) return [`${stem}Test${ext}`, `${stem}Tests${ext}`, `Test${stem}${ext}`, `${stem}IT${ext}`, `${stem}Spec${ext}`]; + if (C_EXT.test(name)) return [`${stem}_test${ext}`, `test_${stem}${ext}`, `${stem}_unittest${ext}`]; + return [`${stem}_test${ext}`, `test_${stem}${ext}`]; +} + +/** File names the subject of test `name` may have. */ +export function subjectNamesFor(name: string): string[] { + const { stem, ext } = splitName(name); + const inner = splitName(stem); + if (JS_EXT.test(ext) && /^\.(test|spec)$/.test(inner.ext)) { + const base = inner.stem; + // `foo.test.ts` may test `foo.tsx` / `foo.js` too. + return [...new Set([`${base}${ext}`, ...[".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"].map((e) => `${base}${e}`)])]; + } + const out: string[] = []; + const strip = (s: string) => out.push(`${s}${ext}`); + if (ext === ".go" || ext === ".py" || ext === ".rb" || ext === ".rs" || C_EXT.test(name)) { + for (const suffix of ["_test", "_tests", "_spec", "_unittest"]) if (stem.endsWith(suffix) && stem.length > suffix.length) strip(stem.slice(0, -suffix.length)); + if (stem.startsWith("test_") && stem.length > 5) strip(stem.slice(5)); + // Rust integration tests (`tests/foo.rs`) are named like their subject. + if (ext === ".rs") strip(stem); + return [...new Set(out)]; + } + for (const suffix of ["Tests", "Test", "IT", "Spec"]) if (stem.endsWith(suffix) && stem.length > suffix.length) strip(stem.slice(0, -suffix.length)); + if (/^Test[A-Z0-9_]/.test(stem)) strip(stem.slice(4)); + return [...new Set(out)]; +} + +/** Common leading path segments of two `/`-separated paths. */ +function sharedPrefix(a: string, b: string): number { + const x = a.split("/"); + const y = b.split("/"); + let n = 0; + while (n < x.length && n < y.length && x[n] === y[n]) n++; + return n; +} + +/** + * Orders candidate paths for `from`: the mirrored package (same path below + * its root) first, then the closest in the tree. + */ +export function rankCandidates(from: string, candidates: readonly string[], relBelowRoot: (path: string) => string | null): string[] { + const own = relBelowRoot(from); + const ownDir = own === null ? null : own.slice(0, Math.max(own.lastIndexOf("/"), 0)); + const score = (p: string) => { + const rel = relBelowRoot(p); + const dir = rel === null ? null : rel.slice(0, Math.max(rel.lastIndexOf("/"), 0)); + return (ownDir !== null && dir === ownDir ? 1000 : 0) + sharedPrefix(from, p); + }; + return [...candidates].sort((a, b) => score(b) - score(a) || (a < b ? -1 : a > b ? 1 : 0)); +} + +/** Offset of the `#[cfg(test)]` module of a Rust file, if any. */ +export function rustTestModuleOffset(text: string): number | null { + const m = /#\[cfg\(test\)\]\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+/.exec(text); + return m ? m.index : null; +} + +/** The name a new test of `name` gets (without directory), by convention. */ +export function newTestName(name: string): string { + return testNamesFor(name)[0]!; +} diff --git a/src/editor/refactor/catalog.ts b/src/editor/refactor/catalog.ts new file mode 100644 index 00000000..c23d785a --- /dev/null +++ b/src/editor/refactor/catalog.ts @@ -0,0 +1,155 @@ +/** + * IDEA's refactorings and how language-server code actions map onto them: + * `refactor.extract.*` → Extract Method / Introduce Variable…, `refactor.inline*` + * → Inline, `refactor.move*` → Move, signature rewrites → Change Signature. + * Pure; used by Refactor This (⌃T) and the Refactor menu actions. + */ +import type { CodeAction } from "@/bindings"; + +export type RefactoringId = + | "rename" + | "changeSignature" + | "move" + | "safeDelete" + | "extractMethod" + | "extractVariable" + | "extractConstant" + | "extractField" + | "extractParameter" + | "inline"; + +export interface Refactoring { + id: RefactoringId; + /** Action id (keymaps, menu). */ + action: string; + label: string; + /** LSP kind prefix that can produce it; null for client-side refactorings. */ + kind: string | null; + /** IDEA's reason when nothing applies at the caret / selection. */ + reason: (selection: boolean) => string; +} + +const AT_NAME = "Caret should be positioned at the name of the element to be refactored"; + +/** IDEA's Refactor This order. */ +export const REFACTORINGS: readonly Refactoring[] = [ + { id: "rename", action: "refactor.rename", label: "Rename…", kind: null, reason: () => "Caret should be on a symbol" }, + { + id: "changeSignature", + action: "refactor.changeSignature", + label: "Change Signature…", + kind: "refactor.rewrite", + reason: () => "Caret should be positioned at the name of the function or method to be refactored", + }, + { id: "move", action: "refactor.move", label: "Move…", kind: "refactor.move", reason: () => AT_NAME }, + { id: "safeDelete", action: "refactor.safeDelete", label: "Safe Delete…", kind: null, reason: () => "Caret should be on a symbol" }, + { + id: "extractMethod", + action: "refactor.extractMethod", + label: "Extract Method…", + kind: "refactor.extract", + reason: (sel) => + sel ? "Selected block should represent a set of statements or an expression" : "Select the code to extract", + }, + { + id: "extractVariable", + action: "refactor.extractVariable", + label: "Introduce Variable…", + kind: "refactor.extract", + reason: (sel) => (sel ? "Selected block should represent an expression" : "Caret should be inside an expression"), + }, + { + id: "extractConstant", + action: "refactor.extractConstant", + label: "Introduce Constant…", + kind: "refactor.extract", + reason: (sel) => (sel ? "Selected block should represent an expression" : "Caret should be inside an expression"), + }, + { + id: "extractField", + action: "refactor.extractField", + label: "Introduce Field…", + kind: "refactor.extract", + reason: (sel) => + sel ? "Selected block should represent an expression" : "Caret should be inside an expression in a class", + }, + { + id: "extractParameter", + action: "refactor.extractParameter", + label: "Introduce Parameter…", + kind: "refactor.extract", + reason: (sel) => + sel ? "Selected block should represent an expression" : "Caret should be inside an expression in a function", + }, + { id: "inline", action: "refactor.inline", label: "Inline…", kind: "refactor.inline", reason: () => AT_NAME }, +]; + +export function refactoring(id: RefactoringId): Refactoring { + return REFACTORINGS.find((r) => r.id === id)!; +} + +/** IDEA's hint text for a refactoring that cannot run. */ +export function cannotPerform(reason: string): string { + return `Cannot perform refactoring. ${reason.replace(/\.$/, "")}`; +} + +const kindIs = (kind: string, prefix: string) => kind === prefix || kind.startsWith(`${prefix}.`); + +/** + * The IDEA refactoring a server code action implements, or null (e.g. "Convert + * to arrow function"). Some servers (typescript-language-server) report every + * refactoring as plain `refactor`, so titles decide when the kind is generic. + */ +export function classifyAction(action: Pick): RefactoringId | null { + const kind = action.kind ?? ""; + const title = action.title; + if (!kindIs(kind, "refactor")) return null; + const generic = kind === "refactor" || kindIs(kind, "refactor.rewrite"); + if (kindIs(kind, "refactor.extract") || (generic && /^(extract|introduce)\b/i.test(title))) { + const sub = kind.slice("refactor.extract".length); + const is = (re: RegExp) => re.test(sub) || re.test(title); + if (/^(extract|introduce)( (in)?to)?( an?)? (type\b|typedef|interface|struct|enum|trait|superclass|module)/i.test(title)) return null; + if (is(/function|method/i)) return "extractMethod"; + if (is(/field|member|property/i)) return "extractField"; + if (is(/parameter|argument/i)) return "extractParameter"; + // TypeScript: "Extract to constant in enclosing scope" is a local (IDEA's Introduce Variable). + if (/enclosing scope/i.test(title)) return "extractVariable"; + if (is(/constant|static/i)) return "extractConstant"; + if (is(/variable|local|\blet\b/i)) return "extractVariable"; + return null; + } + if (kindIs(kind, "refactor.inline") || (generic && /^inline\b/i.test(title))) return "inline"; + if (kindIs(kind, "refactor.move") || (generic && /^move\b/i.test(title))) return "move"; + if (/signature/i.test(kind) || /\bchange signature\b|\breorder (parameters|arguments)\b/i.test(title)) return "changeSignature"; + return null; +} + +/** + * Whether servers advertising `advertised` (`codeActionProvider.codeActionKinds`) + * could offer `kind`: a listed kind equal to it, a parent of it (`refactor` + * covers `refactor.move`) or a child of it. Unknown (`null`) means maybe. + */ +export function kindAdvertised(advertised: readonly string[] | null, kind: string): boolean { + if (!advertised) return true; + return advertised.some((k) => k !== "" && (kindIs(kind, k) || kindIs(k, kind))); +} + +/** "rust" → "Rust" when the language list is not at hand. */ +export function fallbackLanguageName(id: string): string { + const known: Record = { + typescript: "TypeScript", + javascript: "JavaScript", + typescriptreact: "TypeScript JSX", + javascriptreact: "JavaScript JSX", + cpp: "C++", + csharp: "C#", + php: "PHP", + css: "CSS", + html: "HTML", + json: "JSON", + yaml: "YAML", + toml: "TOML", + sql: "SQL", + }; + return known[id] ?? (id ? id[0]!.toUpperCase() + id.slice(1) : id); +} diff --git a/src/editor/refactor/index.ts b/src/editor/refactor/index.ts new file mode 100644 index 00000000..05e7775d --- /dev/null +++ b/src/editor/refactor/index.ts @@ -0,0 +1,8 @@ +/** + * Refactorings: Refactor This and the Refactor menu over LSP code actions, + * Safe Delete, and the syntax-based fallbacks used when the language server + * is not usable (see `@/editor/structure`). + */ +export { introduceFallback, type FallbackReason, type IntroduceKind } from "./introduce"; +export { refactorThis, runRefactoring } from "./refactorThis"; +export { askUsagesDetected, fileUsages, safeDelete, viewUsages, type FileUsages, type UsagesAnswer } from "./safeDelete"; diff --git a/src/editor/refactor/introduce.test.ts b/src/editor/refactor/introduce.test.ts new file mode 100644 index 00000000..04f6bfd8 --- /dev/null +++ b/src/editor/refactor/introduce.test.ts @@ -0,0 +1,575 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { history, undo } from "@codemirror/commands"; +import type { ExpressionAt, NodeAt, Range, StructuralRanges, SyntaxNode } from "@/bindings"; + +// --- a hand-built syntax tree over the test document ---------------------------- + +interface FakeNode { + kind: string; + from: number; + to: number; + field: string | null; + parent: FakeNode | null; + error?: boolean; +} + +let text = ""; +let nodes: FakeNode[] = []; +let expressionAt: (range: Range) => ExpressionAt = () => { + throw new Error("no expressionAt"); +}; +let errors: Range[] = []; +let structural: StructuralRanges | null = null; + +const posOf = (off: number) => { + const line = Text.of(text.split("\n")).lineAt(off); + return { line: line.number - 1, col: off - line.from }; +}; +const offOf = (p: { line: number; col: number }) => Text.of(text.split("\n")).line(p.line + 1).from + p.col; +const rangeOf = (from: number, to: number): Range => ({ start: posOf(from), end: posOf(to) }); + +function toSyntax(n: FakeNode): SyntaxNode { + return { + kind: n.kind, + range: rangeOf(n.from, n.to), + field: n.field, + named: true, + error: !!n.error, + missing: false, + hasError: !!n.error, + }; +} + +/** + * Nodes outer → inner: `[kind, needle, nth?, field?]`; `needle` locates the + * node's text (nth occurrence, default 0). Parents are the innermost earlier + * node containing it. + */ +function tree(specs: Array<[string, string, number?, string?]>) { + nodes = []; + for (const [kind, needle, nth = 0, field = null] of specs) { + let at = -1; + for (let i = 0; i <= nth; i++) at = text.indexOf(needle, at + 1); + if (at < 0) throw new Error(`no ${needle}`); + const node: FakeNode = { kind, from: at, to: at + needle.length, field, parent: null }; + node.parent = [...nodes].reverse().find((n) => n.from <= node.from && n.to >= node.to) ?? null; + nodes.push(node); + } +} + +const find = (kind: string, nth = 0) => nodes.filter((n) => n.kind === kind)[nth]!; +const syn = (kind: string, nth = 0) => toSyntax(find(kind, nth)); + +function nodeAt(range: Range): NodeAt | null { + const from = offOf(range.start); + const to = offOf(range.end); + let best: FakeNode | null = null; + for (const n of nodes) if (n.from <= from && n.to >= to && (!best || n.to - n.from <= best.to - best.from)) best = n; + if (!best) return null; + const ancestors: SyntaxNode[] = []; + for (let p = best.parent; p; p = p.parent) ancestors.push(toSyntax(p)); + return { node: toSyntax(best), ancestors }; +} + +const structureMock = { + support: vi.fn(async () => ({ tree: true, locals: false })), + expressionAt: vi.fn(async (_doc: number, range: Range) => expressionAt(range)), + syntaxErrors: vi.fn(async () => ({ + errors: errors.map((range) => ({ kind: "missing", range, expected: ";", message: "missing `;`" })), + truncated: false, + })), + nodeAt: vi.fn(async (_doc: number, range: Range) => nodeAt(range)), + identifiers: vi.fn(async (_doc: number, name: string) => + [...text.matchAll(new RegExp(`(? ({ + range: rangeOf(m.index!, m.index! + name.length), + kind: "identifier", + qualified: text[m.index! - 1] === ".", + })), + ), + locals: vi.fn(async () => ({ status: "noIdentifier" as const })), + structuralRanges: vi.fn(async () => structural!), +}; + +vi.mock("@/editor/structure", () => ({ structure: structureMock })); + +const files: Record = {}; +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + commands: { + ...actual.commands, + fsReadText: async (path: string) => + path in files + ? { status: "ok", data: files[path] } + : { status: "error", error: { kind: "notFound", message: path } }, + lspHover: async () => ({ status: "error", error: { kind: "unavailable", message: "no server" } }), + }, + }; +}); +vi.mock("@/platform/project", () => ({ useProject: { getState: () => ({ project: { root: "/p" } }) } })); +const showHint = vi.fn(); +vi.mock("@/editor/lsp/hint", () => ({ showHint })); + +const { introduceFallback } = await import("./introduce"); +const { useLspPopup } = await import("@/editor/lsp/popupStore"); +const { lspDoc } = await import("@/editor/lsp/lsp"); +const { clearLevelCache } = await import("./projectLevel"); + +// --- editor --------------------------------------------------------------------- + +// jsdom has no layout; popups anchor at caret coordinates. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +let view: EditorView | null = null; + +function editor(doc: string, language: string, path: string, sel: { anchor: number; head?: number }) { + text = doc; + view = new EditorView({ + state: EditorState.create({ + doc, + selection: sel, + extensions: [history(), lspDoc.of({ doc: 1, path, language, uiMode: null, readOnly: false })], + }), + }); + return view; +} + +const popup = () => useLspPopup.getState().popup; + +async function pick(id: string) { + const p = popup(); + if (p?.kind !== "list") throw new Error(`no list popup: ${JSON.stringify(p)}`); + useLspPopup.setState({ popup: null }); + await p.items.find((i) => i.id === id)!.run(); + await new Promise((r) => setTimeout(r, 0)); +} + +function submitName(name: string) { + const p = popup(); + if (p?.kind !== "rename") throw new Error("no name popup"); + useLspPopup.setState({ popup: null }); + p.onSubmit(name); +} + +beforeEach(() => { + errors = []; + structural = null; + clearLevelCache(); + for (const k of Object.keys(files)) delete files[k]; +}); + +afterEach(() => { + view?.destroy(); + view = null; + useLspPopup.setState({ popup: null }); + showHint.mockClear(); +}); + +describe("Introduce Variable fallback", () => { + const javaSrc = 'class A {\n void f() {\n new String("123")\n g();\n }\n}\n'; + + function javaTree() { + tree([ + ["program", javaSrc], + ["class_declaration", javaSrc.trimEnd()], + ["class_body", '{\n void f() {\n new String("123")\n g();\n }\n}', 0, "body"], + ["method_declaration", 'void f() {\n new String("123")\n g();\n }'], + ["block", '{\n new String("123")\n g();\n }', 0, "body"], + ["expression_statement", 'new String("123")'], + ["object_creation_expression", 'new String("123")'], + ["string_literal", '"123"'], + ]); + const e = syn("object_creation_expression"); + expressionAt = () => ({ + expression: e, + exact: true, + candidates: [e], + statement: syn("expression_statement"), + block: syn("block"), + functionBoundary: null, + hasError: true, + }); + } + + it("replaces a bare expression statement with the declaration (Java 10+: var) as one undo step", async () => { + files["/p/pom.xml"] = "17"; + const from = javaSrc.indexOf("new String"); + const v = editor(javaSrc, "java", "/p/src/A.java", { anchor: from, head: from + 'new String("123")'.length }); + javaTree(); + expect(await introduceFallback(v, "variable", "notReady")).toBe(true); + expect(v.state.doc.toString()).toBe( + 'class A {\n void f() {\n var string = new String("123");\n g();\n }\n}\n', + ); + const p = popup(); + expect(p).toMatchObject({ kind: "rename", initial: "string", note: "Based on syntax (language server not ready)" }); + submitName("text"); + expect(v.state.doc.toString()).toContain(' var text = new String("123");\n'); + undo(v); + expect(v.state.doc.toString()).toBe(javaSrc); + }); + + it("uses the constructed type below Java 10 and refuses when the type is unknown", async () => { + files["/p/pom.xml"] = "1.8"; + const from = javaSrc.indexOf("new String"); + const v = editor(javaSrc, "java", "/p/src/A.java", { anchor: from, head: from + 'new String("123")'.length }); + javaTree(); + await introduceFallback(v, "variable", "notReady"); + expect(v.state.doc.toString()).toContain(' String string = new String("123");\n'); + + const src = "class A {\n void f() {\n g(h());\n }\n}\n"; + view!.destroy(); + const at = src.indexOf("h()"); + const w = editor(src, "java", "/p/src/A.java", { anchor: at, head: at + 3 }); + tree([ + ["program", src], + ["block", "{\n g(h());\n }"], + ["expression_statement", "g(h());"], + ["method_invocation", "g(h())"], + ["method_invocation", "h()"], + ]); + const e = syn("method_invocation", 1); + expressionAt = () => ({ + expression: e, + exact: true, + candidates: [e], + statement: syn("expression_statement"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + expect(await introduceFallback(w, "variable", "notReady")).toBe(true); + expect(w.state.doc.toString()).toBe(src); + expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/type of the expression is unknown/); + }); + + const tsSrc = "function f(o) {\n const a = o.getUser().name;\n log(o.getUser());\n}\n"; + + function tsTree() { + tree([ + ["program", tsSrc], + ["function_declaration", tsSrc.trimEnd()], + ["statement_block", "{\n const a = o.getUser().name;\n log(o.getUser());\n}", 0, "body"], + ["lexical_declaration", "const a = o.getUser().name;"], + ["member_expression", "o.getUser().name", 0, "value"], + ["call_expression", "o.getUser()", 0, "object"], + ["expression_statement", "log(o.getUser());"], + ["call_expression", "log(o.getUser())"], + ["arguments", "(o.getUser())", 0, "arguments"], + ["call_expression", "o.getUser()", 1], + ]); + const call = syn("call_expression", 0); + expressionAt = () => ({ + expression: call, + exact: false, + candidates: [call, syn("member_expression")], + statement: syn("lexical_declaration"), + block: syn("statement_block"), + functionBoundary: null, + hasError: false, + }); + } + + it("offers expressions at the caret, then all occurrences, and names after the callee", async () => { + const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: tsSrc.indexOf("getUser") + 2 }); + tsTree(); + expect(await introduceFallback(v, "variable", "notReady")).toBe(true); + const chooser = popup(); + expect(chooser).toMatchObject({ kind: "list", title: "Expressions" }); + expect(chooser?.kind === "list" && chooser.items.map((i) => i.label)).toEqual(["o.getUser()", "o.getUser().name"]); + await pick("0"); + expect(popup()).toMatchObject({ kind: "list", title: "Multiple occurrences found" }); + expect( + popup()?.kind === "list" && + popup()!.kind === "list" && + (popup() as { items: { label: string }[] }).items.map((i) => i.label), + ).toEqual(["Replace this occurrence only", "Replace all 2 occurrences"]); + await pick("all"); + expect(v.state.doc.toString()).toBe( + "function f(o) {\n const user = o.getUser();\n const a = user.name;\n log(user);\n}\n", + ); + expect(popup()).toMatchObject({ kind: "rename", initial: "user", suggestions: ["user", "value"] }); + // Escape keeps the default name. + useLspPopup.setState({ popup: null }); + undo(v); + expect(v.state.doc.toString()).toBe(tsSrc); + }); + + it("replaces only the selected occurrence on request", async () => { + const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: tsSrc.indexOf("getUser") + 2 }); + tsTree(); + await introduceFallback(v, "variable", "notReady"); + await pick("0"); + await pick("one"); + expect(v.state.doc.toString()).toBe( + "function f(o) {\n const user = o.getUser();\n const a = user.name;\n log(o.getUser());\n}\n", + ); + }); + + it("respects an empty server answer unless the code there is broken", async () => { + const at = tsSrc.indexOf("o.getUser()"); + const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: at, head: at + "o.getUser()".length }); + tsTree(); + const call = syn("call_expression", 0); + expressionAt = () => ({ + expression: call, + exact: true, + candidates: [call], + statement: syn("lexical_declaration"), + block: syn("statement_block"), + functionBoundary: null, + hasError: false, + }); + expect(await introduceFallback(v, "variable", "empty")).toBe(false); + expect(v.state.doc.toString()).toBe(tsSrc); + errors = [rangeOf(at + 5, at + 5)]; + expect(await introduceFallback(v, "variable", "empty")).toBe(true); + await pick("one"); + expect(v.state.doc.toString()).toContain("const user = o.getUser();"); + expect(popup()).toMatchObject({ note: "Based on syntax (the code has errors)" }); + }); + + it("refuses selections that are not an expression, lambda bodies and loop conditions", async () => { + const src = "function f(xs) {\n const ys = xs.map((x) => x * 2);\n while (next(xs)) {}\n}\n"; + const v = editor(src, "typescript", "/p/a.ts", { anchor: src.indexOf("x * 2"), head: src.indexOf("x * 2") + 3 }); + tree([ + ["program", src], + ["statement_block", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1)], + ["lexical_declaration", "const ys = xs.map((x) => x * 2);"], + ["arrow_function", "(x) => x * 2"], + ["binary_expression", "x * 2", 0, "body"], + ["while_statement", "while (next(xs)) {}"], + ["parenthesized_expression", "(next(xs))", 0, "condition"], + ["call_expression", "next(xs)"], + ]); + expressionAt = () => ({ + expression: syn("binary_expression"), + exact: false, + candidates: [], + statement: syn("lexical_declaration"), + block: syn("statement_block"), + functionBoundary: null, + hasError: false, + }); + await introduceFallback(v, "variable", "notReady"); + expect(showHint.mock.calls.at(-1)?.[1]).toBe( + "Cannot perform refactoring: selected block should represent an expression", + ); + + const bin = syn("binary_expression"); + expressionAt = () => ({ + expression: bin, + exact: true, + candidates: [bin], + statement: syn("lexical_declaration"), + block: syn("statement_block"), + functionBoundary: syn("arrow_function"), + hasError: false, + }); + await introduceFallback(v, "variable", "notReady"); + expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/lambda with an expression body/); + + const call = syn("call_expression"); + expressionAt = () => ({ + expression: call, + exact: true, + candidates: [call], + statement: syn("while_statement"), + block: syn("statement_block"), + functionBoundary: null, + hasError: false, + }); + await introduceFallback(v, "variable", "notReady"); + expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/evaluated conditionally or repeatedly/); + expect(v.state.doc.toString()).toBe(src); + }); + + it("matches occurrences through same-range wrappers and drops callee candidates (Kotlin)", async () => { + const src = "fun f(o: O) {\n val t = o.total() + 1\n println(o.total())\n}\n"; + const v = editor(src, "kotlin", "/p/a.kt", { anchor: src.indexOf("total") + 1 }); + tree([ + ["source_file", src], + ["block", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1)], + ["property_declaration", "val t = o.total() + 1"], + ["binary_expression", "o.total() + 1"], + ["call_expression", "o.total()"], + ["navigation_expression", "o.total"], + ["call_expression", "println(o.total())"], + ["value_arguments", "(o.total())"], + ["value_argument", "o.total()", 1], + ["call_expression", "o.total()", 1], + ]); + const nav = syn("navigation_expression"); + const call = syn("call_expression"); + expressionAt = () => ({ + expression: nav, + exact: false, + candidates: [nav, call, syn("binary_expression")], + statement: syn("property_declaration"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + await introduceFallback(v, "variable", "notReady"); + const chooser = popup(); + expect(chooser?.kind === "list" && chooser.items.map((i) => i.label)).toEqual(["o.total()", "o.total() + 1"]); + await pick("0"); + await pick("all"); + expect(v.state.doc.toString()).toBe( + "fun f(o: O) {\n val total = o.total()\n val t = total + 1\n println(total)\n}\n", + ); + }); + + it("replaces a bare statement whose missing `;` let the parser swallow the next line", async () => { + files["/p/pom.xml"] = "21"; + const src = 'class A {\n void f() {\n new String("1")\n System.out.println();\n }\n}\n'; + const at = src.indexOf("new String"); + const v = editor(src, "java", "/p/A.java", { anchor: at, head: at + 'new String("1")'.length }); + tree([ + ["program", src], + ["block", src.slice(src.indexOf("{\n new"), src.lastIndexOf("}\n}") + 1)], + ["expression_statement", 'new String("1")\n System'], + ["object_creation_expression", 'new String("1")'], + ]); + const e = syn("object_creation_expression"); + const stmt = { ...syn("expression_statement"), hasError: true }; + expressionAt = () => ({ + expression: e, + exact: true, + candidates: [e], + statement: stmt, + block: syn("block"), + functionBoundary: null, + hasError: true, + }); + await introduceFallback(v, "variable", "empty"); + expect(v.state.doc.toString()).toBe( + 'class A {\n void f() {\n var string = new String("1");\n System.out.println();\n }\n}\n', + ); + }); + + it("inserts before a statement that does not start its line", async () => { + const src = "def f(c, x):\n if c: g(x + 1)\n"; + const at = src.indexOf("x + 1"); + const v = editor(src, "python", "/p/a.py", { anchor: at, head: at + 5 }); + tree([ + ["module", src], + ["if_statement", "if c: g(x + 1)"], + ["block", "g(x + 1)", 0, "consequence"], + ["expression_statement", "g(x + 1)"], + ["call", "g(x + 1)"], + ["binary_operator", "x + 1"], + ]); + const e = syn("binary_operator"); + expressionAt = () => ({ + expression: e, + exact: true, + candidates: [e], + statement: syn("expression_statement"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + await introduceFallback(v, "variable", "notReady"); + expect(v.state.doc.toString()).toBe("def f(c, x):\n if c: value = x + 1; g(value)\n"); + }); +}); + +describe("Introduce Constant fallback", () => { + it("adds a typed Java constant before the member, after its comments", async () => { + const src = "class A {\n // doc\n void f() { h(42); k(42); }\n}\n"; + const at = src.indexOf("42"); + const v = editor(src, "java", "/p/A.java", { anchor: at, head: at + 2 }); + tree([ + ["program", src], + ["class_declaration", src.trimEnd()], + ["class_body", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1), 0, "body"], + ["method_declaration", "void f() { h(42); k(42); }"], + ["block", "{ h(42); k(42); }", 0, "body"], + ["expression_statement", "h(42);"], + ["method_invocation", "h(42)"], + ["decimal_integer_literal", "42"], + ["expression_statement", "k(42);"], + ["method_invocation", "k(42)"], + ["decimal_integer_literal", "42", 1], + ]); + const lit = syn("decimal_integer_literal"); + expressionAt = () => ({ + expression: lit, + exact: true, + candidates: [lit], + statement: syn("expression_statement"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + structural = { + selection: [], + statement: null, + prevStatement: null, + nextStatement: null, + block: null, + declaration: syn("method_declaration"), + prevDeclaration: null, + nextDeclaration: null, + }; + await introduceFallback(v, "constant", "error"); + await pick("all"); + expect(v.state.doc.toString()).toBe( + "class A {\n private static final int I = 42;\n\n // doc\n void f() { h(I); k(I); }\n}\n", + ); + expect(popup()).toMatchObject({ kind: "rename", initial: "I", note: "Based on syntax (language server failed)" }); + submitName("ANSWER"); + expect(v.state.doc.toString()).toContain("private static final int ANSWER = 42;"); + expect(v.state.doc.toString()).toContain("h(ANSWER); k(ANSWER);"); + }); + + it("adds a module-level Python constant and refuses non-literals", async () => { + const src = 'import os\n\ndef f():\n return g("max retries")\n'; + const at = src.indexOf('"max'); + const v = editor(src, "python", "/p/a.py", { anchor: at, head: at + '"max retries"'.length }); + tree([ + ["module", src], + ["import_statement", "import os"], + ["function_definition", src.slice(src.indexOf("def"))], + ["block", 'return g("max retries")', 0, "body"], + ["return_statement", 'return g("max retries")'], + ["call", 'g("max retries")'], + ["string", '"max retries"'], + ]); + const lit = syn("string"); + expressionAt = () => ({ + expression: lit, + exact: true, + candidates: [lit], + statement: syn("return_statement"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + await introduceFallback(v, "constant", "notReady"); + expect(v.state.doc.toString()).toBe( + 'import os\n\nMAX_RETRIES = "max retries"\n\ndef f():\n return g(MAX_RETRIES)\n', + ); + + const call = syn("call"); + expressionAt = () => ({ + expression: call, + exact: true, + candidates: [call], + statement: syn("return_statement"), + block: syn("block"), + functionBoundary: null, + hasError: false, + }); + view!.dispatch({ + changes: { from: 0, to: view!.state.doc.length, insert: src }, + selection: { anchor: src.indexOf("g("), head: src.indexOf("g(") + 'g("max retries")'.length }, + }); + await introduceFallback(view!, "constant", "notReady"); + expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/only literal values/); + }); +}); diff --git a/src/editor/refactor/introduce.ts b/src/editor/refactor/introduce.ts new file mode 100644 index 00000000..3a45f616 --- /dev/null +++ b/src/editor/refactor/introduce.ts @@ -0,0 +1,683 @@ +/** + * Introduce Variable (⌥⌘V) / Introduce Constant (⌥⌘C) without the language + * server, from the tree-sitter structure service (IDEA's dumb mode). Called by + * the LSP actions only when the server is not usable, or answered nothing over + * code with syntax errors. Does only what is syntactically certain: refuses + * with a hint otherwise, asks when there is a choice (which expression, which + * occurrences), then lets the user pick the name. One undo step. + */ +import type { EditorView } from "@codemirror/view"; +import type { ChangeSet, ChangeSpec, EditorState, Text } from "@codemirror/state"; +import { redo, undo } from "@codemirror/commands"; +import type { NodeAt, Range, SyntaxNode } from "@/bindings"; +import { structure, type ExpressionAt } from "@/editor/structure"; +import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions"; +import { showHint } from "@/editor/lsp/hint"; +import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { expressionWords, findTextOccurrences, normalizeExpression } from "./occurrences"; +import { isIdentifier, isKeyword, suggestNames, uniqueNames } from "./names"; +import { cIsC23, javaLanguageLevel } from "./projectLevel"; +import { + constantDeclaration, + isLiteral, + isTsJs, + javaConstructedType, + javaTypeFromHover, + literalType, + replacesBareStatement, + variableDeclaration, + type DeclContext, + type DeclResult, + type Declaration, +} from "./templates"; + +/** Why the language server could not do it (see `withFallback`). */ +export type FallbackReason = "notReady" | "error" | "empty"; +export type IntroduceKind = "variable" | "constant"; + +const NOTES: Record = { + notReady: "Based on syntax (language server not ready)", + error: "Based on syntax (language server failed)", + empty: "Based on syntax (the code has errors)", +}; + +/** At most this many other occurrences are checked and offered. */ +const MAX_OCCURRENCES = 30; + +const FUNCTION_LIKE = /lambda|arrow_function|closure|function|func_literal|method|anonymous|class_body|object_creation/; +/** Ruby blocks are closures. */ +const RUBY_BLOCK = /^(block|do_block)$/; + +function functionLike(language: string, kind: string): boolean { + return FUNCTION_LIKE.test(kind) || (language === "ruby" && RUBY_BLOCK.test(kind)); +} +/** Fields of a sub-expression that is evaluated conditionally or repeatedly by its parent. */ +const CONDITIONAL_FIELDS = new Set(["consequence", "alternative", "body", "update", "increment"]); +const LOOP = /while|for|loop|do_statement|repeat|until/; +const BLOCKISH = + /^(block|chunk|statement_list|program|module|source_file|compound_statement|statement_block|body|do_block|statements|declaration_list|class_body|template_body)$/; + +interface Prepared { + view: EditorView; + ctx: EditorDocContext; + kind: IntroduceKind; + reason: FallbackReason; + /** Document the structure answers refer to. */ + doc: Text; + at: ExpressionAt; +} + +function nodeText(doc: Text, node: { range: Range }): string { + const { from, to } = rangeToOffsets(doc, node.range); + return doc.sliceString(from, to); +} + +function sameRange(a: Range, b: Range): boolean { + return ( + a.start.line === b.start.line && a.start.col === b.start.col && a.end.line === b.end.line && a.end.col === b.end.col + ); +} + +/** + * `[node, ...ancestors]` from `nodeAt`, and the index of the outermost node + * spanning exactly `range` and of `kind` (wrappers such as Kotlin's + * `value_argument` share the expression's range). + */ +function chainOf(n: NodeAt | null, range: Range, kind: string): { chain: SyntaxNode[]; self: number } | null { + if (!n) return null; + const chain = [n.node, ...n.ancestors]; + let self = -1; + chain.forEach((c, i) => { + if (sameRange(c.range, range) && c.kind === kind) self = i; + }); + return self < 0 ? null : { chain, self }; +} + +/** Assignment target / increment operand: replacing it by a variable changes meaning. */ +function isWritten(chain: SyntaxNode[], self: number): boolean { + for (let i = self; i <= self + 1 && i + 1 < chain.length; i++) { + const node = chain[i]!; + const parent = chain[i + 1]!; + if (node.field === "left" && /assign/.test(parent.kind)) return true; + if ( + /update_expression|increment|decrement|postfix_unary|prefix_unary/.test(parent.kind) && + /\+\+|--/.test(parent.kind + node.kind) + ) + return true; + if (/update_expression|inc_statement|dec_statement/.test(parent.kind)) return true; + if (node.kind !== "expression_list") break; + } + return false; +} + +/** The expression is a statement of its own (`foo();`, a Python docstring). */ +function isBareStatement(chain: SyntaxNode[], self: number, doc: Text): boolean { + const parent = chain[self + 1]; + if (!parent) return false; + if (BLOCKISH.test(parent.kind)) return true; + if (parent.kind !== "expression_statement") return false; + const node = chain[self]!; + const { to } = rangeToOffsets(doc, node.range); + const end = rangeToOffsets(doc, parent.range).to; + return /^\s*;?\s*$/.test(doc.sliceString(to, end)); +} + +/** Lines above `pos`'s line that belong to the declaration there (comments, annotations). */ +function declarationStart(doc: Text, pos: number): number { + let line = doc.lineAt(pos); + while (line.number > 1) { + const prev = doc.line(line.number - 1); + const t = prev.text.trim(); + if (!t || !/^(\/\/|\/\*|\*|#(?!include|define|if|!)|@)/.test(t)) break; + line = prev; + } + return line.from; +} + +function indentOf(doc: Text, pos: number): string { + return /^[ \t]*/.exec(doc.lineAt(pos).text)![0]; +} + +/** `o.get` in `o.get()`: a function reference, not a value worth extracting. */ +function isCallee(doc: Text, node: SyntaxNode, outer: SyntaxNode | undefined): boolean { + if (!outer || !/call|invocation/.test(outer.kind) || outer.range.start.line !== node.range.start.line) return false; + if (outer.range.start.col !== node.range.start.col) return false; + const end = rangeToOffsets(doc, node.range).to; + return /^\s*(\(|<|::<)/.test(doc.sliceString(end, end + 4)); +} + +function preview(text: string): string { + const one = text.replace(/\s+/g, " ").trim(); + return one.length > 80 ? `${one.slice(0, 77)}…` : one; +} + +/** + * Entry point from the action: true when it handled the request (refactored, + * asked, or explained why not); false to let the caller report the server's + * own answer. + */ +export async function introduceFallback( + view: EditorView, + kind: IntroduceKind, + reason: FallbackReason, +): Promise { + const before = view.state.doc; + try { + return await introduce(view, kind, reason); + } catch (e) { + if (!isCancelled(e)) console.debug("[refactor]", e); + return view.state.doc !== before; + } +} + +async function introduce(view: EditorView, kind: IntroduceKind, reason: FallbackReason): Promise { + const ctx = docOf(view); + if (!ctx) return false; + const support = await structure.support(ctx.language).catch(() => null); + if (!support?.tree) return false; + if (ctx.readOnly) { + showHint(view, "The file is read-only"); + return true; + } + await whenSynced(view); + const doc = view.state.doc; + const sel = view.state.selection.main; + const at = await structure.expressionAt(ctx.doc, offsetsToRange(doc, sel.from, sel.to)); + // A server that is up and answered nothing is respected unless the code there is broken. + if (reason === "empty" && !(await brokenAround(ctx, doc, sel.from, sel.to, at))) return false; + if (view.state.doc !== doc) return true; + + const p: Prepared = { view, ctx, kind, reason, doc, at }; + let candidates: SyntaxNode[]; + if (sel.empty) { + candidates = at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1])); + if (!candidates.length) return refuse(p, "no expression at the caret"); + } else { + if (!at.expression || !at.exact) return refuse(p, "selected block should represent an expression"); + candidates = [at.expression]; + } + if (candidates.length === 1) { + await perform(p, candidates[0]!); + return true; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, sel.head), + title: "Expressions", + items: candidates.map((c, i) => ({ id: `${i}`, label: preview(nodeText(doc, c)), run: () => perform(p, c) })), + }); + return true; +} + +async function brokenAround( + ctx: EditorDocContext, + doc: Text, + from: number, + to: number, + at: ExpressionAt, +): Promise { + if (at.hasError) return true; + const lineFrom = doc.lineAt(from).from; + const lineTo = doc.lineAt(to).to; + const { errors } = await structure.syntaxErrors(ctx.doc); + return errors.some((e) => { + const r = rangeToOffsets(doc, e.range); + return r.from <= lineTo && r.to >= lineFrom; + }); +} + +function refuse(p: Prepared, why: string): true { + showHint(p.view, `Cannot perform refactoring: ${why}`); + return true; +} + +async function perform(p: Prepared, expr: SyntaxNode): Promise { + const { view, doc } = p; + if (view.state.doc !== doc) { + showHint(view, "The document changed; invoke the refactoring again"); + return; + } + if (expr.error || expr.missing || expr.hasError) { + refuse(p, "the expression contains syntax errors"); + return; + } + const at = chainOf(await structure.nodeAt(p.ctx.doc, expr.range), expr.range, expr.kind); + if (!at) { + refuse(p, "the expression could not be located"); + return; + } + if (isWritten(at.chain, at.self)) { + refuse(p, "the expression is assigned to"); + return; + } + if (p.kind === "variable") await introduceVariable(p, expr, at.chain, at.self); + else await introduceConstant(p, expr, at.chain, at.self); +} + +// --------------------------------------------------------------------------- +// Variable + +async function introduceVariable(p: Prepared, expr: SyntaxNode, chain: SyntaxNode[], self: number) { + const { doc, ctx, at } = p; + const statement = at.statement; + if (!statement || !at.block) return void refuse(p, "no statement to put the declaration before"); + if (at.functionBoundary) { + return void refuse(p, "the expression is in a lambda with an expression body; convert it to a block body first"); + } + // Hoisting before the statement must not change how often / whether it runs. + for (let i = self; i < chain.length; i++) { + const node = chain[i]!; + const parent = chain[i + 1]; + if (!parent || sameRange(node.range, statement.range)) break; + if (CONDITIONAL_FIELDS.has(node.field ?? "") || (node.field === "condition" && LOOP.test(parent.kind))) { + return void refuse(p, "the expression is evaluated conditionally or repeatedly by its statement"); + } + if (i > self && /comprehension|generator_expression/.test(node.kind)) { + return void refuse(p, "the expression is inside a comprehension"); + } + } + + const exprText = nodeText(doc, expr); + const { from: exprFrom, to: exprTo } = rangeToOffsets(doc, expr.range); + const { from: stmtFrom, to: stmtTo } = rangeToOffsets(doc, statement.range); + // `foo();` / `foo()` alone (a missing `;` may make the parser swallow the next line into the statement). + const rest = doc.sliceString(exprTo, stmtTo); + const bare = + replacesBareStatement(ctx.language, statement.kind) && + stmtFrom === exprFrom && + (/^\s*;?\s*$/.test(rest) || (statement.hasError && /^[ \t]*;?[ \t]*(\n|$)/.test(rest))); + const bareTo = /^\s*;?\s*$/.test(rest) ? stmtTo : exprTo + /^[ \t]*;?/.exec(rest)![0].length; + + const declCx = await variableContext(p, expr, exprText, statement); + const probe = variableDeclaration(ctx.language, "x", exprText, declCx); + if (!probe.ok) return void refuse(p, probe.reason); + + const blockRange = rangeToOffsets(doc, at.block.range); + const block = at.block; + const resolution = resolutionOf(p); + const others = await verifiedOccurrences(p, expr, exprText, stmtFrom, blockRange.to, async (o, s) => { + // In the same block, not inside a nested function / lambda. + const above = s.chain.slice(s.self + 1); + const blockAt = above.findIndex((n) => sameRange(n.range, block.range)); + if (blockAt < 0 || above.slice(0, blockAt).some((n) => functionLike(ctx.language, n.kind))) return false; + if (isWritten(s.chain, s.self) || isBareStatement(s.chain, s.self, doc)) return false; + return sameResolution(p, resolution, { from: exprFrom, to: exprTo }, o); + }); + + const names = await nameSuggestions(p, expr, exprText); + const firstOnLine = !doc.sliceString(doc.lineAt(stmtFrom).from, stmtFrom).trim(); + const plan = (all: boolean) => (name: string, state: EditorState) => { + const result = variableDeclaration(ctx.language, name, exprText, declCx); + if (!result.ok) return null; + const decl = result.decl; + const specs: ChangeSpec[] = []; + const refs: number[] = []; + if (bare) specs.push({ from: stmtFrom, to: bareTo, insert: decl.text }); + else { + const sep = firstOnLine ? `\n${indentOf(doc, stmtFrom)}` : decl.terminated ? " " : "; "; + specs.push({ from: stmtFrom, insert: decl.text + sep }); + specs.push({ from: exprFrom, to: exprTo, insert: decl.reference }); + refs.push(exprFrom); + } + if (all) { + for (const o of others) { + specs.push({ from: o.from, to: o.to, insert: decl.reference }); + refs.push(o.from); + } + } + return { changes: state.changes(specs), decl, declAt: stmtFrom, refs }; + }; + await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); +} + +async function variableContext( + p: Prepared, + expr: SyntaxNode, + text: string, + statement: SyntaxNode, +): Promise { + const { ctx, doc } = p; + const root = useProject.getState().project?.root ?? null; + if (ctx.language === "java") { + const level = await javaLanguageLevel(ctx.path, root).catch(() => null); + if (level !== null && level >= 10) return { javaVar: true }; + const type = + literalType("java", expr.kind, text) ?? javaConstructedType(text) ?? (await javaHoverType(p, expr, text)); + return { type }; + } + if (ctx.language === "c") { + if (await cIsC23(ctx.path, root).catch(() => false)) return { cAuto: true }; + return { type: literalType("c", expr.kind, text) }; + } + if (isTsJs(ctx.language)) return { semicolons: usesSemicolons(doc, statement) }; + return {}; +} + +function usesSemicolons(doc: Text, statement: SyntaxNode): boolean { + if (nodeText(doc, statement).trimEnd().endsWith(";")) return true; + return /;[ \t]*$/m.test(doc.toString()); +} + +/** Return / declared type of a Java call or name from the server's hover, if it answers. */ +async function javaHoverType(p: Prepared, expr: SyntaxNode, text: string): Promise { + const m = /([A-Za-z_$][\w$]*)\s*(\([\s\S]*\))?\s*$/.exec(text); + if (!m || !/method_invocation|identifier|field_access/.test(expr.kind)) return null; + const { from } = rangeToOffsets(p.doc, expr.range); + const pos = p.doc.lineAt(from + m.index); + const at = { line: pos.number - 1, col: from + m.index - pos.from }; + try { + const hover = await Promise.race([ + call(commands.lspHover(nextRequestId(), p.ctx.doc, at)), + new Promise((resolve) => setTimeout(() => resolve(null), 1500)), + ]); + return hover ? javaTypeFromHover(hover.contents.value, m[1]!) : null; + } catch { + return null; + } +} + +/** Memoised `locals` lookups: local definition / not-local reason of the identifier at an offset. */ +function resolutionOf(p: Prepared): (offset: number) => Promise { + const memo = new Map>(); + const supported = structure.support(p.ctx.language).then( + (s) => s.locals, + () => false, + ); + return (offset) => { + let r = memo.get(offset); + if (!r) { + r = (async () => { + if (!(await supported)) return "unknown"; + const line = p.doc.lineAt(offset); + const l = await structure.locals(p.ctx.doc, { line: line.number - 1, col: offset - line.from }); + if (l.status === "local") return `local:${l.symbol.definition.start.line}:${l.symbol.definition.start.col}`; + return l.status === "notLocal" ? `not:${l.reason}` : "none"; + })(); + memo.set(offset, r); + } + return r; + }; +} + +/** Every name in the two (equal up to whitespace) expressions resolves alike: no shadowing in between. */ +async function sameResolution( + p: Prepared, + resolve: (offset: number) => Promise, + a: Occurrence, + b: Occurrence, +): Promise { + const wa = expressionWords(p.doc.sliceString(a.from, a.to)); + const wb = expressionWords(p.doc.sliceString(b.from, b.to)); + if (wa.length !== wb.length) return false; + for (let i = 0; i < Math.min(wa.length, 8); i++) { + if (wa[i]!.word !== wb[i]!.word) return false; + if ((await resolve(a.from + wa[i]!.offset)) !== (await resolve(b.from + wb[i]!.offset))) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Constant + +async function introduceConstant(p: Prepared, expr: SyntaxNode, chain: SyntaxNode[], self: number) { + const { doc, ctx } = p; + const text = nodeText(doc, expr); + if (!isLiteral(ctx.language, expr.kind, text)) { + return void refuse(p, "only literal values can be extracted as constants without the language server"); + } + if (isBareStatement(chain, self, doc)) return void refuse(p, "the literal is a statement on its own"); + if (!constantPosition(ctx.language, chain, self)) + return void refuse(p, "the literal is not in an expression position"); + + const declCx: DeclContext = { type: ctx.language === "java" ? literalType("java", expr.kind, text) : null }; + if (isTsJs(ctx.language)) declCx.semicolons = /;[ \t]*$/m.test(doc.toString()); + const probe = constantDeclaration(ctx.language, "X", text, declCx); + if (!probe.ok) return void refuse(p, probe.reason); + + const place = await constantPlacement(p, expr, chain); + if (typeof place === "string") return void refuse(p, place); + + const { from: exprFrom, to: exprTo } = rangeToOffsets(doc, expr.range); + const others = await verifiedOccurrences( + p, + expr, + text, + place.insertAt, + place.scopeTo, + async (_o, s) => constantPosition(ctx.language, s.chain, s.self) && !isBareStatement(s.chain, s.self, doc), + ); + + const names = await nameSuggestions(p, expr, text); + const plan = (all: boolean) => (name: string, state: EditorState) => { + const result: DeclResult = constantDeclaration(ctx.language, name, text, declCx); + if (!result.ok) return null; + const decl = result.decl; + const specs: ChangeSpec[] = [ + { from: place.insertAt, insert: place.indent + decl.text + "\n" + place.after }, + { from: exprFrom, to: exprTo, insert: decl.reference }, + ]; + const refs = [exprFrom]; + if (all) { + for (const o of others) { + specs.push({ from: o.from, to: o.to, insert: decl.reference }); + refs.push(o.from); + } + } + return { changes: state.changes(specs), decl, declAt: place.insertAt, declIndent: place.indent.length, refs }; + }; + await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); +} + +/** Not an object key, type, import source…: a place where a named constant can stand. */ +function constantPosition(language: string, chain: SyntaxNode[], self: number): boolean { + if ( + chain.slice(self + 1).some((n) => /import|literal_type|type_annotation|predefined_type|case_pattern/.test(n.kind)) + ) + return false; + return !(isTsJs(language) && chain[self]!.field === "key"); +} + +interface Placement { + insertAt: number; + indent: string; + /** Text after the declaration's line (blank line separator). */ + after: string; + /** End of the region where the constant is visible (occurrence search). */ + scopeTo: number; +} + +async function constantPlacement(p: Prepared, expr: SyntaxNode, chain: SyntaxNode[]): Promise { + const { doc, ctx } = p; + if (ctx.language === "java") { + const exprFrom = rangeToOffsets(doc, expr.range).from; + const line = doc.lineAt(exprFrom); + const ranges = await structure.structuralRanges(ctx.doc, { line: line.number - 1, col: exprFrom - line.from }); + const member = ranges.declaration; + if (!member) return "no class member contains the expression"; + const i = chain.findIndex((n) => sameRange(n.range, member.range)); + const body = chain[i + 1]; + const owner = chain[i + 2]; + if (i < 0 || !body || body.kind !== "class_body" || !owner || !/^(class|record)_declaration$/.test(owner.kind)) { + return "constants can only be added to a class or record without the language server"; + } + const memberFrom = rangeToOffsets(doc, member.range).from; + return { + insertAt: declarationStart(doc, memberFrom), + indent: indentOf(doc, memberFrom), + after: "\n", + scopeTo: rangeToOffsets(doc, body.range).to, + }; + } + // Module level: before the top-level statement containing the expression. + const top = chain[chain.length - 2]; + if (!top) return "the expression is not inside a top-level declaration"; + if ( + /import|package|future/.test(top.kind) || + (top.kind === "export_statement" && /\bfrom\s*["']/.test(nodeText(doc, top))) + ) { + return "the literal is part of an import"; + } + const topFrom = rangeToOffsets(doc, top.range).from; + return { insertAt: declarationStart(doc, topFrom), indent: "", after: "\n", scopeTo: doc.length }; +} + +// --------------------------------------------------------------------------- +// Shared steps + +interface Occurrence { + from: number; + to: number; +} + +/** Other occurrences of `exprText` in `[from, to)` that the tree confirms (`accept`). */ +async function verifiedOccurrences( + p: Prepared, + expr: SyntaxNode, + exprText: string, + from: number, + to: number, + accept: (o: Occurrence, s: { chain: SyntaxNode[]; self: number }) => Promise, +): Promise { + const { doc } = p; + const { from: exprFrom, to: exprTo } = rangeToOffsets(doc, expr.range); + const literal = isLiteral(p.ctx.language, expr.kind, exprText); + const found = findTextOccurrences(doc.toString(), from, to, exprText, MAX_OCCURRENCES + 1).filter( + (o) => o.to <= exprFrom || o.from >= exprTo, + ); + const out: Occurrence[] = []; + for (const o of found.slice(0, MAX_OCCURRENCES)) { + const range = offsetsToRange(doc, o.from, o.to); + const s = chainOf(await structure.nodeAt(p.ctx.doc, range), range, expr.kind); + if (!s || s.chain[s.self]!.hasError) continue; + if (literal && doc.sliceString(o.from, o.to) !== exprText) continue; + if (normalizeExpression(doc.sliceString(o.from, o.to)) !== normalizeExpression(exprText)) continue; + if (await accept(o, s)) out.push(o); + } + return out; +} + +/** IDEA's "Multiple occurrences found" choice; runs `apply` directly when there are none. */ +async function chooseOccurrences(p: Prepared, others: number, apply: (all: boolean) => void) { + if (!others) { + apply(false); + return; + } + const head = p.view.state.selection.main.head; + openPopup({ + kind: "list", + anchor: caretAnchor(p.view, head), + title: "Multiple occurrences found", + items: [ + { id: "one", label: "Replace this occurrence only", run: () => apply(false) }, + { id: "all", label: `Replace all ${others + 1} occurrences`, run: () => apply(true) }, + ], + }); +} + +async function nameSuggestions(p: Prepared, expr: SyntaxNode, text: string): Promise { + const raw = suggestNames({ kind: expr.kind, text, language: p.ctx.language, constant: p.kind === "constant" }); + return uniqueNames(raw, (name) => nameTaken(p, name)); +} + +async function nameTaken(p: Prepared, name: string): Promise { + const found = await structure.identifiers(p.ctx.doc, name); + return found.some((o) => !o.qualified); +} + +type Plan = ( + name: string, + state: EditorState, +) => { changes: ChangeSet; decl: Declaration; declAt: number; declIndent?: number; refs: number[] } | null; + +/** Name ranges (declaration + references) of the variable in the document after `plan`. */ +function nameRanges(planned: NonNullable>, name: string): { from: number; to: number }[] { + const { changes, decl, declAt, refs } = planned; + const declStart = changes.mapPos(declAt, -1) + (planned.declIndent ?? 0) + decl.nameOffset; + return [ + { from: declStart, to: declStart + name.length }, + ...refs.map((r) => { + const at = changes.mapPos(r, -1) + decl.referenceNameOffset; + return { from: at, to: at + name.length }; + }), + ]; +} + +/** Caret after the refactoring: after the replaced occurrence, else after the declared name. */ +function caretOf(planned: NonNullable>, name: string): number { + const ranges = nameRanges(planned, name); + return (ranges[1] ?? ranges[0]!).to; +} + +function applyAndName(p: Prepared, plan: Plan, names: string[]) { + const { view } = p; + if (view.state.doc !== p.doc) { + showHint(view, "The document changed; invoke the refactoring again"); + return; + } + const original = view.state.doc; + const name = names[0]!; + const planned = plan(name, view.state); + if (!planned) return; + view.dispatch({ + changes: planned.changes, + selection: { anchor: caretOf(planned, name) }, + scrollIntoView: true, + userEvent: "refactor.introduce", + }); + const applied = view.state.doc; + const ranges = nameRanges(planned, name); + view.focus(); + openPopup({ + kind: "rename", + anchor: caretAnchor(view, ranges[0]!.from), + title: p.kind === "variable" ? "Name for the new variable" : "Name for the new constant", + initial: name, + suggestions: names, + note: NOTES[p.reason], + hint: `↵ Apply · Esc Keep '${name}'`, + onSubmit: (chosen) => void rename(p, chosen, name, { original, applied, plan, ranges }), + }); +} + +async function rename( + p: Prepared, + chosen: string, + current: string, + st: { original: Text; applied: Text; plan: Plan; ranges: { from: number; to: number }[] }, +) { + const { view } = p; + const name = chosen.replace(/^\$/, ""); + if (!name || name === current) return; + if (!isIdentifier(name) || isKeyword(name)) { + showHint(view, `'${chosen}' is not a valid name; kept '${current}'`, { tone: "warning" }); + return; + } + if (view.state.doc !== st.applied) { + showHint(view, "The document changed; the name was not applied"); + return; + } + // Redo the whole refactoring with the chosen name, so it stays one undo step. + const planned = undo(view) && view.state.doc.eq(st.original) ? st.plan(name, view.state) : null; + if (planned) { + view.dispatch({ + changes: planned.changes, + selection: { anchor: caretOf(planned, name) }, + scrollIntoView: true, + userEvent: "refactor.introduce", + }); + } else { + if (view.state.doc !== st.applied) redo(view); + if (view.state.doc !== st.applied) return; + view.dispatch({ + changes: st.ranges.map((r) => ({ from: r.from, to: r.to, insert: name })), + userEvent: "refactor.rename", + }); + } + await whenSynced(view); + const uses = (await structure.identifiers(p.ctx.doc, name).catch(() => [])).filter((o) => !o.qualified).length; + if (uses > st.ranges.length) showHint(view, `'${name}' is also used elsewhere in this file`, { tone: "warning" }); +} diff --git a/src/editor/refactor/names.ts b/src/editor/refactor/names.ts new file mode 100644 index 00000000..a1fa96b7 --- /dev/null +++ b/src/editor/refactor/names.ts @@ -0,0 +1,256 @@ +/** + * Name suggestions for Introduce Variable / Constant without a language + * server, after IDEA: from the callee (`getUser()` → `user`), the constructed + * type (`new StringBuilder()` → `stringBuilder`, `builder`), the accessed + * property, the literal kind (`s`, `i`, `b`…). Purely textual; callers drop + * names already used in the file. + */ + +export type NameStyle = "camel" | "snake"; + +const SNAKE = new Set(["python", "ruby", "rust", "elixir", "c", "zig", "lua"]); + +export function nameStyle(language: string): NameStyle { + return SNAKE.has(language) ? "snake" : "camel"; +} + +const KEYWORDS = new Set( + ( + "abstract and as assert async await begin bool boolean break byte case catch char class const continue def default " + + "defer del delete do double elif else elsif end ensure enum except export extends extern false final finally float fn " + + "for from func function global go goto if impl import in instanceof int interface is lambda let local long loop match " + + "mod module mut native new nil none not null of or package pass private protected public raise rescue return " + + "self short static struct super switch synchronized then this throw throws trait true try typeof undefined " + + "unless until use val var void volatile when where while with yield" + ).split(" "), +); + +const STRIP_PREFIXES = [ + "get", + "to", + "as", + "create", + "new", + "make", + "build", + "find", + "fetch", + "load", + "read", + "compute", + "calculate", +]; +const BOOL_PREFIXES = ["is", "has", "can", "should"]; + +/** `getUserName` / `get_user_name` / `UserName` → `["get", "user", "name"]`. */ +export function splitWords(identifier: string): string[] { + return identifier + .replace(/^[$@]+/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s_-]+/) + .filter(Boolean) + .map((w) => w.toLowerCase()); +} + +function join(words: string[], style: NameStyle | "constant"): string { + if (style === "constant") return words.map((w) => w.toUpperCase()).join("_"); + if (style === "snake") return words.join("_"); + return words.map((w, i) => (i === 0 ? w : w[0]!.toUpperCase() + w.slice(1))).join(""); +} + +/** Every suffix of the word list, longest first: `[string, builder]` → `stringBuilder`, `builder`. */ +function suffixes(words: string[]): string[][] { + return words.map((_, i) => words.slice(i)); +} + +/** Strips one balanced bracket group ending at `end` (exclusive); returns its start or -1. */ +function openingBracket(text: string, end: number, open: string, close: string): number { + let depth = 0; + for (let i = end - 1; i >= 0; i--) { + const c = text[i]; + if (c === close) depth++; + else if (c === open && --depth === 0) return i; + } + return -1; +} + +/** Last identifier segment of a callee / member chain (`a.b::c->d` → `d`). */ +function lastSegment(path: string): string | null { + const m = /([A-Za-z_$][\w$]*)\s*$/.exec(path.replace(/::<[^>]*>\s*$/, "").replace(/<[^<>()]*>\s*$/, "")); + return m ? m[1]! : null; +} + +function singular(word: string): string | null { + if (/ies$/.test(word)) return word.slice(0, -3) + "y"; + if (/(ss|us)$/.test(word)) return null; + if (/(sh|ch|x)es$/.test(word)) return word.slice(0, -2); + if (/s$/.test(word) && word.length > 2) return word.slice(0, -1); + return null; +} + +export interface SuggestInput { + /** Syntax node kind of the expression. */ + kind: string; + text: string; + language: string; + constant?: boolean; +} + +/** Word lists, best first. */ +function wordCandidates(kind: string, raw: string, language: string): string[][] { + let text = raw.trim(); + // Unwrap what does not change the value's nature. + for (;;) { + const before = text; + text = text + .replace(/^(await|try[!?]?)\s+/, "") + .replace(/(\?|!!|!)$/, "") + .replace(/\.(unwrap|expect|unwrap_or_default|clone|to_owned)\([^()]*\)$/, "") + .trim(); + if (text.startsWith("(") && openingBracket(text, text.length, "(", ")") === 0) text = text.slice(1, -1).trim(); + if (text === before) break; + } + + // Literals. + const str = /^(?:[rbuRBUfF]{0,2})(["'`])([\s\S]*)\1$/.exec(text) ?? /^@?"([\s\S]*)"$/.exec(text); + if ( + /char/.test(kind) || + (/^'.'$|^'\\.'$/.test(text) && + ![ + "python", + "javascript", + "typescript", + "javascriptreact", + "typescriptreact", + "ruby", + "php", + "lua", + "elixir", + "dart", + ].includes(language)) + ) { + return [["c"], ["ch"]]; + } + if (/string|encapsed|template|heredoc/.test(kind) || (str && /literal|string/.test(kind))) { + const content = str ? str[str.length - 1]! : ""; + const words = /^[A-Za-z][A-Za-z0-9 _-]{0,40}$/.test(content.trim()) ? splitWords(content.trim()) : []; + return [...(words.length && words.length <= 3 ? [words] : []), ["s"], ["text"]]; + } + if (/^-?\s*(\d[\d_]*|0[xXbBoO][\da-fA-F_]+)[lLuU]*$/.test(text)) return [["i"], ["value"]]; + if (/^-?\s*(\d[\d_]*)?\.?\d[\d_]*([eE][+-]?\d+)?[fFdDmM]?$/.test(text)) return [["value"], ["v"]]; + if (/^(true|false|True|False|YES|NO)$/.test(text)) return [["b"], ["flag"]]; + if (/^(null|nil|None|undefined|nullptr)$/.test(text)) return [["value"]]; + if (/^\[[\s\S]*\]$/.test(text) || /^(vec!|listOf|arrayOf|mutableListOf|List\.of|Arrays\.asList)/.test(text)) + return [["list"], ["items"]]; + if (/^\{[\s\S]*\}$/.test(text) && /object|dictionary|hash|map/.test(kind)) { + return language === "python" ? [["data"], ["mapping"]] : [["obj"], ["data"]]; + } + if (/lambda|arrow|closure|function|anonymous/.test(kind)) return [["fn"], ["callback"]]; + + // Construction: `new Foo(…)`, `Foo(…)`, `Foo::new(…)`, `&Foo { … }`. + const ctor = + /^new\s+([\w$.]+)/.exec(text) ?? + /^&?(?:mut\s+)?([A-Z][\w$]*)(?:::(?:new|default|from|with_\w+|create|of))?\s*(?:::<[^>]*>)?\s*[({]/.exec(text) ?? + /^([\w$.]*\.)?([A-Z][\w$]*)\.(?:of|from|valueOf|newInstance|create|getInstance)\s*\(/.exec(text); + if (ctor) { + const type = lastSegment(ctor[ctor.length - 1]!) ?? ""; + const words = splitWords(type).filter((w) => w !== "new"); + if (words.length) return suffixes(words); + } + + // Calls: the last top-level argument list. + if (text.endsWith(")")) { + const open = openingBracket(text, text.length, "(", ")"); + if (open > 0) { + const callee = text.slice(0, open).trim(); + const name = lastSegment(callee); + if (name) { + const words = splitWords(name); + const out: string[][] = []; + if (words.length > 1 && BOOL_PREFIXES.includes(words[0]!)) out.push(...suffixes(words.slice(1)), words); + else if (words.length > 1 && STRIP_PREFIXES.includes(words[0]!)) out.push(...suffixes(words.slice(1))); + else if (words.length === 1 && (STRIP_PREFIXES.includes(words[0]!) || words[0] === "new")) { + // `map.get(k)`, `Foo.create()`: name after the receiver. + const receiver = lastSegment(callee.slice(0, callee.length - name.length).replace(/(\.|::|->|\?\.)\s*$/, "")); + const sing = receiver ? singular(splitWords(receiver).pop() ?? "") : null; + if (sing) out.push([sing]); + else if (receiver && /^[A-Z]/.test(receiver)) out.push(...suffixes(splitWords(receiver))); + else if (!["get", "new", "to", "as"].includes(words[0]!)) out.push(words); + } else out.push(...suffixes(words)); + out.push(["value"]); + return out; + } + } + } + + // Index access: `items[i]` → `item`. + if (text.endsWith("]")) { + const open = openingBracket(text, text.length, "[", "]"); + if (open > 0) { + const base = lastSegment(text.slice(0, open)); + const last = base ? splitWords(base).pop() : undefined; + const sing = last ? singular(last) : null; + return [...(sing ? [[sing]] : []), ["item"], ["element"]]; + } + } + + // Comparisons / boolean operators. + if ( + /comparison|boolean_operator|not_operator|logical/.test(kind) || + /(==|!=|<=|>=|&&|\|\||\s<\s|\s>\s|\bnot\b|\band\b|\bor\b|^!)/.test(text) + ) { + if (!/[=!]~|=>/.test(text)) return [["b"], ["flag"]]; + } + + // Member access / plain identifiers: `a.b.userName` → `userName`, `name`. + const member = /(?:^|\.|::|->|\?\.)\s*\$?([A-Za-z_][\w]*)$/.exec(text); + if (member && !/[\s+\-*/%^|&(),]/.test(text.replace(/\s*(\.|::|->|\?\.)\s*/g, "."))) { + const words = splitWords(member[1]!); + if (words.length > 1 && STRIP_PREFIXES.includes(words[0]!)) return [...suffixes(words.slice(1)), ["value"]]; + return [...suffixes(words), ["value"]]; + } + if (/^["'`]/.test(text) || /\+\s*["'`]|["'`]\s*\+/.test(text)) return [["s"], ["text"]]; + return [["value"]]; +} + +/** + * Suggested names, best first and distinct; at least one. Keywords and + * non-identifiers are dropped. `constant` gives UPPER_SNAKE_CASE. + */ +export function suggestNames(input: SuggestInput): string[] { + const style = input.constant ? "constant" : nameStyle(input.language); + const out: string[] = []; + for (const words of wordCandidates(input.kind, input.text, input.language)) { + if (!words.length) continue; + const name = join(words, style); + if (!isIdentifier(name) || KEYWORDS.has(name.toLowerCase()) || out.includes(name)) continue; + out.push(name); + } + if (!out.length) out.push(input.constant ? "VALUE" : "value"); + return out; +} + +export function isIdentifier(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); +} + +export function isKeyword(name: string): boolean { + return KEYWORDS.has(name.toLowerCase()); +} + +/** + * Drops names `taken` reports as used; when every suggestion is taken, numbers + * the first one (`user1`, `user2`…). + */ +export async function uniqueNames(names: string[], taken: (name: string) => Promise): Promise { + const used = await Promise.all(names.map((n) => taken(n).catch(() => true))); + const free = names.filter((_, i) => !used[i]); + if (free.length) return free; + const base = names[0]!; + for (let i = 1; i < 100; i++) { + const n = `${base}${i}`; + if (!(await taken(n).catch(() => true))) return [n]; + } + return [base]; +} diff --git a/src/editor/refactor/occurrences.ts b/src/editor/refactor/occurrences.ts new file mode 100644 index 00000000..54d02bff --- /dev/null +++ b/src/editor/refactor/occurrences.ts @@ -0,0 +1,115 @@ +/** + * Textual candidates for "replace all occurrences": spans of `text` equal to + * an expression up to whitespace between tokens. Callers confirm each span + * with the syntax tree (same node kind, same resolution) before using it. + */ + +const WORD = /[\w$]/; + +/** + * Collapses whitespace between tokens (kept as one space between two word + * characters), leaving string / char literal contents untouched. + */ +export function normalizeExpression(text: string): string { + let out = ""; + let pendingSpace = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (c === '"' || c === "'" || c === "`") { + if (pendingSpace && out && WORD.test(out[out.length - 1]!)) out += " "; + pendingSpace = false; + let j = i + 1; + while (j < text.length && text[j] !== c) j += text[j] === "\\" ? 2 : 1; + out += text.slice(i, j + 1); + i = j; + continue; + } + if (/\s/.test(c)) { + pendingSpace = true; + continue; + } + if (pendingSpace && out && WORD.test(out[out.length - 1]!) && WORD.test(c)) out += " "; + pendingSpace = false; + out += c; + } + return out; +} + +function escape(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Regex source matching `expr` with any whitespace between its tokens. */ +export function expressionPattern(expr: string): string { + const norm = normalizeExpression(expr.trim()); + const tokens: string[] = []; + let i = 0; + while (i < norm.length) { + const c = norm[i]!; + if (c === " ") { + tokens.push("\\s+"); + i++; + } else if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < norm.length && norm[j] !== c) j += norm[j] === "\\" ? 2 : 1; + tokens.push(escape(norm.slice(i, j + 1))); + i = j + 1; + } else if (WORD.test(c)) { + let j = i; + while (j < norm.length && WORD.test(norm[j]!)) j++; + tokens.push(escape(norm.slice(i, j))); + i = j; + } else { + tokens.push(escape(c)); + i++; + } + } + let src = ""; + tokens.forEach((t, k) => { + if (k > 0 && t !== "\\s+" && tokens[k - 1] !== "\\s+") src += "\\s*"; + src += t; + }); + const head = WORD.test(norm[0] ?? "") ? "(?= limit) break; + } + return out; +} + +/** Identifier-like words of an expression outside literals, with offsets relative to it. */ +export function expressionWords(expr: string): { word: string; offset: number }[] { + const out: { word: string; offset: number }[] = []; + let i = 0; + while (i < expr.length) { + const c = expr[i]!; + if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < expr.length && expr[j] !== c) j += expr[j] === "\\" ? 2 : 1; + i = j + 1; + } else if (/[A-Za-z_$]/.test(c) && (i === 0 || !WORD.test(expr[i - 1]!))) { + let j = i; + while (j < expr.length && WORD.test(expr[j]!)) j++; + out.push({ word: expr.slice(i, j), offset: i }); + i = j; + } else i++; + } + return out; +} diff --git a/src/editor/refactor/projectLevel.ts b/src/editor/refactor/projectLevel.ts new file mode 100644 index 00000000..2904bbd4 --- /dev/null +++ b/src/editor/refactor/projectLevel.ts @@ -0,0 +1,142 @@ +/** + * Language levels read from build files, for templates that depend on them: + * Java `var` (10+) from Maven / Gradle, C `auto` (C23) from CMake / compiler + * flags. Unknown means "not known to be supported". + */ +import { call, commands } from "@/platform/ipc"; + +/** `1.8` → 8, `17` → 17. */ +export function parseJavaVersion(v: string | undefined | null): number | null { + if (!v) return null; + const m = /^\s*(?:1\.)?(\d+)/.exec(v); + return m ? Number(m[1]) : null; +} + +function pomProperties(pom: string): Map { + const props = new Map(); + const section = /([\s\S]*?)<\/properties>/.exec(pom)?.[1] ?? ""; + for (const m of section.matchAll(/<([\w.-]+)>\s*([^<]*?)\s*<\/\1>/g)) props.set(m[1]!, m[2]!); + return props; +} + +function resolve(value: string | undefined, props: Map, depth = 0): string | undefined { + if (!value || depth > 5) return value; + const ref = /^\$\{([\w.-]+)\}$/.exec(value.trim()); + return ref ? resolve(props.get(ref[1]!), props, depth + 1) : value; +} + +/** Java language level declared by a pom.xml, if any. */ +export function javaLevelFromPom(pom: string): number | null { + const clean = pom.replace(//g, ""); + const props = pomProperties(clean); + const plugin = /\s*maven-compiler-plugin\s*<\/artifactId>([\s\S]*?)<\/plugin>/.exec(clean)?.[1] ?? ""; + const tag = (name: string) => new RegExp(`<${name}>\\s*([^<]+?)\\s*`).exec(plugin)?.[1]; + const candidates = [ + tag("release"), + props.get("maven.compiler.release"), + tag("source"), + props.get("maven.compiler.source"), + props.get("java.version"), + ]; + for (const c of candidates) { + const v = parseJavaVersion(resolve(c, props)); + if (v !== null) return v; + } + return null; +} + +/** Java language level declared by a build.gradle(.kts), if any. */ +export function javaLevelFromGradle(gradle: string): number | null { + const clean = gradle.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + const patterns = [ + /options\.release(?:\.set\s*\(|\s*=)\s*(\d+)/, + /JavaLanguageVersion\.of\s*\(\s*["']?(\d+)/, + /jvmToolchain\s*\(\s*(\d+)/, + /sourceCompatibility\s*(?:=|\s)\s*(?:JavaVersion\.VERSION_(?:1_)?(\d+)|["']?((?:1\.)?\d+)["']?)/, + /JavaVersion\.VERSION_(?:1_)?(\d+)/, + ]; + for (const p of patterns) { + const m = p.exec(clean); + if (m) return parseJavaVersion(m[1] ?? m[2]); + } + return null; +} + +/** C23 (`auto` type inference) requested by CMake or compiler flags. */ +export function cStandardIs23(file: string, text: string): boolean { + if (/CMakeLists\.txt$/.test(file)) return /C_STANDARD\s+(23|2x)\b/i.test(text) || /-std=(c|gnu)(23|2x)\b/.test(text); + return /-std=(c|gnu)(23|2x)\b/.test(text); +} + +type ReadText = (path: string) => Promise; + +const readText: ReadText = (path) => call(commands.fsReadText(path)); + +function parentDir(path: string): string | null { + const i = path.replace(/\/+$/, "").lastIndexOf("/"); + return i > 0 ? path.slice(0, i) : null; +} + +/** Directories from `file`'s up to `root` (inclusive), nearest first. */ +export function ancestorDirs(file: string, root: string | null): string[] { + const out: string[] = []; + let dir = parentDir(file); + const stop = root?.replace(/\/+$/, ""); + while (dir) { + out.push(dir); + if (!stop || dir === stop || !dir.startsWith(stop)) break; + dir = parentDir(dir); + } + return out; +} + +const CACHE_MS = 30_000; +const cache = new Map }>(); + +function cached(key: string, compute: () => Promise): Promise { + const hit = cache.get(key); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.value as Promise; + const value = compute(); + cache.set(key, { at: Date.now(), value }); + return value; +} + +export function clearLevelCache() { + cache.clear(); +} + +/** Nearest declared Java level for `file` (walking up to `root`), or null. */ +export function javaLanguageLevel( + file: string, + root: string | null, + read: ReadText = readText, +): Promise { + return cached(`java:${file}`, async () => { + for (const dir of ancestorDirs(file, root)) { + for (const [name, parse] of [ + ["pom.xml", javaLevelFromPom], + ["build.gradle", javaLevelFromGradle], + ["build.gradle.kts", javaLevelFromGradle], + ] as const) { + const text = await read(`${dir}/${name}`).catch(() => null); + if (text === null) continue; + const level = parse(text); + if (level !== null) return level; + } + } + return null; + }); +} + +/** Whether the C file is built as C23 (per the nearest build configuration found). */ +export function cIsC23(file: string, root: string | null, read: ReadText = readText): Promise { + return cached(`c:${file}`, async () => { + for (const dir of ancestorDirs(file, root)) { + for (const name of ["CMakeLists.txt", "compile_flags.txt", "compile_commands.json", ".clangd", "Makefile"]) { + const text = await read(`${dir}/${name}`).catch(() => null); + if (text !== null && cStandardIs23(name, text)) return true; + } + } + return false; + }); +} diff --git a/src/editor/refactor/query.ts b/src/editor/refactor/query.ts new file mode 100644 index 00000000..acb3c482 --- /dev/null +++ b/src/editor/refactor/query.ts @@ -0,0 +1,53 @@ +/** Cancellable LSP queries shared by Refactor This, the Refactor menu actions and Safe Delete. */ +import type { EditorView } from "@codemirror/view"; +import type { CodeActionQuery, IdeError } from "@/bindings"; +import { IpcError, call, commands, isTauri, nextRequestId } from "@/platform/ipc"; +import { whenSynced } from "@/editor/api"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import { fallbackLanguageName } from "./catalog"; + +type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError }; + +export const cancelled = () => new IpcError({ kind: "cancelled", message: "cancelled" }); + +/** Runs a backend request under a UI-allocated id; aborting `signal` cancels it there. */ +export async function cancellable(run: (request: number) => Promise>, signal?: AbortSignal): Promise { + if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" }); + if (signal?.aborted) throw cancelled(); + const id = nextRequestId(); + const onAbort = () => void commands.cancelRequest(id); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + return await call(run(id)); + } finally { + signal?.removeEventListener("abort", onAbort); + } +} + +/** `refactor*` code actions for the selection, with the kinds the servers advertise. */ +export async function queryRefactorActions(view: EditorView, signal?: AbortSignal, only: string[] = ["refactor"]): Promise { + const ctx = docOf(view); + if (!ctx) throw new IpcError({ kind: "unavailable", message: "No document" }); + await whenSynced(view); + const sel = view.state.selection.main; + const range = offsetsToRange(view.state.doc, sel.from, sel.to); + const q = await cancellable((r) => commands.lspCodeActionQuery(r, ctx.doc, range, only), signal); + // Servers may ignore `only`. + return { ...q, actions: q.actions.filter((a) => a.kind?.startsWith("refactor") ?? false) }; +} + +let names: Promise> | null = null; + +/** Display name of a language id ("rust" → "Rust"). */ +export async function languageName(id: string): Promise { + if (isTauri()) { + names ??= commands + .languages() + .then((list) => new Map(list.map((l) => [l.id as string, l.name]))) + .catch(() => new Map()); + const name = (await names).get(id); + if (name) return name; + } + return fallbackLanguageName(id); +} diff --git a/src/editor/refactor/refactor.test.ts b/src/editor/refactor/refactor.test.ts new file mode 100644 index 00000000..10353297 --- /dev/null +++ b/src/editor/refactor/refactor.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { suggestNames, uniqueNames } from "./names"; +import { + constantDeclaration, + isLiteral, + javaConstructedType, + javaTypeFromHover, + literalType, + replacesBareStatement, + variableDeclaration, +} from "./templates"; +import { ancestorDirs, cStandardIs23, javaLanguageLevel, javaLevelFromGradle, javaLevelFromPom } from "./projectLevel"; +import { expressionWords, findTextOccurrences, normalizeExpression } from "./occurrences"; + +describe("suggestNames", () => { + const s = (text: string, kind = "call_expression", language = "java", constant = false) => + suggestNames({ text, kind, language, constant }); + + it("names calls after the callee without get/to/create prefixes", () => { + expect(s("getUser()")).toEqual(["user", "value"]); + expect(s("repo.getUserName(id)")).toEqual(["userName", "name", "value"]); + expect(s("self.get_user_name()", "call", "python")).toEqual(["user_name", "name", "value"]); + expect(s("list.isEmpty()")).toEqual(["empty", "isEmpty", "value"]); + expect(s("await fetchOrders()", "await_expression", "typescript")).toEqual(["orders", "value"]); + expect(s("users.get(0)")).toEqual(["user", "value"]); + expect(s("compute(a + b, c)")).toEqual(["compute", "value"]); + }); + + it("names constructions after the type", () => { + expect(s("new StringBuilder()", "object_creation_expression")).toEqual(["stringBuilder", "builder"]); + expect(s("new java.util.ArrayList()", "object_creation_expression")).toEqual(["arrayList", "list"]); + expect(s("StringBuilder()", "call_expression", "kotlin")).toEqual(["stringBuilder", "builder"]); + expect(s("HashMap::new()", "call_expression", "rust")).toEqual(["hash_map", "map"]); + }); + + it("names literals, members, indexing and conditions", () => { + expect(s('"hello world"', "string_literal")).toEqual(["helloWorld", "s", "text"]); + expect(s('"a/b?c"', "string")).toEqual(["s", "text"]); + expect(s("42", "decimal_integer_literal")).toEqual(["i", "value"]); + expect(s("1.5", "float")).toEqual(["value", "v"]); + expect(s("true", "true")).toEqual(["b", "flag"]); + expect(s("order.totalPrice", "field_access")).toEqual(["totalPrice", "price", "value"]); + expect(s("$o->name", "member_access_expression", "php")).toEqual(["name", "value"]); + expect(s("items[i]", "array_access")).toEqual(["item", "element"]); + expect(s("a > b && c", "binary_expression")).toEqual(["b", "flag"]); + expect(s("(a + b)", "parenthesized_expression")).toEqual(["value"]); + }); + + it("gives UPPER_SNAKE_CASE constants and drops keywords", () => { + expect(s('"max retries"', "string_literal", "java", true)).toEqual(["MAX_RETRIES", "S", "TEXT"]); + expect(s("100", "integer", "python", true)).toEqual(["I", "VALUE"]); + expect(s("new Object()", "object_creation_expression")).toEqual(["object"]); + expect(s("getClass()")).toEqual(["value"]); + }); + + it("drops taken names and numbers the best one when all are taken", async () => { + const taken = new Set(["user", "value", "user1"]); + const isTaken = (n: string) => Promise.resolve(taken.has(n)); + expect(await uniqueNames(["user", "name", "value"], isTaken)).toEqual(["name"]); + expect(await uniqueNames(["user", "value"], isTaken)).toEqual(["user2"]); + }); +}); + +describe("templates", () => { + const decl = (language: string, cx = {}) => { + const r = variableDeclaration(language, "n", "e()", cx); + return r.ok ? r.decl.text : r.reason; + }; + + it("declares variables per language", () => { + expect(decl("java", { javaVar: true })).toBe("var n = e();"); + expect(decl("java", { type: "String" })).toBe("String n = e();"); + expect(decl("java")).toMatch(/Java 10/); + expect(decl("csharp")).toBe("var n = e();"); + expect(decl("kotlin")).toBe("val n = e()"); + expect(decl("typescript", { semicolons: true })).toBe("const n = e();"); + expect(decl("javascript", { semicolons: false })).toBe("const n = e()"); + expect(decl("python")).toBe("n = e()"); + expect(decl("rust")).toBe("let n = e();"); + expect(decl("go")).toBe("n := e()"); + expect(decl("swift")).toBe("let n = e()"); + expect(decl("scala")).toBe("val n = e()"); + expect(decl("ruby")).toBe("n = e()"); + expect(decl("lua")).toBe("local n = e()"); + expect(decl("elixir")).toBe("n = e()"); + expect(decl("cpp")).toBe("auto n = e();"); + expect(decl("c")).toMatch(/C23/); + expect(decl("c", { cAuto: true })).toBe("auto n = e();"); + expect(decl("c", { type: "const char *" })).toBe("const char *n = e();"); + expect(decl("dart")).toBe("final n = e();"); + expect(decl("haskell")).toMatch(/not supported/); + const php = variableDeclaration("php", "n", "e()"); + expect(php.ok && php.decl).toMatchObject({ + text: "$n = e();", + nameOffset: 1, + reference: "$n", + referenceNameOffset: 1, + }); + }); + + it("declares constants per language", () => { + const c = (language: string, cx = {}) => { + const r = constantDeclaration(language, "N", "1", cx); + return r.ok ? r.decl.text : r.reason; + }; + expect(c("java", { type: "int" })).toBe("private static final int N = 1;"); + expect(c("java")).toMatch(/needs a type/); + expect(c("typescript")).toBe("const N = 1;"); + expect(c("python")).toBe("N = 1"); + expect(c("kotlin")).toBe("private const val N = 1"); + expect(c("go")).toMatch(/not supported/); + }); + + it("replaces bare statements only where they discard their value", () => { + expect(replacesBareStatement("java", "expression_statement")).toBe(true); + expect(replacesBareStatement("python", "expression_statement")).toBe(true); + expect(replacesBareStatement("rust", "expression_statement")).toBe(true); + expect(replacesBareStatement("rust", "call_expression")).toBe(false); + expect(replacesBareStatement("kotlin", "call_expression")).toBe(false); + expect(replacesBareStatement("ruby", "call")).toBe(false); + }); + + it("types literals and plain constructions", () => { + expect(literalType("java", "string_literal", '"x"')).toBe("String"); + expect(literalType("java", "decimal_integer_literal", "10L")).toBe("long"); + expect(literalType("java", "hex_integer_literal", "0xFF")).toBe("int"); + expect(literalType("java", "decimal_floating_point_literal", "1.5f")).toBe("float"); + expect(literalType("java", "decimal_floating_point_literal", "1e3")).toBe("double"); + expect(literalType("java", "unary_expression", "-1")).toBe("int"); + expect(literalType("java", "character_literal", "'c'")).toBe("char"); + expect(literalType("java", "true", "true")).toBe("boolean"); + expect(literalType("java", "null_literal", "null")).toBeNull(); + expect(literalType("java", "binary_expression", '"a" + "b"')).toBeNull(); + expect(literalType("c", "string_literal", '"x"')).toBe("const char *"); + expect(literalType("c", "number_literal", "3UL")).toBeNull(); + expect(javaConstructedType('new StringBuilder("a")')).toBe("StringBuilder"); + expect(javaConstructedType("new HashMap()")).toBe("HashMap"); + expect(javaConstructedType("new ArrayList<>()")).toBeNull(); + expect(javaConstructedType("new Runnable() { public void run() {} }")).toBeNull(); + expect(javaConstructedType("new Foo().bar()")).toBeNull(); + expect(javaConstructedType("new int[3]")).toBe("int[]"); + }); + + it("recognises literals that do not depend on scope", () => { + expect(isLiteral("typescript", "template_string", "`a${b}`")).toBe(false); + expect(isLiteral("typescript", "template_string", "`ab`")).toBe(true); + expect(isLiteral("typescript", "identifier", "x")).toBe(false); + expect(isLiteral("python", "string", 'f"{x}"')).toBe(false); + expect(isLiteral("python", "string", '"x"')).toBe(true); + expect(isLiteral("python", "unary_operator", "-1")).toBe(true); + expect(isLiteral("kotlin", "string_literal", '"a$b"')).toBe(false); + expect(isLiteral("kotlin", "identifier", "true")).toBe(true); + }); + + it("reads a Java type from a jdtls hover", () => { + expect(javaTypeFromHover("```java\nString java.lang.String.trim()\n```", "trim")).toBe("String"); + expect(javaTypeFromHover("```java\njava.util.List a.B.names()\n```", "names")).toBe( + "java.util.List", + ); + expect(javaTypeFromHover("```java\nint count - a.B.f()\n```", "count")).toBe("int"); + expect(javaTypeFromHover("```java\nvoid a.B.run()\n```", "run")).toBeNull(); + expect(javaTypeFromHover("something else", "x")).toBeNull(); + }); +}); + +describe("project language levels", () => { + it("reads Maven poms", () => { + expect(javaLevelFromPom("17")).toBe(17); + expect(javaLevelFromPom("1.8")).toBe(8); + expect(javaLevelFromPom("21")).toBe(21); + expect( + javaLevelFromPom( + "11maven-compiler-plugin${jdk}", + ), + ).toBe(11); + expect(javaLevelFromPom("")).toBeNull(); + }); + + it("reads Gradle builds", () => { + expect(javaLevelFromGradle("java { sourceCompatibility = JavaVersion.VERSION_1_8 }")).toBe(8); + expect(javaLevelFromGradle("sourceCompatibility = '11'")).toBe(11); + expect(javaLevelFromGradle("java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }")).toBe(21); + expect(javaLevelFromGradle("kotlin { jvmToolchain(17) }")).toBe(17); + expect(javaLevelFromGradle("// sourceCompatibility = 17\nplugins {}")).toBeNull(); + }); + + it("finds the nearest build file below the project root", async () => { + expect(ancestorDirs("/p/a/src/A.java", "/p")).toEqual(["/p/a/src", "/p/a", "/p"]); + const files: Record = { + "/p/a/build.gradle": "plugins { id 'java' }", + "/p/pom.xml": "17", + }; + const read = (path: string) => + path in files ? Promise.resolve(files[path]!) : Promise.reject(new Error("missing")); + expect(await javaLanguageLevel("/p/a/src/Main.java", "/p", read)).toBe(17); + expect(await javaLanguageLevel("/q/Other.java", "/q", read)).toBeNull(); + }); + + it("detects C23", () => { + expect(cStandardIs23("CMakeLists.txt", "set(CMAKE_C_STANDARD 23)")).toBe(true); + expect(cStandardIs23("compile_flags.txt", "-std=c2x\n-Wall")).toBe(true); + expect(cStandardIs23("Makefile", "CFLAGS=-std=c11")).toBe(false); + }); +}); + +describe("occurrences", () => { + it("normalises whitespace between tokens but not inside strings", () => { + expect(normalizeExpression("a +\n b")).toBe("a+b"); + expect(normalizeExpression("new Foo( 1 )")).toBe("new Foo(1)"); + expect(normalizeExpression('f( "a b" )')).toBe('f("a b")'); + }); + + it("finds spans equal up to whitespace, whole words only", () => { + const text = 'x = a + b; y = a+b; z = aa + b; w = "a + b"; v = a + bc;'; + const found = findTextOccurrences(text, 0, text.length, "a + b").map((o) => text.slice(o.from, o.to)); + expect(found).toEqual(["a + b", "a+b", "a + b"]); + expect(findTextOccurrences(text, 10, text.length, "a+b").length).toBe(2); + }); + + it("lists the names of an expression outside literals", () => { + expect(expressionWords('foo(bar, "baz", qux.x)').map((w) => w.word)).toEqual(["foo", "bar", "qux", "x"]); + }); +}); diff --git a/src/editor/refactor/refactorThis.test.ts b/src/editor/refactor/refactorThis.test.ts new file mode 100644 index 00000000..ecd3e6c2 --- /dev/null +++ b/src/editor/refactor/refactorThis.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CodeAction, CodeActionQuery } from "@/bindings"; + +type Result = { status: "ok"; data: unknown } | { status: "error"; error: { kind: string; message: string } }; +const ok = (data: unknown): Result => ({ status: "ok", data }); +const unavailable: Result = { status: "error", error: { kind: "unavailable", message: "no server" } }; + +let query: (request: number) => Promise = async () => ok({ actions: [], advertisedKinds: null }); +let prepare: Result = ok(null); +const lspCodeActionQuery = vi.fn(async (request: number, _doc: number, _range: unknown, _only: string[]) => query(request)); +const lspPrepareRename = vi.fn(async () => prepare); +const lspApplyCodeAction = vi.fn(async () => ok(null)); +const lspCodeActions = vi.fn(async () => ok([])); +const cancelRequest = vi.fn(async () => undefined); +const languages = vi.fn(async () => [{ id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] }]); + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, lspCodeActionQuery, lspPrepareRename, lspApplyCodeAction, lspCodeActions, cancelRequest, languages }, + }; +}); +let expression = false; +vi.mock("@/editor/structure", () => ({ + structure: { + support: vi.fn(async () => ({ tree: true, locals: false })), + expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null })), + }, +})); +const showHint = vi.fn(); +vi.mock("@/editor/lsp/hint", () => ({ showHint })); +const introduceFallback = vi.fn(async () => true); +vi.mock("./introduce", () => ({ introduceFallback })); +const safeDelete = vi.fn(); +vi.mock("./safeDelete", () => ({ safeDelete })); + +const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalog"); +const { refactorThis, runRefactoring } = await import("./refactorThis"); +const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { lspDoc } = await import("@/editor/lsp/lsp"); + +// jsdom has no layout; popups anchor at the caret. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const action = (title: string, kind: string, disabledReason: string | null = null): CodeAction => ({ + title, + kind, + isPreferred: false, + disabledReason, + edit: null, + command: null, + data: null, + server: 1, +}); +const answer = (actions: CodeAction[], advertisedKinds: string[] | null = null) => async () => + ok({ actions, advertisedKinds } satisfies CodeActionQuery); + +let view: EditorView | null = null; +function editor(text = "fn main() { let total = price * 2; }") { + view = new EditorView({ + state: EditorState.create({ + doc: text, + selection: { anchor: text.indexOf("price") + 1 }, + extensions: [lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false })], + }), + }); + return view; +} +const labels = () => { + const p = useLspPopup.getState().popup; + return p?.kind === "list" ? p.items.map((i) => i.label) : null; +}; + +beforeEach(() => { + query = answer([]); + prepare = ok(null); + expression = false; +}); +afterEach(() => { + view?.destroy(); + view = null; + closePopup(); + vi.clearAllMocks(); +}); + +describe("catalog", () => { + it("maps server kinds and titles to IDEA refactorings", () => { + const c = (title: string, kind: string | null) => classifyAction({ title, kind }); + expect(c("Extract to function in module scope", "refactor.extract.function")).toBe("extractMethod"); + expect(c("Extract into function", "refactor.extract")).toBe("extractMethod"); + expect(c("Extract into variable", "refactor.extract")).toBe("extractVariable"); + expect(c("Extract to constant in enclosing scope", "refactor.extract.constant")).toBe("extractVariable"); + expect(c("Extract to constant in module scope", "refactor.extract.constant")).toBe("extractConstant"); + expect(c("Extract field", "refactor.extract.field")).toBe("extractField"); + expect(c("Introduce parameter", "refactor.extract.parameter")).toBe("extractParameter"); + expect(c("Inline variable", "refactor.inline")).toBe("inline"); + expect(c("Move to a new file", "refactor.move.newFile")).toBe("move"); + expect(c("Change signature", "refactor.changeSignature")).toBe("changeSignature"); + expect(c("Convert parameters to destructured object", "refactor.rewrite.parameters.toDestructured")).toBeNull(); + expect(c("Extract type alias", "refactor.extract.type")).toBeNull(); + expect(c("Remove unused", "quickfix")).toBeNull(); + // typescript-language-server reports plain `refactor` for everything. + expect(c("Extract to inner function in function 'formatMoney'", "refactor")).toBe("extractMethod"); + expect(c("Extract to constant in enclosing scope", "refactor")).toBe("extractVariable"); + expect(c("Extract to type alias", "refactor")).toBeNull(); + expect(c("Extract type as type alias", "refactor.extract")).toBeNull(); + expect(c("Extract struct from enum variant", "refactor.extract")).toBeNull(); + expect(c("Move to a new file", "refactor")).toBe("move"); + expect(c("Inline variable", "refactor")).toBe("inline"); + expect(c("Convert to template string", "refactor")).toBeNull(); + }); + + it("knows which kinds an advertised list can produce", () => { + expect(kindAdvertised(null, "refactor.move")).toBe(true); + expect(kindAdvertised(["quickfix", "refactor.extract"], "refactor.move")).toBe(false); + expect(kindAdvertised(["", "quickfix"], "refactor.move")).toBe(false); + expect(kindAdvertised(["refactor"], "refactor.move")).toBe(true); + expect(kindAdvertised(["refactor.move.newFile"], "refactor.move")).toBe(true); + }); + + it("formats IDEA's hint", () => { + expect(cannotPerform("Caret should be on a symbol.")).toBe("Cannot perform refactoring. Caret should be on a symbol"); + }); +}); + +describe("Refactor This", () => { + it("lists only the refactorings applicable at the caret, in IDEA order", async () => { + prepare = ok({ start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }); + query = answer([ + action("Inline variable", "refactor.inline"), + action("Extract into variable", "refactor.extract"), + action("Convert to raw string", "refactor.rewrite"), + action("Extract into function", "refactor.extract", "Can't extract here"), + ]); + await refactorThis(editor(), vi.fn()); + expect(labels()).toEqual(["Rename…", "Safe Delete…", "Introduce Variable…", "Inline…", "Convert to raw string"]); + expect(lspCodeActionQuery).toHaveBeenCalledOnce(); + expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]); + }); + + it("runs the mapped server action, Rename and Safe Delete", async () => { + prepare = ok({ start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }); + query = answer([action("Inline variable", "refactor.inline")]); + const rename = vi.fn(); + const v = editor(); + await refactorThis(v, rename); + const items = (useLspPopup.getState().popup as { items: { label: string; run: () => void }[] }).items; + items.find((i) => i.label === "Inline…")!.run(); + await new Promise((r) => setTimeout(r, 0)); + expect(lspApplyCodeAction).toHaveBeenCalledOnce(); + items.find((i) => i.label === "Rename…")!.run(); + expect(rename).toHaveBeenCalledWith(v); + items.find((i) => i.label === "Safe Delete…")!.run(); + expect(safeDelete).toHaveBeenCalledWith(v); + }); + + it("offers the syntax-based Introduce Variable / Constant when the server is down and an expression is there", async () => { + query = async () => unavailable; + expression = true; + await refactorThis(editor(), vi.fn()); + expect(labels()).toEqual(["Introduce Variable…", "Introduce Constant…"]); + }); + + it("says so when nothing applies", async () => { + const v = editor(); + await refactorThis(v, vi.fn()); + expect(useLspPopup.getState().popup).toBeNull(); + expect(showHint).toHaveBeenCalledWith(v, "No refactorings available at the caret"); + }); + + it("cancels a superseded query and drops answers after the caret moved", async () => { + let release: (r: Result) => void = () => {}; + query = (request) => new Promise((r) => (release = r)).then((x) => (void request, x as Result)); + const v = editor(); + const first = refactorThis(v, vi.fn()); + await new Promise((r) => setTimeout(r, 0)); + const firstId = lspCodeActionQuery.mock.calls[0]![0]; + const releaseFirst = release; + query = answer([action("Inline variable", "refactor.inline")]); + v.dispatch({ selection: { anchor: 2 } }); + const second = refactorThis(v, vi.fn()); + expect(cancelRequest).toHaveBeenCalledWith(firstId); + releaseFirst({ status: "error", error: { kind: "cancelled", message: "cancelled" } }); + await Promise.all([first, second]); + expect(labels()).toEqual(["Inline…"]); + + closePopup(); + let late: (r: Result) => void = () => {}; + query = () => new Promise((r) => (late = r)); + const third = refactorThis(v, vi.fn()); + await new Promise((r) => setTimeout(r, 0)); + v.dispatch({ selection: { anchor: 5 } }); + late(ok({ actions: [action("Inline variable", "refactor.inline")], advertisedKinds: null })); + await third; + expect(useLspPopup.getState().popup).toBeNull(); + }); +}); + +describe("Refactor menu actions", () => { + it("apply the single matching action, or offer a choice", async () => { + query = answer([action("Move to a new file", "refactor.move.newFile")]); + await runRefactoring(editor(), "move"); + expect(lspApplyCodeAction).toHaveBeenCalledOnce(); + expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor.move"]); + + closePopup(); + query = answer([action("Inline variable", "refactor.inline"), action("Inline call", "refactor.inline.call")]); + await runRefactoring(view!, "inline"); + expect(labels()).toEqual(["Inline variable", "Inline call"]); + }); + + it("say the server doesn't support it when its advertised kinds can't produce it", async () => { + query = answer([], ["quickfix", "refactor.extract", "refactor.inline"]); + const v = editor(); + await runRefactoring(v, "move"); + expect(showHint).toHaveBeenCalledWith(v, "Cannot perform refactoring. The language server for Rust doesn't support Move"); + }); + + it("give IDEA's caret reason, the server's disabled reason, or the missing server", async () => { + const v = editor(); + await runRefactoring(v, "inline"); + expect(showHint).toHaveBeenLastCalledWith( + v, + "Cannot perform refactoring. Caret should be positioned at the name of the element to be refactored", + ); + query = answer([action("Extract into function", "refactor.extract", "Selection is not a statement")]); + await runRefactoring(v, "extractMethod"); + expect(showHint).toHaveBeenLastCalledWith(v, "Cannot perform refactoring. Selection is not a statement"); + query = async () => unavailable; + await runRefactoring(v, "changeSignature"); + expect(showHint).toHaveBeenLastCalledWith(v, "Cannot perform refactoring. No language server for Rust is running"); + }); + + it("Introduce Variable falls back to the syntax refactoring", async () => { + query = async () => unavailable; + const v = editor(); + await runRefactoring(v, "extractVariable"); + expect(introduceFallback).toHaveBeenCalledWith(v, "variable", "notReady"); + expect(showHint).not.toHaveBeenCalled(); + }); + + it("never fall back to unrelated code actions", async () => { + await runRefactoring(editor(), "extractField"); + expect(lspCodeActions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/editor/refactor/refactorThis.ts b/src/editor/refactor/refactorThis.ts new file mode 100644 index 00000000..87e6d261 --- /dev/null +++ b/src/editor/refactor/refactorThis.ts @@ -0,0 +1,247 @@ +/** + * Refactor This (⌃T) and the Refactor menu actions. One cancellable + * `refactor*` code-action query per invocation; server actions are mapped to + * IDEA's refactorings (`catalog.ts`), client-side ones (Rename, Safe Delete, + * syntax-based Introduce Variable / Constant) are offered when they apply. + * Menu items always run, and say why with IDEA's "Cannot perform + * refactoring. " when nothing applies. + */ +import type { EditorView } from "@codemirror/view"; +import { forEachDiagnostic } from "@codemirror/lint"; +import type { CodeAction, CodeActionQuery } from "@/bindings"; +import { commands, isCancelled } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { whenSynced } from "@/editor/api"; +import { structure } from "@/editor/structure"; +import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; +import { offsetToPos, offsetsToRange } from "@/editor/lsp/positions"; +import { showHint } from "@/editor/lsp/hint"; +import { caretAnchor, openPopup, type ListPopupItem } from "@/editor/lsp/popupStore"; +import { applyCodeAction } from "@/editor/lsp/intentions"; +import { planLocalRename } from "@/editor/lsp/syntaxFallback"; +import { + REFACTORINGS, + cannotPerform, + classifyAction, + kindAdvertised, + refactoring, + type Refactoring, + type RefactoringId, +} from "./catalog"; +import { introduceFallback, type IntroduceKind } from "./introduce"; +import { languageName, queryRefactorActions } from "./query"; +import { safeDelete } from "./safeDelete"; + +/** Outcome of the server query: its answer, or why there is none. */ +export type ServerAnswer = { status: "ok"; query: CodeActionQuery } | { status: "notReady" } | { status: "error"; message: string }; + +async function askServer(view: EditorView, signal?: AbortSignal, only?: string[]): Promise { + try { + return { status: "ok", query: await queryRefactorActions(view, signal, only) }; + } catch (e) { + if (isCancelled(e)) throw e; + if (isUnavailable(e)) return { status: "notReady" }; + return { status: "error", message: e instanceof Error ? e.message : String(e) }; + } +} + +/** Server actions per IDEA refactoring, plus the unmapped refactorings (by title). */ +export function groupActions(actions: readonly CodeAction[]): { + mapped: Map; + other: CodeAction[]; +} { + const mapped = new Map(); + const other: CodeAction[] = []; + for (const a of actions) { + if (a.disabledReason) continue; + const id = classifyAction(a); + if (!id) { + other.push(a); + continue; + } + let list = mapped.get(id); + if (!list) mapped.set(id, (list = [])); + list.push(a); + } + return { mapped, other }; +} + +/** Applies the single action, or lets the user pick one. */ +function runActions(view: EditorView, title: string, actions: readonly CodeAction[]) { + if (actions.length === 1) return applyCodeAction(view, actions[0]!); + openPopup({ + kind: "list", + anchor: caretAnchor(view, view.state.selection.main.head), + title, + items: actions.map((a, i) => ({ + id: `${i}`, + label: a.title, + icon: "bulb", + emphasis: a.isPreferred, + run: () => applyCodeAction(view, a), + })), + }); +} + +const INTRODUCE: Partial> = { + extractVariable: "variable", + extractConstant: "constant", +}; + +/** First error diagnostic on the selected lines (servers offer nothing over broken code). */ +function errorAround(view: EditorView): string | null { + const sel = view.state.selection.main; + const from = view.state.doc.lineAt(sel.from).from; + const to = view.state.doc.lineAt(sel.to).to; + let error: string | null = null; + forEachDiagnostic(view.state, (d, a, b) => { + if (!error && d.severity === "error" && a <= to && b >= from) error = d.message; + }); + return error; +} + +/** IDEA's reason for `r` being unavailable, given the server's answer. */ +export async function unavailableReason(view: EditorView, r: Refactoring, answer: ServerAnswer, actions: readonly CodeAction[]): Promise { + const ctx = docOf(view); + const lang = await languageName(ctx?.language ?? ""); + if (answer.status === "notReady") return `No language server for ${lang} is running`; + if (answer.status === "error") return answer.message; + const disabled = actions.find((a) => a.disabledReason && classifyAction(a) === r.id)?.disabledReason; + if (disabled) return disabled; + if (r.kind && !kindAdvertised(answer.query.advertisedKinds, r.kind)) { + return `The language server for ${lang} doesn't support ${r.label.replace(/…$/, "")}`; + } + const error = errorAround(view); + const reason = r.reason(!view.state.selection.main.empty); + return error ? `${reason} (fix the error first: ${error})` : reason; +} + +let menuRun: AbortController | null = null; + +/** A Refactor menu action (Move…, Inline…, Extract Method…): runs it or says why it can't. */ +export async function runRefactoring(view: EditorView, id: RefactoringId): Promise { + const r = refactoring(id); + const ctx = docOf(view); + if (!ctx) return; + if (ctx.readOnly) { + showHint(view, cannotPerform("The file is read-only")); + return; + } + menuRun?.abort(); + const run = (menuRun = new AbortController()); + const state = view.state; + let answer: ServerAnswer; + try { + answer = await askServer(view, run.signal, r.kind ? [r.kind] : undefined); + } catch (e) { + if (isCancelled(e)) return; + throw e; + } finally { + if (menuRun === run) menuRun = null; + } + if (run.signal.aborted || view.state.doc !== state.doc || !view.state.selection.eq(state.selection)) return; + const all = answer.status === "ok" ? answer.query.actions : []; + const actions = groupActions(all).mapped.get(id) ?? []; + if (actions.length) { + await runActions(view, r.label.replace(/…$/, ""), actions); + return; + } + const introduce = INTRODUCE[id]; + if (introduce) { + const reason = answer.status === "ok" ? "empty" : answer.status === "notReady" ? "notReady" : "error"; + if (await introduceFallback(view, introduce, reason)) return; + } + showHint(view, cannotPerform(await unavailableReason(view, r, answer, all))); +} + +/** Whether Rename (and so Safe Delete) has a symbol to act on at the caret. */ +async function symbolAtCaret(view: EditorView, serverUp: boolean): Promise { + const ctx = docOf(view)!; + const pos = offsetToPos(view.state.doc, view.state.selection.main.head); + if (serverUp) { + try { + if (await lspRequest(() => commands.lspPrepareRename(ctx.doc, pos))) return true; + } catch (e) { + if (isCancelled(e)) throw e; + } + } + try { + return (await planLocalRename(ctx, pos)).kind === "plan"; + } catch (e) { + if (isCancelled(e)) throw e; + return false; + } +} + +async function expressionAtCaret(view: EditorView, signal: AbortSignal): Promise { + const ctx = docOf(view)!; + const sel = view.state.selection.main; + try { + const at = await structure.expressionAt(ctx.doc, offsetsToRange(view.state.doc, sel.from, sel.to), { signal }); + return !!at.expression; + } catch (e) { + if (isCancelled(e)) throw e; + return false; + } +} + +/** Items of the Refactor This popup, IDEA order: applicable IDEA refactorings, then the server's others. */ +export function refactorThisItems( + view: EditorView, + answer: ServerAnswer, + facts: { symbol: boolean; expression: boolean }, + run: { rename: (view: EditorView) => unknown; safeDelete: (view: EditorView) => unknown }, +): ListPopupItem[] { + const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []); + const serverUsable = answer.status === "ok"; + const items: ListPopupItem[] = []; + for (const r of REFACTORINGS) { + const actions = mapped.get(r.id) ?? []; + let go: (() => unknown) | null = null; + if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions); + else if (r.id === "rename" && facts.symbol) go = () => run.rename(view); + else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view); + else if (INTRODUCE[r.id] && facts.expression && !serverUsable) + go = () => introduceFallback(view, INTRODUCE[r.id]!, answer.status === "notReady" ? "notReady" : "error"); + if (!go) continue; + const exec = go; + items.push({ id: r.id, label: r.label, shortcut: shortcutLabel(r.action), run: () => void exec() }); + } + other.forEach((a, i) => + items.push({ id: `server${i}`, label: a.title, icon: "bulb", emphasis: a.isPreferred, run: () => applyCodeAction(view, a) }), + ); + return items; +} + +let thisRun: AbortController | null = null; + +/** ⌃T: only the refactorings applicable at the caret. */ +export async function refactorThis(view: EditorView, rename: (view: EditorView) => unknown): Promise { + const ctx = docOf(view); + if (!ctx) return; + thisRun?.abort(); + const run = (thisRun = new AbortController()); + await whenSynced(view); + const state = view.state; + let items: ListPopupItem[]; + try { + const answer = await askServer(view, run.signal); + const [symbol, expression] = await Promise.all([ + symbolAtCaret(view, answer.status !== "notReady"), + expressionAtCaret(view, run.signal), + ]); + items = refactorThisItems(view, answer, { symbol, expression }, { rename, safeDelete }); + } catch (e) { + if (isCancelled(e)) return; + throw e; + } finally { + if (thisRun === run) thisRun = null; + } + // Superseded, or the caret moved / the text changed meanwhile: the answer is stale. + if (run.signal.aborted || view.state.doc !== state.doc || !view.state.selection.eq(state.selection)) return; + if (!items.length) { + showHint(view, "No refactorings available at the caret"); + return; + } + openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items }); +} diff --git a/src/editor/refactor/runKind.test.ts b/src/editor/refactor/runKind.test.ts new file mode 100644 index 00000000..ab1b993a --- /dev/null +++ b/src/editor/refactor/runKind.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CodeAction } from "@/bindings"; + +let answer: () => unknown = () => ({ status: "ok", data: [] }); +const lspCodeActions = vi.fn(async () => answer()); +const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null })); + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspCodeActions, lspApplyCodeAction } }; +}); +const showHint = vi.fn(); +vi.mock("@/editor/lsp/hint", () => ({ showHint })); + +const { runKindAction } = await import("@/editor/lsp/intentions"); +const { lspDoc } = await import("@/editor/lsp/lsp"); + +const action = (title: string, disabledReason: string | null = null): CodeAction => ({ + title, + kind: "refactor.extract.constant", + isPreferred: false, + disabledReason, + edit: null, + command: null, + data: null, + server: 1, +}); + +let view: EditorView | null = null; +afterEach(() => { + view?.destroy(); + showHint.mockClear(); + lspApplyCodeAction.mockClear(); +}); + +function run(fallback: (reason: "notReady" | "error" | "empty") => Promise) { + view = new EditorView({ + state: EditorState.create({ + doc: "const a = 1;", + extensions: [lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false })], + }), + }); + return runKindAction(view, "refactor.extract", { + title: /constant/i, + none: "Nothing", + label: "Introduce Constant", + fallback, + }); +} + +describe("runKindAction fallback", () => { + it("uses the server's action when there is one", async () => { + answer = () => ({ status: "ok", data: [action("Extract to constant")] }); + const fallback = vi.fn(async () => true); + await run(fallback); + expect(fallback).not.toHaveBeenCalled(); + expect(lspApplyCodeAction).toHaveBeenCalledOnce(); + }); + + it("falls back when the server is not ready or fails", async () => { + answer = () => ({ status: "error", error: { kind: "unavailable", message: "no server" } }); + const fallback = vi.fn(async () => true); + await run(fallback); + expect(fallback).toHaveBeenCalledWith("notReady"); + answer = () => ({ status: "error", error: { kind: "protocol", message: "boom" } }); + await run(fallback); + expect(fallback).toHaveBeenLastCalledWith("error"); + expect(showHint).not.toHaveBeenCalled(); + }); + + it("asks the fallback on an empty answer and keeps the server's reason when it declines", async () => { + answer = () => ({ status: "ok", data: [action("Extract to constant", "Cannot extract empty range")] }); + const fallback = vi.fn(async () => false); + await run(fallback); + expect(fallback).toHaveBeenCalledWith("empty"); + expect(showHint).toHaveBeenCalledWith(expect.anything(), "Cannot extract empty range"); + }); +}); diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts new file mode 100644 index 00000000..5ea15704 --- /dev/null +++ b/src/editor/refactor/safeDelete.test.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { history, undo } from "@codemirror/commands"; +import type { LocalSymbol, Location, Locals, OutlineItem, Range, StructuralRanges, SyntaxNode } from "@/bindings"; + +// --- backend fakes --------------------------------------------------------- + +type Answer = () => { status: "ok"; data: T } | { status: "error"; error: { kind: string; message: string } }; +const ok = (data: T) => () => ({ status: "ok" as const, data }); +const unavailable = () => ({ status: "error" as const, error: { kind: "unavailable", message: "no server" } }); + +let goto: Answer = ok([]); +let outline: Answer = ok([]); +let references: Answer = ok([]); +const lspGoto = vi.fn(async () => goto()); +const docOutline = vi.fn(async () => outline()); +const lspReferences = vi.fn(async () => references()); +const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] })); +const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null })); +const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } })); +const docClose = vi.fn(async () => ({ status: "ok", data: null })); +const languages = vi.fn(async () => [ + { id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] }, + { id: "python", name: "Python", lineComment: "#", blockComment: null }, +]); + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { + ...actual.commands, + lspGoto, + docOutline, + lspReferences, + lspCodeActions, + lspApplyCodeAction, + docOpen, + docClose, + languages, + }, + }; +}); + +let locals: Locals = { status: "noIdentifier" }; +let statement: SyntaxNode | null = null; +vi.mock("@/editor/structure", () => ({ + structure: { + locals: vi.fn(async () => locals), + structuralRanges: vi.fn(async (): Promise> => ({ statement })), + }, +})); +const showHint = vi.fn(); +vi.mock("@/editor/lsp/hint", () => ({ showHint })); +const executeAction = vi.fn(); +vi.mock("@/platform/actions", () => ({ executeAction })); + +const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } = await import("./safeDelete"); +const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { lspDoc } = await import("@/editor/lsp/lsp"); + +// --- helpers ---------------------------------------------------------------- + +const doc = (s: string) => Text.of(s.split("\n")); +const posOf = (text: string, off: number) => { + const line = doc(text).lineAt(off); + return { line: line.number - 1, col: off - line.from }; +}; +const rangeOf = (text: string, from: number, to: number): Range => ({ start: posOf(text, from), end: posOf(text, to) }); +/** Removes `deletionRange` of the `needle` span from `text`. */ +function cut(text: string, needle: string, comments?: { line: string | null; block: [string, string] | null }) { + const from = text.indexOf(needle); + const r = deletionRange(doc(text), from, from + needle.length, comments); + return r && text.slice(0, r.from) + text.slice(r.to); +} + +let view: EditorView | null = null; +function editor(text: string, caretNeedle: string, language = "rust") { + view = new EditorView({ + state: EditorState.create({ + doc: text, + selection: { anchor: text.indexOf(caretNeedle) + 1 }, + extensions: [history(), lspDoc.of({ doc: 1, path: "/p/a.rs", language, uiMode: null, readOnly: false })], + }), + }); + return view; +} + +function item(text: string, name: string, decl: string, kind: OutlineItem["kind"] = "function"): OutlineItem { + const from = text.indexOf(decl); + const at = text.indexOf(name, from); + return { + name, + detail: null, + kind, + range: rangeOf(text, from, from + decl.length), + selection_range: rangeOf(text, at, at + name.length), + children: [], + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + goto = ok([]); + outline = ok([]); + references = ok([]); + locals = { status: "noIdentifier" }; + statement = null; +}); + +afterEach(() => { + view?.destroy(); + view = null; + closePopup(); + vi.clearAllMocks(); +}); + +// --- tests -------------------------------------------------------------------- + +describe("deletionRange", () => { + it("removes whole lines plus the trailing blank line", () => { + expect(cut("fn a() {}\n\nfn unused() {}\n\nfn b() {}\n", "fn unused() {}")).toBe("fn a() {}\n\nfn b() {}\n"); + }); + + it("takes the doc comment and annotations directly above", () => { + const text = "fn a() {}\n\n/**\n * Docs.\n */\n#[inline]\nfn unused() {}\n\nfn b() {}\n"; + expect(cut(text, "fn unused() {}")).toBe("fn a() {}\n\nfn b() {}\n"); + const java = "class A {\n // keep\n\n /// doc\n @Deprecated\n void unused() {}\n\n void b() {}\n}\n"; + expect(cut(java, "void unused() {}")).toBe("class A {\n // keep\n\n void b() {}\n}\n"); + }); + + it("takes the blank line above when the declaration ends its block", () => { + expect(cut("fn f() {\n a();\n\n let x = 1;\n}\n", "let x = 1;")).toBe("fn f() {\n a();\n}\n"); + }); + + it("handles the last line of the document", () => { + expect(cut("fn a() {}\n\nfn unused() {}", "fn unused() {}")).toBe("fn a() {}"); + expect(cut("fn a() {}\nfn unused() {}", "fn unused() {}")).toBe("fn a() {}"); + }); + + it("uses the language's comment syntax (Python comments and decorators)", () => { + const py = "import x\n\n# helper\n@cache\ndef unused():\n pass\n\ndef b():\n pass\n"; + expect(cut(py, "def unused():\n pass", { line: "#", block: null })).toBe("import x\n\ndef b():\n pass\n"); + }); + + it("accepts a trailing separator or comment but refuses shared lines", () => { + expect(cut("let a = 1;\nlet b = 2; // note\nlet c = 3;\n", "let b = 2")).toBe("let a = 1;\nlet c = 3;\n"); + expect(cut("struct S {\n a: i32,\n b: i32,\n}\n", "a: i32")).toBe("struct S {\n b: i32,\n}\n"); + expect(cut("let a = 1; let b = 2;\n", "let b = 2;")).toBeNull(); + expect(cut("f(a, b);\n", "a")).toBeNull(); + }); +}); + +describe("outlineItemAt", () => { + it("finds the innermost item whose name contains the position", () => { + const text = "impl S {\n fn m() {}\n}\n"; + const m = item(text, "m", "fn m() {}", "method"); + const s = { ...item(text, "S", "impl S {\n fn m() {}\n}", "struct"), children: [m] }; + expect(outlineItemAt([s], posOf(text, text.indexOf("m()")))).toBe(m); + expect(outlineItemAt([s], posOf(text, text.indexOf("S")))).toBe(s); + expect(outlineItemAt([s], posOf(text, text.indexOf("{}")))).toBeNull(); + }); +}); + +describe("localDeclaration", () => { + const text = "fn f(p: i32) {\n let x = 1;\n let (a, b) = t;\n let m: HashMap = m();\n for i in v {}\n}\n"; + const sym = (name: string, kind: LocalSymbol["kind"] = "var", after = "{"): LocalSymbol => { + const at = text.indexOf(` ${name}`, text.indexOf(after)) + 1; + return { name, kind, definition: rangeOf(text, at, at + name.length), scope: rangeOf(text, 0, text.length), references: [], certain: true, conflicts: [] }; + }; + const stmt = (kind: string, needle: string): SyntaxNode => { + const at = text.indexOf(needle); + return { kind, range: rangeOf(text, at, at + needle.length), field: null, named: true, error: false, missing: false, hasError: false }; + }; + + it("returns the declaring statement of a single local", () => { + expect(localDeclaration(doc(text), sym("x"), stmt("let_declaration", "let x = 1;"))).toEqual(stmt("let_declaration", "let x = 1;").range); + expect(localDeclaration(doc(text), sym("m"), stmt("let_declaration", "let m: HashMap = m();"))).not.toBeTypeOf("string"); + }); + + it("refuses parameters, destructuring, loop variables and ambiguous locals", () => { + expect(localDeclaration(doc(text), sym("p", "parameter", "f"), null)).toMatch(/Change Signature/); + expect(localDeclaration(doc(text), sym("b"), stmt("let_declaration", "let (a, b) = t;"))).toMatch(/other names/); + expect(localDeclaration(doc(text), sym("i"), stmt("for_expression", "for i in v {}"))).toMatch(/separate statement/); + expect(localDeclaration(doc(text), { ...sym("x"), certain: false }, stmt("let_declaration", "let x = 1;"))).toMatch(/more than once/); + }); +}); + +describe("usagesMessage", () => { + it("says how many usages and where", () => { + const loc = (path: string): Location => ({ path, range: rangeOf("x", 0, 1) }); + expect(usagesMessage("Function 'f'", [loc("/a")])).toEqual({ + message: "1 usage found", + detail: "Function 'f' has 1 usage that is not safe to delete.", + }); + expect(usagesMessage("The 2 files", [loc("/a"), loc("/b")], true).detail).toBe( + "The 2 files have 2 usages that are not safe to delete (in 2 files).", + ); + }); +}); + +describe("safeDelete in the editor", () => { + const text = "fn used() {}\n\nfn unused() {\n unused();\n}\n\nfn main() {}\n"; + + it("deletes an unused declaration as one undo step, ignoring recursive calls", async () => { + const v = editor(text, "unused() {"); + outline = ok([item(text, "used", "fn used() {}"), item(text, "unused", "fn unused() {\n unused();\n}")]); + const self = text.indexOf("unused();"); + references = ok([{ path: "/p/a.rs", range: rangeOf(text, self, self + 6) }]); + await safeDelete(v); + expect(v.state.doc.toString()).toBe("fn used() {}\n\nfn main() {}\n"); + expect(lspCodeActions).not.toHaveBeenCalled(); + expect(lspApplyCodeAction).not.toHaveBeenCalled(); + undo(v); + expect(v.state.doc.toString()).toBe(text); + }); + + it("asks when usages exist: View Usages / Delete Anyway / Cancel", async () => { + const usage: Location = { path: "/p/b.rs", range: { start: { line: 3, col: 4 }, end: { line: 3, col: 8 } } }; + outline = ok([item(text, "used", "fn used() {}")]); + references = ok([usage]); + + const run = async (choice: string) => { + const v = editor(text, "used() {}"); + const done = safeDelete(v); + for (let i = 0; i < 20 && useLspPopup.getState().popup?.kind !== "choice"; i++) await flush(); + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "choice") throw new Error("no dialog"); + expect(popup.title).toBe("Usages Detected"); + expect(popup.message).toBe("1 usage found"); + expect(popup.choices.map((c) => c.label)).toEqual(["View Usages", "Delete Anyway", "Cancel"]); + popup.choices.find((c) => c.label === choice)!.run(); + closePopup(); + await done; + return v; + }; + + expect((await run("Cancel")).state.doc.toString()).toBe(text); + expect(executeAction).not.toHaveBeenCalled(); + expect((await run("View Usages")).state.doc.toString()).toBe(text); + expect(executeAction).toHaveBeenCalledWith("find.showUsageResults", { symbol: "used", locations: [usage] }); + expect((await run("Delete Anyway")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n"); + }); + + it("refuses a declaration in another file instead of doing something else", async () => { + const v = editor(text, "main"); + goto = ok([{ path: "/p/other.rs", range: rangeOf(text, 0, 1) }]); + await safeDelete(v); + expect(showHint).toHaveBeenCalledWith(v, "Cannot perform refactoring. 'main' is declared in other.rs; Safe Delete it there"); + expect(v.state.doc.toString()).toBe(text); + }); + + it("without a language server deletes only locals, using the syntax references", async () => { + const body = "fn f() {\n let x = 1;\n let y = 2;\n g(y);\n}\n"; + goto = unavailable; + references = unavailable; + const x = body.indexOf("x"); + locals = { + status: "local", + symbol: { name: "x", kind: "var", definition: rangeOf(body, x, x + 1), scope: rangeOf(body, 0, body.length), references: [], certain: true, conflicts: [] }, + }; + const at = body.indexOf("let x"); + statement = { kind: "let_declaration", range: rangeOf(body, at, at + 10), field: null, named: true, error: false, missing: false, hasError: false }; + const v = editor(body, " x ="); + v.dispatch({ selection: { anchor: x } }); + await safeDelete(v); + expect(v.state.doc.toString()).toBe("fn f() {\n let y = 2;\n g(y);\n}\n"); + expect(lspReferences).not.toHaveBeenCalled(); + }); + + it("without a language server refuses top-level declarations with the reason", async () => { + goto = unavailable; + outline = ok([item(text, "used", "fn used() {}")]); + locals = { status: "notLocal", name: "used", range: rangeOf(text, 3, 7), reason: "topLevel" }; + const v = editor(text, "used() {}"); + await safeDelete(v); + expect(showHint).toHaveBeenCalledWith( + v, + "Cannot perform refactoring. The language server for Rust is not available; only local declarations can be deleted safely without it", + ); + expect(v.state.doc.toString()).toBe(text); + }); + + it("needs a symbol at the caret", async () => { + const v = editor("fn a() {}\n\n\n", "\n\n"); + v.dispatch({ selection: { anchor: 11 } }); + await safeDelete(v); + expect(showHint).toHaveBeenCalledWith(v, "Cannot perform refactoring. Caret should be on a symbol"); + }); +}); + +describe("fileUsages", () => { + const text = "pub fn a() {}\npub struct B;\n"; + + it("collects references to top-level symbols from other files", async () => { + outline = ok([item(text, "a", "pub fn a() {}"), item(text, "B", "pub struct B;", "struct")]); + const r = (path: string, line: number): Location => ({ path, range: { start: { line, col: 0 }, end: { line, col: 1 } } }); + references = ok([r("/p/a.rs", 0), r("/p/main.rs", 4), r("/p/main.rs", 4), r("/p/gone.rs", 1)]); + const res = await fileUsages(["/p/a.rs", "/p/gone.rs"]); + expect(res).toEqual({ status: "ok", usages: [r("/p/main.rs", 4)] }); + expect(docClose).toHaveBeenCalledTimes(2); + }); + + it("is unsupported when the language server can't search", async () => { + outline = ok([item(text, "a", "pub fn a() {}")]); + references = unavailable; + expect((await fileUsages(["/p/a.rs"])).status).toBe("unsupported"); + }); + + it("is cancellable", async () => { + const abort = new AbortController(); + abort.abort(); + await expect(fileUsages(["/p/a.rs"], abort.signal)).rejects.toMatchObject({ kind: "cancelled" }); + }); +}); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts new file mode 100644 index 00000000..cdc955ab --- /dev/null +++ b/src/editor/refactor/safeDelete.ts @@ -0,0 +1,436 @@ +/** + * Safe Delete (⌘⌦): resolves the declaration at the caret (LSP definition + + * document symbols; the tree-sitter structure service for local declarations + * only), searches its usages (LSP references, the declaration's own body + * excluded), then deletes it — with attached doc comments / annotations and + * the trailing blank line — as one undo step, or, when it is still used, + * asks like IDEA: "Usages Detected" → View Usages / Delete Anyway / Cancel. + * + * Files (project view): references to the file's top-level symbols from other + * files, when the language server can answer (`fileUsages`). + */ +import type { EditorView } from "@codemirror/view"; +import type { Text } from "@codemirror/state"; +import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings"; +import { call, commands, isCancelled, isTauri } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; +import { basename } from "@/platform/project"; +import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api"; +import { structure } from "@/editor/structure"; +import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; +import { comparePos, offsetToPos, rangeContains, rangeToOffsets } from "@/editor/lsp/positions"; +import { showHint } from "@/editor/lsp/hint"; +import { openPopup, useLspPopup } from "@/editor/lsp/popupStore"; +import { cannotPerform } from "./catalog"; +import { cancelled, languageName } from "./query"; + +// --------------------------------------------------------------------------- +// Deletion range (pure) + +export interface CommentSyntax { + line: string | null; + block: [string, string] | null; +} + +const C_LIKE: CommentSyntax = { line: "//", block: ["/*", "*/"] }; + +/** Annotation / attribute / decorator lines that belong to the next declaration. */ +const ANNOTATION = /^(@[A-Za-z_][\w.]*|#\[)/; + +/** + * The text to remove for a declaration spanning `from..to`: whole lines, plus + * the doc comments / annotations directly above it and the trailing blank + * line (or the blank line above it when nothing follows in its block). Null + * when the declaration shares a line with other code (e.g. `int a, b;`). + */ +export function deletionRange( + doc: Text, + from: number, + to: number, + comments: CommentSyntax = C_LIKE, +): { from: number; to: number } | null { + const first = doc.lineAt(from); + const last = doc.lineAt(Math.max(from, to)); + if (doc.sliceString(first.from, from).trim()) return null; + const rest = doc.sliceString(to, last.to).trim(); + const restOk = + !rest || + /^[;,]$/.test(rest) || + (!!comments.line && (rest.startsWith(comments.line) || /^[;,]\s*/.test(rest) && rest.replace(/^[;,]\s*/, "").startsWith(comments.line))); + if (!restOk) return null; + + // Attached comments and annotations above (no blank line in between). + let startLine = first.number; + const block = comments.block; + for (let n = first.number - 1; n >= 1; n--) { + const text = doc.line(n).text.trim(); + if (!text) break; + if (block && text.endsWith(block[1])) { + let open = n; + while (open >= 1 && !doc.line(open).text.includes(block[0])) open--; + if (open < 1 || doc.line(open).text.trim().indexOf(block[0]) !== 0) break; + startLine = open; + n = open; + continue; + } + if ((comments.line && text.startsWith(comments.line)) || ANNOTATION.test(text)) { + startLine = n; + continue; + } + break; + } + + let start = doc.line(startLine).from; + let end = last.to; + const blank = (n: number) => n >= 1 && n <= doc.lines && !doc.line(n).text.trim(); + if (last.number < doc.lines) { + end = last.to + 1; + const next = last.number + 1; + if (blank(next) && next < doc.lines) end = doc.line(next).to + 1; + else if (blank(next)) end = doc.line(next).to; + else if (blank(startLine - 1) && /^[}\])]|^end\b/.test(doc.line(next).text.trim())) start = doc.line(startLine - 1).from; + } else if (startLine > 1) { + // Last line: take the line break before it (and a blank line above). + start = doc.line(blank(startLine - 1) && startLine > 2 ? startLine - 2 : startLine - 1).to; + } + return { from: start, to: end }; +} + +// --------------------------------------------------------------------------- +// Declaration at the caret + +export interface SafeDeleteTarget { + name: string; + /** "Function", "Class", "Variable"… for messages. */ + kind: string; + /** The declaring identifier (references are asked there). */ + at: Pos; + /** Declaration extent in the document it was resolved against. */ + range: Range; + /** Resolved from the syntax tree (no language server): usages are the local's references. */ + local: LocalSymbol | null; +} + +export type Resolution = { status: "ok"; target: SafeDeleteTarget } | { status: "refused"; reason: string }; + +const KIND_LABEL: Record = { + enumMember: "Enum constant", + typeParameter: "Type parameter", + typeAlias: "Type alias", + var: "Variable", + function: "Function", + import: "Import", +}; + +function kindLabel(kind: string): string { + return KIND_LABEL[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1); +} + +/** Innermost outline item whose name range contains `pos`. */ +export function outlineItemAt(items: readonly OutlineItem[], pos: Pos): OutlineItem | null { + for (const item of items) { + if (!rangeContains(item.range, pos)) continue; + const inner = outlineItemAt(item.children, pos); + if (inner) return inner; + if (rangeContains(item.selection_range, pos)) return item; + } + return null; +} + +const UNDELETABLE = new Set(["file", "package", "namespace"]); + +const DECLARATION_STATEMENT = + /declaration|assignment|^let|local|^var|^const|import|^use|expression_statement|property|short_var|definition$/; +const COMPOUND_STATEMENT = /^(for|while|if|with|try|catch|switch|match|do|loop|foreach|enhanced_for|case)/; +const FUNCTION_STATEMENT = /function|method|^def|^fn|func|procedure/; + +/** Declaration statement of a syntax-resolved local, or why it can't be deleted on its own. */ +export function localDeclaration(doc: Text, symbol: LocalSymbol, statement: SyntaxNode | null): Range | string { + if (symbol.kind === "parameter") return "Deleting a parameter changes its call sites; use Change Signature"; + if (symbol.kind === "label" || symbol.kind === "type" || symbol.kind === "macro") return "Caret should be on a variable or function declaration"; + if (!symbol.certain) return `'${symbol.name}' is declared more than once in this scope`; + if (!statement || !rangeContains(statement.range, symbol.definition.start)) return "The declaration could not be determined"; + const kind = statement.kind; + if (symbol.kind === "function") { + return FUNCTION_STATEMENT.test(kind) ? statement.range : "The declaration could not be determined"; + } + if (COMPOUND_STATEMENT.test(kind) || !DECLARATION_STATEMENT.test(kind)) return `'${symbol.name}' is not declared by a separate statement`; + const { from, to } = rangeToOffsets(doc, statement.range); + const defEnd = rangeToOffsets(doc, symbol.definition).to; + // Up to the initializer, the statement must declare only this name (no `a, b` / destructuring). + const after = doc.sliceString(defEnd, to); + let declarator = doc.sliceString(from, defEnd) + after.slice(0, Math.max(0, after.search(/=|;|$/))); + for (let i = 0; i < 5; i++) declarator = declarator.replace(/<[^<>]*>/g, ""); + if (/[,([{]/.test(declarator)) return "The declaration declares other names too"; + return statement.range; +} + +async function resolveTarget(view: EditorView, ctx: EditorDocContext): Promise { + const doc = view.state.doc; + const head = view.state.selection.main.head; + const pos = offsetToPos(doc, head); + const word = view.state.wordAt(head); + if (!word) return { status: "refused", reason: "Caret should be on a symbol" }; + const name = view.state.sliceDoc(word.from, word.to); + + let lsp = true; + let defPos = pos; + try { + const locs = await lspRequest(() => commands.lspGoto(ctx.doc, pos, "definition")); + const here = locs.find((l) => l.path === ctx.path); + if (here) defPos = here.range.start; + else if (locs.length) { + return { status: "refused", reason: `'${name}' is declared in ${basename(locs[0]!.path)}; Safe Delete it there` }; + } + } catch (e) { + if (isCancelled(e)) throw e; + lsp = false; + } + + const outline = await lspRequest(() => commands.docOutline(ctx.doc)).catch(() => [] as OutlineItem[]); + const item = outlineItemAt(outline, defPos); + if (item && !UNDELETABLE.has(item.kind)) { + if (lsp) + return { + status: "ok", + target: { name: item.name, kind: kindLabel(item.kind), at: item.selection_range.start, range: item.range, local: null }, + }; + } + + const locals = await structure.locals(ctx.doc, defPos).catch((e: unknown) => { + if (isCancelled(e)) throw e; + return null; + }); + if (locals?.status === "local") { + const { symbol } = locals; + const ranges = await structure.structuralRanges(ctx.doc, symbol.definition.start).catch(() => null); + const decl = localDeclaration(doc, symbol, ranges?.statement ?? null); + if (typeof decl === "string") return { status: "refused", reason: decl }; + return { + status: "ok", + target: { + name: symbol.name, + kind: kindLabel(symbol.kind === "other" ? "variable" : symbol.kind), + at: symbol.definition.start, + range: decl, + local: lsp ? null : symbol, + }, + }; + } + if (!lsp) { + const lang = await languageName(ctx.language); + return { + status: "refused", + reason: `The language server for ${lang} is not available; only local declarations can be deleted safely without it`, + }; + } + return { status: "refused", reason: item ? `'${name}' can't be deleted` : "Caret should be on the name of a declaration" }; +} + +/** LSP references to the target, its own declaration excluded. */ +async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Promise { + const outside = (path: string, range: Range) => + path !== ctx.path || comparePos(range.start, target.range.start) < 0 || comparePos(range.start, target.range.end) > 0; + if (target.local) return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range })); + const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false)); + return refs.filter((l) => outside(l.path, l.range)); +} + +// --------------------------------------------------------------------------- +// Usages Detected dialog + +export type UsagesAnswer = "view" | "delete" | "cancel"; + +/** `subject`: "Function 'foo'", "File 'a.ts'", "3 files" (`plural`). */ +export function usagesMessage(subject: string, usages: readonly Location[], plural = false): { message: string; detail: string } { + const n = usages.length; + const files = new Set(usages.map((u) => u.path)).size; + const noun = n === 1 ? "usage that is" : "usages that are"; + return { + message: `${n} ${n === 1 ? "usage" : "usages"} found`, + detail: `${subject} ${plural ? "have" : "has"} ${n} ${noun} not safe to delete${files > 1 ? ` (in ${files} files)` : ""}.`, + }; +} + +/** IDEA's "Usages Detected" question; resolves "cancel" when dismissed or replaced. */ +export function askUsagesDetected(subject: string, usages: readonly Location[], plural = false): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (a: UsagesAnswer) => { + if (done) return; + done = true; + unsub(); + resolve(a); + }; + const { message, detail } = usagesMessage(subject, usages, plural); + openPopup({ + kind: "choice", + title: "Usages Detected", + message, + detail, + choices: [ + { label: "View Usages", primary: true, run: () => finish("view") }, + { label: "Delete Anyway", run: () => finish("delete") }, + { label: "Cancel", run: () => finish("cancel") }, + ], + onDismiss: () => finish("cancel"), + }); + const mine = useLspPopup.getState().popup; + const unsub = useLspPopup.subscribe((s) => { + if (s.popup !== mine) finish("cancel"); + }); + }); +} + +/** Opens the Find Usages tool window with `usages` (code-insight's `find.showUsageResults`). */ +export function viewUsages(symbol: string, usages: readonly Location[]) { + void executeAction("find.showUsageResults", { symbol, locations: usages }); +} + +// --------------------------------------------------------------------------- +// Editor action + +async function commentSyntax(language: string): Promise { + if (!isTauri()) return C_LIKE; + try { + const info = (await commands.languages()).find((l) => l.id === language); + if (info) return { line: info.lineComment, block: info.blockComment }; + } catch { + /* default below */ + } + return C_LIKE; +} + +function applyDeletion(view: EditorView, docBefore: Text, target: SafeDeleteTarget, comments: CommentSyntax): boolean { + if (view.state.doc !== docBefore) { + showHint(view, "The file changed; Safe Delete cancelled", { tone: "warning" }); + return false; + } + const { from, to } = rangeToOffsets(docBefore, target.range); + const del = deletionRange(docBefore, from, to, comments); + if (!del) { + showHint(view, cannotPerform(`'${target.name}' shares its line with other code`)); + return false; + } + view.dispatch({ + changes: { from: del.from, to: del.to }, + selection: { anchor: del.from }, + userEvent: "delete.safe", + scrollIntoView: true, + }); + return true; +} + +let running: EditorView | null = null; + +export async function safeDelete(view: EditorView): Promise { + const ctx = docOf(view); + if (!ctx) return; + if (ctx.readOnly) { + showHint(view, "The file is read-only"); + return; + } + if (running === view) return; + running = view; + try { + await whenSynced(view); + const docBefore = view.state.doc; + const selBefore = view.state.selection; + const resolved = await resolveTarget(view, ctx); + if (resolved.status === "refused") { + showHint(view, cannotPerform(resolved.reason)); + return; + } + const { target } = resolved; + const [usages, comments] = await Promise.all([findUsages(ctx, target), commentSyntax(ctx.language)]); + if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return; + // Validate before asking: a declaration that can't be removed by lines is refused up front. + const { from, to } = rangeToOffsets(docBefore, target.range); + if (!deletionRange(docBefore, from, to, comments)) { + showHint(view, cannotPerform(`'${target.name}' shares its line with other code`)); + return; + } + if (!usages.length) { + applyDeletion(view, docBefore, target, comments); + return; + } + const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages); + if (answer === "view") viewUsages(target.name, usages); + else if (answer === "delete") applyDeletion(view, docBefore, target, comments); + else view.focus(); + } catch (e) { + if (isCancelled(e)) return; + showHint(view, isUnavailable(e) ? cannotPerform("No language server is available") : cannotPerform(errorText(e))); + } finally { + running = null; + } +} + +function errorText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +// --------------------------------------------------------------------------- +// Files (project view) + +export type FileUsages = + | { status: "ok"; usages: Location[] } + /** The language server can't tell (none running, no document symbols…). */ + | { status: "unsupported"; reason: string }; + +/** At most this many top-level symbols per file are searched. */ +const MAX_SYMBOLS = 60; + +async function withDoc(path: string, fn: (doc: number) => Promise): Promise { + const open = editorsFor(path)[0]; + if (open) return fn(open.ctx.doc); + const info = await lspRequest(() => commands.docOpen(path)); + try { + return await fn(info.id); + } finally { + void commands.docClose(info.id); + } +} + +/** + * References from outside `paths` to the top-level symbols the files declare. + * Unsupported when no reference search could run for any of them. + */ +export async function fileUsages(paths: readonly string[], signal?: AbortSignal): Promise { + if (!isTauri()) return { status: "unsupported", reason: "No language server available" }; + const deleted = new Set(paths); + const found = new Map(); + let searched = false; + let reason = "No language server supports usage search for these files"; + for (const path of paths) { + if (signal?.aborted) throw cancelled(); + try { + await withDoc(path, async (doc) => { + const outline = await call(commands.docOutline(doc)); + for (const item of outline.slice(0, MAX_SYMBOLS)) { + if (signal?.aborted) return; + let refs: Location[]; + try { + refs = await lspRequest(() => commands.lspReferences(doc, item.selection_range.start, false)); + } catch (e) { + if (isUnavailable(e)) { + reason = errorText(e); + return; + } + continue; + } + searched = true; + for (const r of refs) { + if (deleted.has(r.path)) continue; + found.set(`${r.path}:${r.range.start.line}:${r.range.start.col}`, r); + } + } + }); + } catch (e) { + if (isCancelled(e)) throw e; + reason = errorText(e); + } + } + if (signal?.aborted) throw cancelled(); + return searched ? { status: "ok", usages: [...found.values()] } : { status: "unsupported", reason }; +} diff --git a/src/editor/refactor/templates.ts b/src/editor/refactor/templates.ts new file mode 100644 index 00000000..13c7053d --- /dev/null +++ b/src/editor/refactor/templates.ts @@ -0,0 +1,251 @@ +/** + * Per-language declaration templates and literal typing for the syntax-only + * Introduce Variable / Constant. Only what is certain from the text: no type + * inference beyond literals and plain constructor calls. + */ + +const TS_JS = new Set(["typescript", "typescriptreact", "javascript", "javascriptreact"]); + +export function isTsJs(language: string): boolean { + return TS_JS.has(language); +} + +/** Facts about the project / file a template depends on. */ +export interface DeclContext { + /** Java: language level ≥ 10 (`var`). */ + javaVar?: boolean; + /** C: C23 (`auto`). */ + cAuto?: boolean; + /** TS/JS: the file ends statements with `;`. */ + semicolons?: boolean; + /** Explicit type for languages that need one (Java without `var`, C without `auto`). */ + type?: string | null; +} + +export interface Declaration { + text: string; + /** Offset of the variable name within `text`. */ + nameOffset: number; + /** How occurrences refer to the variable (`$name` in PHP). */ + reference: string; + /** Offset of the name within `reference`. */ + referenceNameOffset: number; + /** `text` ends with a statement terminator. */ + terminated: boolean; +} + +export type DeclResult = { ok: true; decl: Declaration } | { ok: false; reason: string }; + +function build(prefix: string, name: string, middle: string, suffix: string, sigil = ""): Declaration { + return { + text: `${prefix}${sigil}${name}${middle}${suffix}`, + nameOffset: prefix.length + sigil.length, + reference: `${sigil}${name}`, + referenceNameOffset: sigil.length, + terminated: suffix.endsWith(";"), + }; +} + +/** `T name` with C's `char *name` spacing. */ +function typed(type: string): string { + return type.endsWith("*") ? type : `${type} `; +} + +export function variableDeclaration(language: string, name: string, expr: string, cx: DeclContext = {}): DeclResult { + const ok = (decl: Declaration): DeclResult => ({ ok: true, decl }); + const v = (prefix: string, op: string, suffix = "") => ok(build(prefix, name, ` ${op} ${expr}`, suffix)); + switch (language) { + case "java": + if (cx.javaVar) return v("var ", "=", ";"); + if (cx.type) return v(typed(cx.type), "=", ";"); + return { + ok: false, + reason: + "the type of the expression is unknown and `var` needs Java 10+ (set maven.compiler.release / sourceCompatibility, or wait for the language server)", + }; + case "csharp": + return v("var ", "=", ";"); + case "kotlin": + case "scala": + return v("val ", "="); + case "swift": + return v("let ", "="); + case "rust": + return v("let ", "=", ";"); + case "go": + return v("", ":="); + case "python": + case "ruby": + case "elixir": + return v("", "="); + case "lua": + return v("local ", "="); + case "php": + return ok(build("", name, ` = ${expr}`, ";", "$")); + case "cpp": + return v("auto ", "=", ";"); + case "c": + if (cx.cAuto) return v("auto ", "=", ";"); + if (cx.type) return v(typed(cx.type), "=", ";"); + return { ok: false, reason: "the type of the expression is unknown (`auto` needs C23)" }; + case "dart": + return v("final ", "=", ";"); + case "zig": + return v("const ", "=", ";"); + case "groovy": + return v("def ", "="); + default: + if (TS_JS.has(language)) return v("const ", "=", cx.semicolons === false ? "" : ";"); + return { ok: false, reason: `not supported for ${language} without the language server` }; + } +} + +export function constantDeclaration(language: string, name: string, expr: string, cx: DeclContext = {}): DeclResult { + const ok = (prefix: string, suffix = ""): DeclResult => ({ + ok: true, + decl: build(prefix, name, ` = ${expr}`, suffix), + }); + switch (language) { + case "java": + if (!cx.type) + return { ok: false, reason: "a constant needs a type; only literals are typed without the language server" }; + return ok(`private static final ${typed(cx.type)}`, ";"); + case "python": + return ok(""); + case "kotlin": + return ok("private const val "); + default: + if (TS_JS.has(language)) return ok("const ", cx.semicolons === false ? "" : ";"); + return { ok: false, reason: `Introduce Constant is not supported for ${language} without the language server` }; + } +} + +/** Languages whose block-level expression statements discard their value. */ +const STATEMENT_VALUE_DISCARDED = new Set([ + "java", + "csharp", + "python", + "go", + "c", + "cpp", + "php", + "dart", + "lua", + "zig", + ...TS_JS, +]); + +/** + * May an expression statement that consists of exactly the extracted + * expression be replaced by the declaration (IDEA: `foo();` → `var x = foo();`)? + * Not where a trailing expression is the block's value (Rust tail + * expressions, Kotlin / Scala / Ruby / Elixir / Swift blocks). + */ +export function replacesBareStatement(language: string, statementKind: string): boolean { + if (language === "rust") return statementKind === "expression_statement"; + return STATEMENT_VALUE_DISCARDED.has(language); +} + +const JAVA_INT = /^(0[xX][\da-fA-F_]+|0[bB][01_]+|\d[\d_]*)([lL]?)$/; +const JAVA_FLOAT = /^(\d[\d_]*\.?[\d_]*([eE][+-]?\d+)?|\.\d[\d_]*([eE][+-]?\d+)?)([fFdD]?)$/; + +function numericType(text: string, intTypes: [string, string], floatTypes: [string, string]): string | null { + const t = text.replace(/^-\s*/, ""); + const i = JAVA_INT.exec(t); + if (i) return i[2] ? intTypes[1] : intTypes[0]; + const f = JAVA_FLOAT.exec(t); + if (f && /[.eEfFdD]/.test(t)) return /[fF]$/.test(t) ? floatTypes[1] : floatTypes[0]; + return null; +} + +/** + * Is `kind`/`text` a literal (optionally negated number) whose value does not + * depend on anything in scope? Interpolating strings are not. + */ +export function isLiteral(language: string, kind: string, text: string): boolean { + const t = text.trim(); + const negNumber = /^-\s*[\d.]/.test(t) && /unary|prefix/.test(kind); + switch (language) { + case "java": + return negNumber || (/_literal$/.test(kind) && kind !== "null_literal") || kind === "true" || kind === "false"; + case "python": + if (negNumber) return true; + if (kind === "string" || kind === "concatenated_string") return !/(^|["'\s])[rRbBuU]*[fF][rRbB]*["']/.test(t); + return ["integer", "float", "true", "false"].includes(kind); + case "kotlin": + if (negNumber) return true; + if (kind === "string_literal") return !t.includes("$"); + if (kind === "identifier" || kind === "boolean_literal") return t === "true" || t === "false"; + return /^(number|float|character|integer|real|hex|bin|long|unsigned)_literal$/.test(kind); + case "c": + case "cpp": + return negNumber || ["string_literal", "char_literal", "number_literal", "true", "false"].includes(kind); + default: + if (TS_JS.has(language)) { + if (negNumber) return true; + if (kind === "template_string") return !t.includes("${"); + return ["string", "number", "true", "false", "regex"].includes(kind); + } + return false; + } +} + +/** Java / C type of a literal, or null when it has none (or is not a literal). */ +export function literalType(language: string, kind: string, text: string): string | null { + if (!isLiteral(language, kind, text)) return null; + const t = text.trim(); + if (language === "java") { + if (kind === "string_literal") return "String"; + if (kind === "character_literal") return "char"; + if (kind === "true" || kind === "false") return "boolean"; + return numericType(t, ["int", "long"], ["double", "float"]); + } + if (language === "c") { + if (kind === "string_literal") return "const char *"; + if (kind === "char_literal") return "char"; + if (kind === "true" || kind === "false") return null; + const n = t.replace(/^-\s*/, ""); + if (/^(0[xX][\da-fA-F]+|\d+)[uU]?[lL]{1,2}[uU]?$/.test(n)) + return /[uU]/.test(n) ? null : /[lL]{2}/.test(n) ? "long long" : "long"; + if (/^(0[xX][\da-fA-F]+|\d+)$/.test(n)) return "int"; + return numericType(n, ["int", "long"], ["double", "float"]); + } + return null; +} + +/** Java type of a plain constructor call / array creation (`new Foo(…)`, `new int[3]`); null with `<>` or a class body. */ +export function javaConstructedType(text: string): string | null { + const t = text.trim(); + const arr = /^new\s+([\w$.]+)\s*((?:\[[^\]]*\]\s*)+)(\{[\s\S]*\})?$/.exec(t); + if (arr) return `${arr[1]}${"[]".repeat((arr[2]!.match(/\[/g) ?? []).length)}`; + const obj = /^new\s+([\w$.]+(?:\s*<[\w$.,\s?<>[\]]+>)?)\s*\(/.exec(t); + if (!obj || !t.endsWith(")")) return null; + // The argument list must be the one closing the expression (no `{ … }` body, no chained call). + let depth = 0; + for (let i = obj[0].length - 1; i < t.length; i++) { + if (t[i] === "(") depth++; + else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null; + } + return obj[1]!.replace(/\s+/g, ""); +} + +/** + * Type of a Java expression from a jdtls-style hover (```java\nString java.lang.String.trim()\n``` + * or `String name`), for a call / name ending in `name`. Null when unclear. + */ +export function javaTypeFromHover(hover: string, name: string): string | null { + const code = /```(?:java)?\n([\s\S]*?)```/.exec(hover)?.[1] ?? hover; + const line = code + .split("\n") + .find((l) => l.trim()) + ?.trim(); + if (!line) return null; + const at = new RegExp(`(^|[\\s.])${name.replace(/\$/g, "\\$")}\\s*(\\(|$|\\s|-)`).exec(line); + if (!at) return null; + let before = line.slice(0, at.index + at[1]!.length).trim(); + before = before.replace(/\s*[\w$.]+\.$/, "").trim(); // owner: `java.lang.String.` + before = before.replace(/^((public|protected|private|static|final|abstract|synchronized|native|default)\s+)+/, ""); + before = before.replace(/^<[^>]*>\s*/, ""); + if (!before || before === "void") return null; + return /^[\w$.]+(\s*<[\w$.,\s?<>[\]]*>)?(\[\])*$/.test(before) ? before : null; +} diff --git a/src/editor/structure/index.ts b/src/editor/structure/index.ts new file mode 100644 index 00000000..461e5788 --- /dev/null +++ b/src/editor/structure/index.ts @@ -0,0 +1,188 @@ +/** + * Structure service client: tree-sitter answers from the backend (`ide-syntax`) + * that editor features fall back to when the language server cannot answer — + * IDEA's "dumb mode" parser. + * + * Contract for features: + * - LSP first. Call the server; use `withFallback` to switch to these queries + * only when it is not usable (`notReady`: no server / not running / + * starting), fails (`error`), answers nothing (`empty`, e.g. a refactoring + * over code with syntax errors) or, opt-in, is too slow (`slow`). + * - Answers are syntactic and only claim what is certain: names in member + * position are never locals, references never cross a shadowing + * declaration, ambiguity is flagged (`certain: false`, `exact: false`, + * candidate lists) instead of guessed. Respect those flags (e.g. ask before + * a rename that is not `certain`). + * - Positions are 0-based lines / UTF-16 columns (`@/editor/lsp/positions` + * converts to CodeMirror offsets). Queries read the backend document, so + * `await whenSynced(view)` first after local edits. + * - Languages without a grammar reject with `unavailable` (check `support`). + * Pass `signal` to cancel a query. + */ +import type { + DocId, + ExpressionAt, + FileIdentifiers, + IdeError, + IdentifierOccurrence, + Locals, + NodeAt, + Pos, + Range, + RequestId, + StructuralRanges, + StructureSupport, + SyntaxErrors, +} from "@/bindings"; +import { IpcError, call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; + +export type { + ExpressionAt, + FileIdentifiers, + IdentifierOccurrence, + Locals, + LocalSymbol, + NodeAt, + NotLocalReason, + StructuralRanges, + StructureSupport, + SyntaxError, + SyntaxErrors, + SyntaxNode, +} from "@/bindings"; + +export interface QueryOptions { + /** Aborting cancels the backend query (it rejects with `cancelled`). */ + signal?: AbortSignal; +} + +type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError }; + +async function query(run: (request: RequestId | null) => Promise>, opts?: QueryOptions): Promise { + const signal = opts?.signal; + if (!signal) return call(run(null)); + if (signal.aborted) throw new IpcError({ kind: "cancelled", message: "cancelled" }); + const id = nextRequestId(); + const onAbort = () => void commands.cancelRequest(id); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await call(run(id)); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +const supportCache = new Map>(); + +export const structure = { + /** What the service can answer for a language id (cached). */ + support(language: string): Promise { + let p = supportCache.get(language); + if (!p) { + p = call(commands.syntaxSupport(language)).catch((e: unknown) => { + supportCache.delete(language); + throw e; + }); + supportCache.set(language, p); + } + return p; + }, + /** Smallest named node covering `range`, ancestors parent-first. */ + nodeAt: (doc: DocId, range: Range, opts?: QueryOptions): Promise => + query((r) => commands.syntaxNodeAt(doc, range, r), opts), + /** Expression for a selection (exact / enclosing / caret candidates), with statement and block. */ + expressionAt: (doc: DocId, range: Range, opts?: QueryOptions): Promise => + query((r) => commands.syntaxExpressionAt(doc, range, r), opts), + /** ERROR / MISSING nodes, document order. */ + syntaxErrors: (doc: DocId, opts?: QueryOptions): Promise => + query((r) => commands.syntaxErrors(doc, r), opts), + /** Local definition + references of the identifier at `pos`, or why it is not a local. */ + locals: (doc: DocId, pos: Pos, opts?: QueryOptions): Promise => + query((r) => commands.syntaxLocals(doc, pos, r), opts), + /** Identifier tokens spelled `name` (comments and string contents excluded). */ + identifiers: (doc: DocId, name: string, opts?: QueryOptions): Promise => + query((r) => commands.syntaxIdentifiers(doc, name, r), opts), + /** `identifiers` over files (open buffers preferred); files without hits are omitted. */ + identifiersInFiles: (paths: string[], name: string, opts?: QueryOptions): Promise => + query((r) => commands.syntaxIdentifiersInFiles(paths, name, r ?? nextRequestId()), opts), + /** Selection chain + statement / declaration ranges at `pos`. */ + structuralRanges: (doc: DocId, pos: Pos, opts?: QueryOptions): Promise => + query((r) => commands.syntaxStructuralRanges(doc, pos, r), opts), +}; + +/** When `withFallback` switches from the language server to the structure service. */ +export type FallbackWhen = "notReady" | "error" | "empty" | "slow"; + +export interface FallbackOptions { + /** Default `["notReady", "error", "empty"]`. */ + when?: FallbackWhen[]; + /** Default: `null` / `undefined` / empty array. */ + isEmpty?: (value: T) => boolean; + /** With `"slow"`: ask the fallback once the server has not answered within this time (default 1500 ms). */ + slowMs?: number; + /** Skip the server (e.g. a back-off after recent `unavailable` errors). */ + skipLsp?: boolean; +} + +export interface Answer { + value: T; + source: "lsp" | "syntax"; +} + +const defaultEmpty = (v: unknown) => v == null || (Array.isArray(v) && v.length === 0); + +function lspFailure(e: unknown): FallbackWhen { + return e instanceof IpcError && e.kind === "unavailable" ? "notReady" : "error"; +} + +/** + * LSP-first: returns the server's answer unless a `when` condition holds, then + * the fallback's. Cancellation is never a reason to fall back. If the fallback + * fails too, the server's outcome stands (its error, or its empty answer). + */ +export async function withFallback( + lsp: () => Promise, + fallback: () => Promise, + opts: FallbackOptions = {}, +): Promise> { + const when = new Set(opts.when ?? ["notReady", "error", "empty"]); + const isEmpty = opts.isEmpty ?? defaultEmpty; + const useFallback = async (): Promise | null> => { + try { + const value = await fallback(); + return isEmpty(value) ? null : { value, source: "syntax" }; + } catch (e) { + if (isCancelled(e)) throw e; + return null; + } + }; + if (opts.skipLsp) return { value: await fallback(), source: "syntax" }; + + const fromLsp: Promise> = (async () => { + let value: T; + try { + value = await lsp(); + } catch (e) { + if (isCancelled(e) || !when.has(lspFailure(e))) throw e; + const fb = await useFallback(); + if (fb) return fb; + throw e; + } + if (when.has("empty") && isEmpty(value)) return (await useFallback()) ?? { value, source: "lsp" }; + return { value, source: "lsp" }; + })(); + if (!when.has("slow")) return fromLsp; + // The server may still fail after the fallback answered; nobody awaits it then. + fromLsp.catch(() => {}); + + let timer: ReturnType | undefined; + const slow = new Promise | null>((resolve) => { + timer = setTimeout(() => void useFallback().then(resolve, () => resolve(null)), opts.slowMs ?? 1500); + }); + try { + const first = await Promise.race([fromLsp, slow]); + return first ?? (await fromLsp); + } finally { + clearTimeout(timer); + } +} diff --git a/src/editor/structure/structure.test.ts b/src/editor/structure/structure.test.ts new file mode 100644 index 00000000..eed5168b --- /dev/null +++ b/src/editor/structure/structure.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { IpcError } from "@/platform/ipc"; +import { withFallback } from "./index"; + +const unavailable = () => new IpcError({ kind: "unavailable", message: "no server" }); +const protocol = () => new IpcError({ kind: "protocol", message: "boom" }); +const cancelled = () => new IpcError({ kind: "cancelled", message: "cancelled" }); +const fail = (e: Error) => () => Promise.reject(e); +const ok = (v: T) => () => Promise.resolve(v); + +describe("withFallback", () => { + it("prefers the language server", async () => { + expect(await withFallback(ok([1]), ok([2]))).toEqual({ value: [1], source: "lsp" }); + }); + + it("falls back when the server is not ready, fails or answers nothing", async () => { + expect(await withFallback(fail(unavailable()), ok([2]))).toEqual({ value: [2], source: "syntax" }); + expect(await withFallback(fail(protocol()), ok([2]))).toEqual({ value: [2], source: "syntax" }); + expect(await withFallback(ok([]), ok([2]))).toEqual({ value: [2], source: "syntax" }); + expect(await withFallback(ok(null), ok("x"))).toEqual({ value: "x", source: "syntax" }); + }); + + it("honours the `when` conditions", async () => { + await expect(withFallback(fail(protocol()), ok([2]), { when: ["notReady"] })).rejects.toThrow("boom"); + expect(await withFallback(ok([]), ok([2]), { when: ["notReady", "error"] })).toEqual({ + value: [], + source: "lsp", + }); + expect(await withFallback(ok({ items: [] }), ok({ items: [1] }), { isEmpty: (v) => v.items.length === 0 })).toEqual({ + value: { items: [1] }, + source: "syntax", + }); + }); + + it("never falls back on cancellation and keeps the server outcome when the fallback fails", async () => { + let called = false; + const fb = () => { + called = true; + return Promise.resolve([2]); + }; + await expect(withFallback(fail(cancelled()), fb)).rejects.toMatchObject({ kind: "cancelled" }); + expect(called).toBe(false); + await expect(withFallback(fail(unavailable()), fail(unavailable()))).rejects.toThrow("no server"); + expect(await withFallback(ok([]), ok([]))).toEqual({ value: [], source: "lsp" }); + expect(await withFallback(ok([]), fail(protocol()))).toEqual({ value: [], source: "lsp" }); + }); + + it("answers from the fallback when the server is slow", async () => { + const slowLsp = () => new Promise((resolve) => setTimeout(() => resolve([1]), 200)); + expect(await withFallback(slowLsp, ok([2]), { when: ["slow"], slowMs: 10 })).toEqual({ value: [2], source: "syntax" }); + // An empty fallback waits for the server. + expect(await withFallback(slowLsp, ok([]), { when: ["slow"], slowMs: 10 })).toEqual({ + value: [1], + source: "lsp", + }); + expect(await withFallback(ok([1]), ok([2]), { when: ["slow"], slowMs: 50 })).toEqual({ value: [1], source: "lsp" }); + }); + + it("can skip the server", async () => { + let lspCalled = false; + const lsp = () => { + lspCalled = true; + return Promise.resolve([1]); + }; + expect(await withFallback(lsp, ok([2]), { skipLsp: true })).toEqual({ value: [2], source: "syntax" }); + expect(lspCalled).toBe(false); + }); +}); diff --git a/src/features/analysis/Analysis.module.css b/src/features/analysis/Analysis.module.css new file mode 100644 index 00000000..5f11a68c --- /dev/null +++ b/src/features/analysis/Analysis.module.css @@ -0,0 +1,280 @@ +.view { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} +.body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.body > * { + flex: 1; + min-height: 0; +} +.status { + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: var(--h-row); + padding: 0 10px; + border-bottom: 1px solid var(--border); + color: var(--fg-secondary); + font-size: var(--fs-small); + white-space: nowrap; +} +.statusText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.progress { + width: 160px; + flex: none; + margin-left: auto; +} +.row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + white-space: nowrap; +} +.strong { + color: var(--fg-strong); +} +.muted { + flex: none; + color: var(--fg-subtle); +} +.count { + color: var(--fg-subtle); + margin-left: 4px; +} +.fileIcon { + flex: none; + color: var(--fg-muted); +} +.message { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg); +} +.sev-error { + flex: none; + color: var(--danger); +} +.sev-warning { + flex: none; + color: var(--warning); +} +.sev-info { + flex: none; + color: var(--info); +} + +/* Duplicates: groups on the left, fragments side by side on the right. */ +.split { + flex: 1; + min-height: 0; + display: flex; +} +.groups { + width: 40%; + min-width: 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); +} +.groups > * { + flex: 1; + min-height: 0; +} +.fragments { + flex: 1; + min-width: 0; + display: flex; + overflow-x: auto; +} +.fragment { + flex: 1 0 280px; + min-width: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); +} +.fragment:last-child { + border-right: 0; +} +.fragmentHeader { + flex: none; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: var(--h-row); + padding: 0 8px; + border: 0; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); + color: var(--fg); + font: inherit; + text-align: left; + white-space: nowrap; + overflow: hidden; + cursor: default; +} +.fragmentHeader:hover { + background: var(--bg-hover); +} +.code { + flex: 1; + min-height: 0; + margin: 0; + padding: 6px 8px; + overflow: auto; + font-family: var(--font-mono); + font-size: var(--fs-small); + line-height: 18px; + color: var(--fg-editor); + white-space: pre; +} +.lineNo { + display: inline-block; + width: 4ch; + margin-right: 1ch; + text-align: right; + color: var(--fg-line-number); + user-select: none; +} + +/* Dialogs */ +.form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; +} +.scope { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + border: 0; +} +.fieldLabel { + padding: 0 0 4px; + color: var(--fg-secondary); + font-size: var(--fs-small); +} +.radioRow { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; + color: var(--fg); + cursor: default; +} +.radioRow input { + margin: 0; + accent-color: var(--accent); +} +.dirRow { + display: flex; + gap: 6px; + padding: 4px 0 0 22px; +} +.grow { + flex: 1; + min-width: 0; +} +.fieldRow { + display: flex; + align-items: center; + gap: 8px; +} +.languageSelect { + min-width: 180px; +} +.template { + width: 100%; + min-height: 96px; + resize: vertical; + padding: 6px 8px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-control); + background: var(--bg-input); + color: var(--fg-editor); + font-family: var(--font-mono); + font-size: var(--fs-code); + line-height: 1.5; +} +.template:focus { + outline: none; + border-color: var(--accent); +} +.hint { + color: var(--fg-subtle); + font-size: var(--fs-small); + line-height: 1.5; +} +.error { + color: var(--danger); + font-size: var(--fs-small); +} +.number { + width: 80px; +} + +/* Replace preview */ +.previewList { + max-height: 360px; + overflow: auto; + margin: 14px; + padding: 0; + list-style: none; + border: 1px solid var(--border); + border-radius: var(--radius-small); +} +.previewItem { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} +.previewItem:last-child { + border-bottom: 0; +} +.previewBody { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} +.previewWhere { + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.previewOld, +.previewNew { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-small); + white-space: pre-wrap; + word-break: break-all; +} +.previewOld { + color: var(--fg-muted); + text-decoration: line-through; +} +.previewNew { + color: var(--vcs-added); +} diff --git a/src/features/analysis/AnalysisDialogs.tsx b/src/features/analysis/AnalysisDialogs.tsx new file mode 100644 index 00000000..d6eaa486 --- /dev/null +++ b/src/features/analysis/AnalysisDialogs.tsx @@ -0,0 +1,303 @@ +/** Inspect Code, Locate Duplicates and Search / Replace Structurally dialogs. */ +import { useEffect, useMemo, useState } from "react"; +import { + call, + commands, + isTauri, + type DuplicateOptions, + type StructuralFile, + type StructuralLanguage, +} from "@/platform/ipc"; +import { basename } from "@/platform/project"; +import { Checkbox, Dialog, Input, Select, Spinner } from "@/ui"; +import { ScopeFields, openDialog } from "./dialogs"; +import { locateDuplicates, loadDuplicateOptions } from "./duplicates"; +import { runInspection } from "./inspect"; +import { defaultScope, type ScopeChoice } from "./scope"; +import { + applyStructuralReplace, + buildReplaceEdit, + loadStructuralForm, + matchKey, + saveStructuralForm, + searchStructurally, + showStructuralResults, + type StructuralForm, +} from "./structural"; +import styles from "./Analysis.module.css"; + +function InspectCodeDialog({ onClose }: { onClose: () => void }) { + const [scope, setScope] = useState(() => defaultScope()); + const ok = () => { + onClose(); + void runInspection(scope); + }; + return ( + +
+ +
Files are checked by their language servers; files without one are skipped.
+
+
+ ); +} + +function DuplicatesDialog({ onClose }: { onClose: () => void }) { + const [scope, setScope] = useState(() => defaultScope()); + const [options, setOptions] = useState(loadDuplicateOptions); + const [minText, setMinText] = useState(String(options.minTokens)); + const min = Number(minText); + const valid = Number.isInteger(min) && min >= 10 && min <= 10_000; + const ok = () => { + if (!valid) return; + onClose(); + void locateDuplicates(scope, { ...options, minTokens: min }); + }; + return ( + +
+ + + setOptions({ ...options, anonymizeIdentifiers: v })} + label="Anonymize variables, fields, methods and types" + /> + setOptions({ ...options, anonymizeLiterals: v })} + label="Anonymize literals" + /> +
+
+ ); +} + +function useStructuralLanguages(): StructuralLanguage[] | null { + const [langs, setLangs] = useState(() => (isTauri() ? null : [])); + useEffect(() => { + if (!isTauri()) return; + let alive = true; + void call(commands.structuralLanguages()).then( + (l) => alive && setLangs(l), + () => alive && setLangs([]), + ); + return () => { + alive = false; + }; + }, []); + return langs; +} + +/** Template validation while typing (debounced). */ +function useTemplateError(pattern: string, language: string): string | null { + const key = `${language}\u0000${pattern}`; + const [result, setResult] = useState<{ key: string; error: string | null }>({ key: "", error: null }); + const applicable = isTauri() && !!pattern.trim() && !!language; + useEffect(() => { + if (!applicable) return; + let alive = true; + const t = setTimeout(() => { + void call(commands.structuralValidate({ pattern, language, replacement: null, maxMatches: 0 })).then( + () => alive && setResult({ key, error: null }), + (e) => alive && setResult({ key, error: e instanceof Error ? e.message : String(e) }), + ); + }, 250); + return () => { + alive = false; + clearTimeout(t); + }; + }, [applicable, key, pattern, language]); + return applicable && result.key === key ? result.error : null; +} + +function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () => void }) { + const [form, setForm] = useState(loadStructuralForm); + const [scope, setScope] = useState(() => defaultScope()); + const [busy, setBusy] = useState(false); + const languages = useStructuralLanguages(); + // The editor's language when it has a grammar, else the first supported one. + const language = + languages && languages.length && !languages.some((l) => l.id === form.language) ? languages[0]!.id : form.language; + const error = useTemplateError(form.pattern, language); + const options = useMemo(() => (languages ?? []).map((l) => ({ value: l.id, label: l.name })), [languages]); + const ready = !!form.pattern.trim() && !!language && !error && !busy; + + const run = async () => { + if (!ready) return; + saveStructuralForm({ ...form, language }); + setBusy(true); + const query = { pattern: form.pattern, language, replacement: replace ? form.replacement : null, maxMatches: 0 }; + const result = await searchStructurally(query, scope); + setBusy(false); + if (!result) return; + onClose(); + if (!replace) { + await showStructuralResults(form.pattern, result.files, result.summary); + } else if (!result.files.length) { + await showStructuralResults(form.pattern, result.files, result.summary); + } else { + showReplacePreview(result.files); + } + }; + + return ( + : undefined} + buttons={[ + { label: replace ? "Find…" : "Find", role: "default", disabled: !ready, onClick: () => void run() }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+
+ Language +