mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
This commit is contained in:
commit
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
+36
@@ -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/
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
target
|
||||
src/bindings.ts
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1 @@
|
||||
{ "printWidth": 120, "semi": true, "singleQuote": false, "trailingComma": "all" }
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
Generated
+11132
File diff suppressed because it is too large.
Load diff
+104
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,74 @@
|
||||
<h1>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
|
||||
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="64">
|
||||
</picture>
|
||||
</h1>
|
||||
|
||||
An open-source desktop IDE in the spirit of IntelliJ IDEA, built with Rust and Tauri.
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
|
||||
> [!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).
|
||||
@@ -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 <https://github.com/microsoft/java-debug>,
|
||||
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
|
||||
(<https://open-vsx.org/extension/vscjava/vscode-java-debug>, 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/<grammar>/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
|
||||
@@ -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"
|
||||
@@ -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<Fragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuplicatesReport {
|
||||
pub groups: Vec<DuplicateGroup>,
|
||||
/// 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<Token>,
|
||||
/// `prefix[i]` = rolling hash of `tokens[..i]`.
|
||||
prefix: Vec<u64>,
|
||||
}
|
||||
|
||||
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<u64>);
|
||||
|
||||
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<Tokenized> = 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<u64, Vec<(u32, u32)>> = 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<Fragment> =
|
||||
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<Vec<Token>> {
|
||||
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<SourceFile> =
|
||||
files.iter().map(|(name, _)| SourceFile { path: PathBuf::from(name), rel_path: (*name).into() }).collect();
|
||||
let map: HashMap<PathBuf, String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<StructuralMatch>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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<u16> {
|
||||
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<Pattern, PatternError> {
|
||||
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<char> = 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<StructuralMatch> {
|
||||
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<StructuralSummary> {
|
||||
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<StructuralMatch> {
|
||||
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<SourceFile> = ["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::<usize>(), 3);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Prints the build model of a folder, optionally after a sync:
|
||||
//! `cargo run -p ide-build --example model -- <dir> [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());
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub env: BTreeMap<String, String>,
|
||||
format: Format,
|
||||
}
|
||||
|
||||
impl BuildStep {
|
||||
fn new(program: impl Into<String>, 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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BuildPlan {
|
||||
pub system: BuildSystem,
|
||||
/// Tree root title: `demo` / `Build module 'core'`.
|
||||
pub title: String,
|
||||
pub steps: Vec<BuildStep>,
|
||||
}
|
||||
|
||||
/// 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<ide_core::IpcPath>,
|
||||
pub range: Option<Range>,
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[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<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<BuildMessage>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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<BuildSystem> {
|
||||
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<Item = &'a Path> {
|
||||
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<BuildPlan> {
|
||||
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<Vec<BuildStep>> {
|
||||
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<Vec<BuildStep>> {
|
||||
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<String> = 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<BuildStep> {
|
||||
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<BuildStep> {
|
||||
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::<serde_json::Value>(&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<BuildStep> {
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf, Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
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::<u32>()
|
||||
+ 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<f32>)> {
|
||||
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<BuildResult> {
|
||||
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<BuildMessage> = Vec::new();
|
||||
let mut seen: HashSet<(Option<PathBuf>, Option<Range>, 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<RawMessage>, messages: &mut Vec<BuildMessage>| {
|
||||
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<PathBuf, Vec<Diagnostic>> {
|
||||
let mut out: BTreeMap<PathBuf, Vec<Diagnostic>> = 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<Vec<PathBuf>> {
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let errors: Vec<String> = 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<PathBuf> {
|
||||
let sets: &[&str] = if test { &["test", "main"] } else { &["main"] };
|
||||
let dirs: Vec<PathBuf> = 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",
|
||||
"<project><artifactId>demo</artifactId><modules><module>core</module></modules></project>",
|
||||
);
|
||||
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&root, "core/src/main/java/A.java", "class A {}");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"));
|
||||
let 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<Vec<String>>);
|
||||
|
||||
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<String> {
|
||||
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")]);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub line: Option<u32>,
|
||||
pub col: Option<u32>,
|
||||
pub end_line: Option<u32>,
|
||||
pub end_col: Option<u32>,
|
||||
pub unit: ColUnit,
|
||||
pub code: Option<String>,
|
||||
/// Directory relative paths resolve against first (cargo: the package of the message).
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
impl RawMessage {
|
||||
fn new(severity: Severity, text: impl Into<String>, 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<u32>) -> 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<RawMessage>,
|
||||
/// `None` = show the line as is; `Some("")` = hide it.
|
||||
pub console: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Pending>,
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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<u32>, &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<u32>, &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<RawMessage> {
|
||||
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<RawMessage> {
|
||||
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<RawMessage> {
|
||||
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<RawMessage> {
|
||||
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<RawMessage> {
|
||||
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<RawMessage> {
|
||||
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<RawMessage>, 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<RawMessage>) {
|
||||
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::<Value>(&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<RawMessage> {
|
||||
let mut out = Vec::new();
|
||||
self.flush(&mut out);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(format: Format, text: &str) -> Vec<RawMessage> {
|
||||
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<u32>) {
|
||||
(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);
|
||||
}
|
||||
}
|
||||
@@ -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<GradleProject>,
|
||||
pub java_version: Option<u32>,
|
||||
/// `gradlew` of the build (absolute), else `gradle`.
|
||||
pub command: String,
|
||||
/// Environment for runs (`JAVA_HOME`); filled in by the shell.
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// When the task list was last read from Gradle (Unix ms); `None` = static defaults.
|
||||
pub synced_at: Option<f64>,
|
||||
pub stale: bool,
|
||||
pub sync_error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub tasks: Vec<GradleTask>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<char> = 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<String> {
|
||||
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<String> {
|
||||
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<String>, Vec<String>) {
|
||||
let text = strip_comments(text);
|
||||
let mut name = None;
|
||||
let mut includes: Vec<String> = 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<PathBuf> {
|
||||
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<PathBuf> = 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<GradleTask> {
|
||||
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<GradleModel> {
|
||||
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<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
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::<String>().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<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = 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<String> = 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<TaskName>: 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());
|
||||
}
|
||||
}
|
||||
@@ -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<MavenModel>,
|
||||
pub gradle: Option<GradleModel>,
|
||||
}
|
||||
|
||||
/// 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"), "<project><artifactId>svc</artifactId></project>").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")));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
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<dyn Read + Send>, 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");
|
||||
}
|
||||
}
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
/// `-o` / `--offline`.
|
||||
pub offline: bool,
|
||||
/// Maven profiles (`-P`), `!id` deactivates.
|
||||
pub profiles: Vec<String>,
|
||||
/// Extra environment (`JAVA_HOME`).
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct CachedMavenProject {
|
||||
key: String,
|
||||
plugins: Vec<MavenPlugin>,
|
||||
/// Effective direct dependencies, with transitive ones when `dependency:tree` ran.
|
||||
dependencies: Vec<MavenDependency>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct MavenCache {
|
||||
hash: String,
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
projects: Vec<CachedMavenProject>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct GradleCache {
|
||||
hash: String,
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
tasks: Vec<(String, GradleTask)>,
|
||||
}
|
||||
|
||||
const MAVEN_CACHE: &str = "maven.json";
|
||||
const GRADLE_CACHE: &str = "gradle.json";
|
||||
|
||||
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
|
||||
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
|
||||
}
|
||||
|
||||
fn write_cache<T: Serialize>(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::<MavenCache>(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::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
|
||||
if !cache.tasks.is_empty() {
|
||||
for p in &mut model.projects {
|
||||
let tasks: Vec<GradleTask> =
|
||||
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<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(&str),
|
||||
) -> IdeResult<Output> {
|
||||
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<String> = Vec::new();
|
||||
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
|
||||
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
|
||||
let _ = std::fs::remove_file(&effective);
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
||||
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<String> = 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"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.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"),
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
|
||||
)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -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 (`<x:project>` → `project`).
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub children: Vec<Element>,
|
||||
}
|
||||
|
||||
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<Item = &'a Element> + '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<String> {
|
||||
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<Element> {
|
||||
let mut stack: Vec<Element> = 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("<!--") {
|
||||
rest = body.find("-->").map_or("", |e| &body[e + 3..]);
|
||||
} else if let Some(body) = tag.strip_prefix("<![CDATA[") {
|
||||
let end = body.find("]]>").unwrap_or(body.len());
|
||||
stack.last_mut()?.text.push_str(&body[..end]);
|
||||
rest = body.get(end + 3..).unwrap_or("");
|
||||
} else if tag.starts_with("<?") {
|
||||
rest = tag.find("?>").map_or("", |e| &tag[e + 2..]);
|
||||
} else if tag.starts_with("<!") {
|
||||
rest = tag.find('>').map_or("", |e| &tag[e + 1..]);
|
||||
} else if let Some(body) = tag.strip_prefix("</") {
|
||||
let end = body.find('>').unwrap_or(body.len());
|
||||
let name = local(body[..end].trim());
|
||||
if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
|
||||
while stack.len() > at {
|
||||
let done = stack.pop()?;
|
||||
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<u8> = 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#"<?xml version="1.0"?>
|
||||
<!DOCTYPE x>
|
||||
<!-- header -->
|
||||
<p:project xmlns:p="urn" a="1 > 2">
|
||||
<name>A & B AB</name>
|
||||
<empty/>
|
||||
<desc><![CDATA[<raw>]]></desc>
|
||||
<!-- <name>ignored</name> -->
|
||||
<list><item>1</item><item attr='/>'>2</item></list>
|
||||
</p:project>"#;
|
||||
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("<raw>"));
|
||||
let items: Vec<String> = 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("<a><b>x</c><d>y").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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic: Option<bool>,
|
||||
/// `underline` | `wave` | `strikeout` | `none`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effect: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effect_color: Option<String>,
|
||||
}
|
||||
|
||||
/// Editor › Code Style › <Language> › 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<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tab_size: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hard_wrap_at: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
/// Detect and use existing file indents for editing.
|
||||
pub detect_indents: bool,
|
||||
/// Enable EditorConfig support.
|
||||
pub editorconfig: bool,
|
||||
pub languages: BTreeMap<String, LanguageCodeStyle>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
pub type IdeResult<T> = Result<T, IdeError>;
|
||||
|
||||
/// 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<String>) -> Self {
|
||||
Self { kind, message: message.into() }
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
}
|
||||
pub fn invalid(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::InvalidInput, m)
|
||||
}
|
||||
pub fn internal(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Internal, m)
|
||||
}
|
||||
pub fn unavailable(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Unavailable, m)
|
||||
}
|
||||
pub fn cancelled() -> Self {
|
||||
Self::new(ErrorKind::Cancelled, "cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> 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())
|
||||
}
|
||||
}
|
||||
@@ -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<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// 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<TextChange>,
|
||||
},
|
||||
/// 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<Diagnostic>,
|
||||
},
|
||||
/// Progress of a background task; `fraction` is `None` when indeterminate.
|
||||
Task {
|
||||
id: TaskId,
|
||||
title: String,
|
||||
message: Option<String>,
|
||||
fraction: Option<f32>,
|
||||
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<String>,
|
||||
},
|
||||
/// 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<i32>,
|
||||
},
|
||||
RunExited {
|
||||
run: RunId,
|
||||
code: Option<i32>,
|
||||
},
|
||||
Debug {
|
||||
session: DebugSessionId,
|
||||
event: DebugEvent,
|
||||
},
|
||||
Notify {
|
||||
level: NotifyLevel,
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
},
|
||||
/// 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<DebugSessionId>,
|
||||
},
|
||||
Initialized,
|
||||
Stopped {
|
||||
thread_id: i64,
|
||||
reason: String,
|
||||
description: Option<String>,
|
||||
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<F: Fn(IdeEvent) + Send + Sync + 'static> EventSink for F {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
self(event)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedSink = Arc<dyn EventSink>;
|
||||
|
||||
/// Discards events; for tests and headless tools.
|
||||
pub struct NullSink;
|
||||
|
||||
impl EventSink for NullSink {
|
||||
fn emit(&self, _: IdeEvent) {}
|
||||
}
|
||||
@@ -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<String>) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&to_ipc(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IpcPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
PathBuf::deserialize(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for IpcPath {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for IpcPath {
|
||||
fn as_ref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Borrow<Path> for IpcPath {
|
||||
fn borrow(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PathBuf> 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<String> 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<IpcPath> for PathBuf {
|
||||
fn from(p: IpcPath) -> Self {
|
||||
p.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<PathBuf> for IpcPath {
|
||||
fn eq(&self, other: &PathBuf) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Path> 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<IpcPath> 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::<IpcPath>(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""#);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The per-project config folder (`<root>/.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<Path>) -> PathBuf {
|
||||
root.join(DIR).join(rel)
|
||||
}
|
||||
@@ -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<String, Vec<String>>,
|
||||
/// 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<String, CustomKeymap>,
|
||||
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<ExternalTool>,
|
||||
}
|
||||
|
||||
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<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<String, NotificationGroupSettings>,
|
||||
}
|
||||
|
||||
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<String, bool>,
|
||||
}
|
||||
|
||||
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<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
pub trim_trailing_whitespace: bool,
|
||||
pub ensure_final_newline: bool,
|
||||
/// Local History keeps changes from this many working days (days with changes), like IDEA.
|
||||
pub local_history_days: u32,
|
||||
/// Upper bound for a project's Local History in MB; the oldest revisions go first.
|
||||
pub local_history_max_mb: u32,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub test_roots: Vec<String>,
|
||||
pub resource_roots: Vec<String>,
|
||||
pub test_resource_roots: Vec<String>,
|
||||
/// Excluded from the project tree, search and indexing.
|
||||
pub excluded: Vec<String>,
|
||||
/// Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked.
|
||||
pub unmarked: Vec<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// `files.exclude` plus the Project Structure excluded folders as globs.
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
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!({}));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<TextChange>,
|
||||
}
|
||||
|
||||
/// 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<FileEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
Edit { path: crate::IpcPath, edits: Vec<TextEdit> },
|
||||
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<String>,
|
||||
pub code: Option<String>,
|
||||
#[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<String>,
|
||||
pub kind: SymbolKind,
|
||||
/// Whole extent of the symbol.
|
||||
pub range: Range,
|
||||
/// The identifier itself; where navigation lands.
|
||||
pub selection_range: Range,
|
||||
pub children: Vec<OutlineItem>,
|
||||
}
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
@@ -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<IssueLink>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
"<frozen importlib._bootstrap".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Debugger (+ Data Views, Stepping, Python Debugger).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DebuggerSettings {
|
||||
pub show_on_breakpoint: bool,
|
||||
pub focus_app_on_breakpoint: bool,
|
||||
pub hide_on_termination: bool,
|
||||
pub breakpoints_any_file: bool,
|
||||
pub inline_values: bool,
|
||||
pub value_tooltip: bool,
|
||||
pub value_tooltip_delay: u32,
|
||||
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
|
||||
pub skip_library_code: bool,
|
||||
pub python: PythonDebuggerSettings,
|
||||
}
|
||||
|
||||
impl Default for DebuggerSettings {
|
||||
fn default() -> 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<String, String>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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::<u64>() 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::<u32>().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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! JaCoCo XML reports:
|
||||
//!
|
||||
//! ```xml
|
||||
//! <report name="app">
|
||||
//! <package name="com/example">
|
||||
//! <sourcefile name="Calc.java">
|
||||
//! <line nr="5" mi="0" ci="3" mb="1" cb="1"/>
|
||||
//! ```
|
||||
//!
|
||||
//! Source files are found below the report's module in the conventional
|
||||
//! source roots (`src/<set>/{java,kotlin,groovy,scala}`), falling back to
|
||||
//! `src/<set>/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: `<module>/target/site/jacoco/jacoco.xml` (Maven) or
|
||||
/// `<module>/build/reports/jacoco/<task>/<task>.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<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
walk(dir, 0, &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
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/<task>/`.
|
||||
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<PathBuf>) {
|
||||
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<String> {
|
||||
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<PathBuf> = 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<PathBuf> = 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<crate::CoverageSuite, String> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd"><report name="app"><sessioninfo id="x" start="1" dump="2"/><package name="com/example"><class name="com/example/Calc" sourcefilename="Calc.java"><method name="add" desc="(II)I" line="5"><counter type="LINE" missed="0" covered="1"/></method></class><sourcefile name="Calc.java"><line nr="5" mi="0" ci="3" mb="0" cb="0"/><line nr="6" mi="2" ci="2" mb="1" cb="1"/><line nr="9" mi="4" ci="0" mb="0" cb="0"/><counter type="LINE" missed="1" covered="2"/></sourcefile><sourcefile name="Util.kt"><line nr="1" mi="0" ci="1" mb="0" cb="0"/></sourcefile></package></report>"#;
|
||||
|
||||
#[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("<report><package name=\"a\"></sourcefile></report>", Path::new("/m")).is_err());
|
||||
let suite = parse("<report></report>", Path::new("/m")).unwrap();
|
||||
assert!(suite.files.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> = 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::<u32>(), hits.trim().parse::<f64>()) 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::<u32>() else { continue };
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let taken = taken != "-" && taken.parse::<f64>().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);
|
||||
}
|
||||
}
|
||||
@@ -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<LineCoverage>,
|
||||
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<FileCoverage>,
|
||||
}
|
||||
|
||||
/// 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 <lcov>`).
|
||||
Exported { runner: String, argv: Vec<String>, cwd: IpcPath, env: BTreeMap<String, String>, 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<String>,
|
||||
pub cwd: Option<IpcPath>,
|
||||
}
|
||||
|
||||
#[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<RunConfig>,
|
||||
report: CoverageReportSpec,
|
||||
},
|
||||
MissingTool {
|
||||
tool: String,
|
||||
message: String,
|
||||
install: Option<InstallCommand>,
|
||||
},
|
||||
Unsupported {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Line data merged from one or more report records.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Accumulator {
|
||||
files: BTreeMap<PathBuf, BTreeMap<u32, LineAcc>>,
|
||||
pub(crate) has_branches: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LineAcc {
|
||||
hits: u64,
|
||||
/// Branch (or block) key → taken.
|
||||
branches: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
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<LineCoverage> = 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<CoverageSuite> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> CoveragePrepared {
|
||||
CoveragePrepared::Unsupported { reason: reason.into() }
|
||||
}
|
||||
|
||||
fn missing(tool: &str, message: impl Into<String>, install: Option<(Vec<String>, &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<String>,
|
||||
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<String> = 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<String> = 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<String> {
|
||||
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<String> = 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<String> = 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<String> { 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/[email protected]"])
|
||||
}
|
||||
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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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<String>,
|
||||
pub transport: Transport,
|
||||
pub install_hint: Option<String>,
|
||||
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<DebuggerConfig> {
|
||||
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<DebuggerConfig> {
|
||||
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<String> {
|
||||
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<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
}
|
||||
|
||||
static TOOLS_ROOT: parking_lot::RwLock<Option<PathBuf>> = 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<PathBuf> {
|
||||
TOOLS_ROOT.read().clone()
|
||||
}
|
||||
|
||||
/// Executable directories of managed installs, read at lookup time so tools installed
|
||||
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
|
||||
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![root.join("node_modules").join(".bin")];
|
||||
if let Ok(entries) = std::fs::read_dir(root) {
|
||||
let mut tools: Vec<PathBuf> = 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<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = 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::<Vec<_>>()).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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
find_binary(command, cwd).or_else(|| xcrun_tool(command))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn xcrun_tool(command: &str) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Name of the managed debugpy install (ide-tools `PipTarget`, unpacked into `<tools>/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` (`<python> -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::<Vec<_>>()).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<dyn AsyncRead + Unpin + Send>;
|
||||
pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
|
||||
|
||||
/// A connected adapter: byte streams plus the process (if we spawned one).
|
||||
pub struct AdapterIo {
|
||||
pub reader: BoxReader,
|
||||
pub writer: BoxWriter,
|
||||
pub child: Option<Child>,
|
||||
/// TCP port of a server-mode adapter; child sessions (`startDebugging`) connect to it again.
|
||||
pub port: Option<u16>,
|
||||
/// Recent stderr lines of the adapter process, for error messages.
|
||||
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + 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<u16>) -> 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<u16>) -> LaunchFuture {
|
||||
Box::pin(async move {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
if let Some(port) = reuse_port {
|
||||
let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?;
|
||||
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<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
let mut buf = sink.lock();
|
||||
if buf.len() >= 40 {
|
||||
buf.remove(0);
|
||||
}
|
||||
buf.push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_with_retries(
|
||||
port: u16,
|
||||
mut child: Option<&mut Child>,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<tokio::net::TcpStream> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
|
||||
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<String> = ["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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
/// 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<String>, 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<String>, 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<String>, 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<String>, 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<AttachableProcess> {
|
||||
let name = file_name(&p.exe).to_owned();
|
||||
let make = |kind, adapter: &str, addr: Option<(Option<String>, 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<Vec<RawProcess>> {
|
||||
use std::collections::HashMap;
|
||||
let ps = |fields: &str| -> IdeResult<String> {
|
||||
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<u32, String> =
|
||||
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<Vec<RawProcess>> {
|
||||
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<u16> {
|
||||
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<u16> {
|
||||
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<Vec<AttachableProcess>> {
|
||||
let cwd = std::env::temp_dir();
|
||||
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
|
||||
let mut out: Vec<AttachableProcess> =
|
||||
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<u16>)> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let cargo = find_binary("cargo", cwd).ok_or_else(|| IdeError::unavailable("`cargo` not found on PATH"))?;
|
||||
let mut cmd = tokio::process::Command::new(cargo);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<DapError> 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<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
|
||||
|
||||
struct Inner {
|
||||
out: mpsc::UnboundedSender<Vec<u8>>,
|
||||
pending: Mutex<Pending>,
|
||||
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<Inner>,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime.
|
||||
pub fn start<R, W>(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
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<oneshot::Receiver<Result<Value, DapError>>, 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<Value, DapError> {
|
||||
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<Value, DapError> {
|
||||
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<Value, String>) -> 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<Incoming>, 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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<PathBuf>,
|
||||
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<DebugSessionId>,
|
||||
pub request: String,
|
||||
pub conn: Connection,
|
||||
pub spec: AdapterSpec,
|
||||
/// TCP port of a server-mode adapter (for `startDebugging` child sessions).
|
||||
pub port: Option<u16>,
|
||||
pub caps: Mutex<Value>,
|
||||
pub adapter_process: Mutex<Option<Child>>,
|
||||
/// Processes started for `runInTerminal`.
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
pub breakpoint_ids: Mutex<HashMap<i64, (PathBuf, usize)>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: DebugSessionId,
|
||||
owner: SessionOwner,
|
||||
name: String,
|
||||
adapter: String,
|
||||
parent: Option<DebugSessionId>,
|
||||
request: String,
|
||||
conn: Connection,
|
||||
spec: AdapterSpec,
|
||||
port: Option<u16>,
|
||||
child: Option<Child>,
|
||||
) -> 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<u32>,
|
||||
) -> Result<Vec<BreakpointState>, DapError> {
|
||||
let enabled: Vec<usize> = (0..breakpoints.len()).filter(|&i| breakpoints[i].enabled).collect();
|
||||
let mut source_bps: Vec<Value> = 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<BreakpointState> = 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<Vec<BreakpointState>, DapError> {
|
||||
let enabled: Vec<usize> = (0..list.len()).filter(|&i| list[i].enabled).collect();
|
||||
let sent: Vec<Value> = 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<Vec<BreakpointState>, DapError> {
|
||||
let enabled: Vec<usize> = (0..list.len()).filter(|&i| list[i].enabled).collect();
|
||||
let sent: Vec<Value> = 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<Item = bool>, sent: &[usize], body: &Value) -> Vec<BreakpointState> {
|
||||
let returned = body.get("breakpoints").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let mut states: Vec<BreakpointState> = 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<DebugManager>,
|
||||
session: Arc<Session>,
|
||||
mut incoming: mpsc::UnboundedReceiver<Incoming>,
|
||||
) {
|
||||
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<DebugManager>, s: &Arc<Session>, 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<DebugManager>, s: &Arc<Session>, 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<Session>, args: &Value) -> Result<Value, String> {
|
||||
let argv: Vec<String> = 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<R: AsyncRead + Unpin + Send + 'static>(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<u8> = 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 } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<Mutex<Vec<Value>>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
run_in_terminal: Option<Vec<String>>,
|
||||
/// 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<u16>) -> 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<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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<i64> = 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<Value> = 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<Value> = 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<DebugManager>,
|
||||
log: Log,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
owner: SessionOwner,
|
||||
}
|
||||
|
||||
/// An owner whose events are collected in the returned list.
|
||||
fn recording_owner(key: &str) -> (SessionOwner, Arc<Mutex<Vec<IdeEvent>>>) {
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = 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<Vec<IdeEvent>>) -> std::collections::BTreeSet<u32> {
|
||||
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<DebugSessionId> {
|
||||
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<String> {
|
||||
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<Value> = 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::<Vec<_>>(), [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<Value> {
|
||||
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::<std::collections::BTreeSet<_>>();
|
||||
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<u32> = 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<Value> {
|
||||
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<Value> {
|
||||
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"));
|
||||
}
|
||||
@@ -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<Vec<IdeEvent>>, 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<Mutex<Vec<IdeEvent>>> = 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);
|
||||
}
|
||||
@@ -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=<dir> cargo test -p ide-dap --test real_adapters -- --ignored --nocapture`
|
||||
//! where `<dir>` 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<Case> {
|
||||
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::<u16>().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<Case> = 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<DebugManager>,
|
||||
events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>>,
|
||||
owner: SessionOwner,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new() -> Self {
|
||||
let events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>> = 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<Vec<String>, 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<String> = 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::<Vec<_>>()));
|
||||
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<String>,
|
||||
source: PathBuf,
|
||||
/// 0-based line inside `tick`.
|
||||
line: u32,
|
||||
extra: fn(u32, Option<u16>) -> 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::<u16>().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<String> = 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::<Vec<_>>())
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
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:?}");
|
||||
}
|
||||
@@ -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=<dir> cargo test -p ide-dap --test script_debuggers -- --ignored --nocapture`
|
||||
//! where `<dir>` 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=<case>`
|
||||
//! 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<PathBuf>, 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_internals>/**", "**/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<Case> {
|
||||
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<DebugManager>,
|
||||
events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>>,
|
||||
owner: SessionOwner,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new() -> Self {
|
||||
let events: Arc<Mutex<Vec<(DebugSessionId, DebugEvent)>>> = 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<String> = self.events.lock().iter().map(|(s, e)| format!("{}: {e:?}", s.0)).collect();
|
||||
log.join("\n")
|
||||
}
|
||||
|
||||
async fn wait<T>(
|
||||
&self,
|
||||
seen: usize,
|
||||
what: &str,
|
||||
f: impl Fn(DebugSessionId, &DebugEvent) -> Option<T>,
|
||||
) -> Result<T, String> {
|
||||
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<StackFrame, String> {
|
||||
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<StackFrame, String> {
|
||||
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<Vec<String>, 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::<Vec<_>>(),
|
||||
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:?}");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<ResultColumn>,
|
||||
/// Cell text; `None` is SQL NULL. Binary values are `0x…` hex.
|
||||
pub rows: Vec<Vec<Option<String>>>,
|
||||
/// Rows changed by a DML statement (`None` for queries).
|
||||
pub affected: Option<f64>,
|
||||
/// 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<R: Row>(row: &R, i: usize) -> Option<String>
|
||||
where
|
||||
usize: ColumnIndex<R>,
|
||||
for<'r> String: Decode<'r, R::Database>,
|
||||
for<'r> Vec<u8>: 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 <Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| hex(&b));
|
||||
}
|
||||
match <String as Decode<R::Database>>::decode(raw) {
|
||||
Ok(s) => Some(s),
|
||||
Err(_) => {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw)
|
||||
.ok()
|
||||
.map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
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<ResultColumn> = Vec::new();
|
||||
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
||||
let mut affected: Option<u64> = 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<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, 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<Fetched> = 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<Self> {
|
||||
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<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = 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<QueryResult> {
|
||||
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<u64> {
|
||||
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<Sqlite>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
|
||||
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<QueryResult> {
|
||||
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: sqlx::Statement>(s: &S) -> Vec<ResultColumn> {
|
||||
s.columns()
|
||||
.iter()
|
||||
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() })
|
||||
.collect()
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbTableDetails")]
|
||||
pub struct TableDetails {
|
||||
pub columns: Vec<ColumnInfo>,
|
||||
pub indexes: Vec<IndexInfo>,
|
||||
pub foreign_keys: Vec<ForeignKey>,
|
||||
}
|
||||
|
||||
#[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<CompletionColumn>,
|
||||
}
|
||||
|
||||
/// `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<String>,
|
||||
pub ref_schema: String,
|
||||
pub ref_table: String,
|
||||
pub ref_columns: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<CompletionTable>,
|
||||
pub foreign_keys: Vec<ForeignKey>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// Connections of one project's data sources, keyed by connection target (see [`split_target`]).
|
||||
pub struct DbManager {
|
||||
root: PathBuf,
|
||||
pools: Mutex<HashMap<String, Arc<Pool>>>,
|
||||
/// SSH tunnels by data source id, shared by the pools of its databases
|
||||
/// (closed with the last of them).
|
||||
tunnels: Mutex<HashMap<String, Arc<Tunnel>>>,
|
||||
/// Serialises opening tunnels so concurrent connects share one.
|
||||
tunnel_open: tokio::sync::Mutex<()>,
|
||||
/// Host keys awaiting the user's trust, by data source id.
|
||||
unknown_host_keys: Mutex<HashMap<String, UnknownHostKey>>,
|
||||
known_hosts: PathBuf,
|
||||
ssh_config: Option<PathBuf>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Passwords entered this session (not saved, or not yet), by credential key.
|
||||
session_passwords: Mutex<HashMap<String, String>>,
|
||||
/// Manual-commit data sources, each with the connection of its open
|
||||
/// transaction (taken by the first statement after a commit / rollback).
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
}
|
||||
|
||||
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
|
||||
|
||||
const MAX_META_ROWS: usize = 100_000;
|
||||
|
||||
fn flag(v: &Option<String>) -> 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<Vec<Option<String>>>, tables: &[CompletionTable]) -> Vec<ForeignKey> {
|
||||
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
|
||||
/// `<id>:<database>` 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<PathBuf>,
|
||||
) -> 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<Vec<DataSource>> {
|
||||
source::load(&self.root)
|
||||
}
|
||||
|
||||
/// The data source of connection target `target`, with its database for `<id>:<database>`.
|
||||
pub fn source(&self, target: &str) -> IdeResult<DataSource> {
|
||||
let (id, database) = split_target(target);
|
||||
let mut source = self
|
||||
.sources()?
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?;
|
||||
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<DataSource>) -> 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<String>, 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<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
}
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
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<String>,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
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, `<id>:<database>`).
|
||||
pub fn connected(&self) -> Vec<String> {
|
||||
self.pools.lock().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// The pool of target `id`, connecting first if needed.
|
||||
pub async fn pool(&self, id: &str) -> IdeResult<Arc<Pool>> {
|
||||
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<String> = 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<Databases> {
|
||||
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<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
|
||||
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
|
||||
let rows = pool.rows(sql::DATABASES, MAX_META_ROWS).await?;
|
||||
let mut names: Vec<String> =
|
||||
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<Vec<String>> {
|
||||
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<Vec<TableInfo>> {
|
||||
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<TableDetails> {
|
||||
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<IndexInfo> = 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<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let missing = || IdeError::not_found(format!("{schema}.{name}"));
|
||||
let cells = |rows: Vec<Vec<Option<String>>>, i: usize| -> Vec<String> {
|
||||
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::<Vec<_>>()
|
||||
.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::<u32>().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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<CompletionModel> {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let mut tables: Vec<CompletionTable> = 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<QueryResult> {
|
||||
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<TablePage> {
|
||||
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<u64> {
|
||||
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::<IdeResult<Vec<_>>>()?;
|
||||
pool.transaction(&statements).await
|
||||
}
|
||||
}
|
||||
@@ -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<String, BTreeMap<String, String>>;
|
||||
|
||||
/// 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u16> {
|
||||
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<u16>,
|
||||
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<u16>,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
/// SQLite database file (absolute, or relative to the project root).
|
||||
pub file: Option<IpcPath>,
|
||||
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<DbSsh>,
|
||||
}
|
||||
|
||||
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<DataSource>,
|
||||
}
|
||||
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<SqlStatement> {
|
||||
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<SqlStatement>, 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<SqlStatement> {
|
||||
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<SqlStatement> {
|
||||
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<String> {
|
||||
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<_>>(), vec!["select 1", "select 2"]);
|
||||
assert_eq!((s[1].from, s[1].to), (13, 21));
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<String>,
|
||||
}
|
||||
|
||||
/// 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<CellValue>, values: Vec<CellValue> },
|
||||
Insert { values: Vec<CellValue> },
|
||||
Delete { key: Vec<CellValue> },
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<String, String> {
|
||||
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<String> = 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<String> = values.iter().map(|c| quote_ident(driver, &c.column)).collect();
|
||||
let vals: Vec<String> = 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<String>,
|
||||
}
|
||||
|
||||
/// `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<String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<IdeError> for OpenError {
|
||||
fn from(e: IdeError) -> Self {
|
||||
OpenError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where and how to connect.
|
||||
pub struct TunnelSpec<'a> {
|
||||
pub ssh: &'a DbSsh,
|
||||
/// SSH password or key passphrase.
|
||||
pub secret: Option<&'a str>,
|
||||
/// The database, as seen from the SSH host.
|
||||
pub target_host: &'a str,
|
||||
pub target_port: u16,
|
||||
pub known_hosts: &'a Path,
|
||||
/// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]).
|
||||
pub ssh_config: Option<&'a Path>,
|
||||
}
|
||||
|
||||
/// An open tunnel; dropping it stops forwarding and closes the SSH sessions.
|
||||
pub struct Tunnel {
|
||||
port: u16,
|
||||
session: Arc<Handle<Client>>,
|
||||
/// Sessions of the `ProxyJump` hosts, kept alive under `session`.
|
||||
_jumps: Vec<Handle<Client>>,
|
||||
accept: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Connects and starts listening on a free local port.
|
||||
pub async fn open(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22));
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_inner(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let hops = resolve_hops(spec.ssh, spec.ssh_config)?;
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
let target = i == hops.len() - 1;
|
||||
let mut handle = match sessions.last() {
|
||||
None => connect::<tokio::net::TcpStream>(hop, spec.known_hosts, None).await?,
|
||||
Some(prev) => {
|
||||
let channel = prev
|
||||
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
|
||||
connect(hop, spec.known_hosts, Some(channel.into_stream())).await?
|
||||
}
|
||||
};
|
||||
// Jump hosts always use the agent / identity files; the secret belongs to the target.
|
||||
let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh };
|
||||
authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?;
|
||||
sessions.push(handle);
|
||||
}
|
||||
let session = Arc::new(sessions.pop().expect("at least one hop"));
|
||||
// Fail fast with the real reason when the SSH host cannot reach the database
|
||||
// (a refused forward otherwise surfaces as the driver's connect timeout).
|
||||
let via = hops.last().expect("at least one hop").label();
|
||||
session
|
||||
.channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?;
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?;
|
||||
let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port();
|
||||
let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port));
|
||||
Ok(Tunnel { port, session, _jumps: sessions, accept })
|
||||
}
|
||||
|
||||
/// The local port to connect to instead of the database.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// The SSH session ended (network loss, server restart).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.session.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tunnel {
|
||||
fn drop(&mut self) {
|
||||
self.accept.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(listener: TcpListener, session: Arc<Handle<Client>>, host: String, port: u16) {
|
||||
loop {
|
||||
let Ok((mut socket, peer)) = listener.accept().await else { return };
|
||||
let session = session.clone();
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
match session
|
||||
.channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port()))
|
||||
.await
|
||||
{
|
||||
Ok(channel) => {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await;
|
||||
}
|
||||
Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError {
|
||||
let why = match e {
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => {
|
||||
"connection refused or host unreachable"
|
||||
}
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => {
|
||||
"port forwarding is not allowed by the SSH server (AllowTcpForwarding)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let why = if why.is_empty() { e.to_string() } else { why.to_owned() };
|
||||
io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}"))
|
||||
}
|
||||
|
||||
fn io(message: impl Into<String>) -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, message)
|
||||
}
|
||||
|
||||
// --- hosts ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Hop {
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
identity_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Hop {
|
||||
fn label(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
/// `~/x` → `$HOME/x`.
|
||||
fn expand(path: &str) -> PathBuf {
|
||||
match (path.strip_prefix("~/"), home()) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_user() -> String {
|
||||
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
|
||||
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
|
||||
let c = config
|
||||
.and_then(|p| russh_config::parse_path(p, alias).ok())
|
||||
.unwrap_or_else(|| russh_config::Config::default(alias));
|
||||
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
|
||||
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
|
||||
(hop, c.host_config.proxy_jump.clone())
|
||||
}
|
||||
|
||||
/// `[user@]host[:port]` of a `ProxyJump` entry.
|
||||
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
||||
let (user, rest) = match spec.rsplit_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, spec),
|
||||
};
|
||||
let (host, port) = match rest.rsplit_once(':') {
|
||||
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
||||
_ => (rest, None),
|
||||
};
|
||||
let (mut hop, _) = config_hop(config, host);
|
||||
if let Some(u) = user {
|
||||
hop.user = u.to_owned();
|
||||
}
|
||||
if let Some(p) = port {
|
||||
hop.port = p;
|
||||
}
|
||||
hop
|
||||
}
|
||||
|
||||
/// The SSH hosts to go through, the tunnel's host last.
|
||||
fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
|
||||
let host = ssh.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("SSH host is empty"));
|
||||
}
|
||||
let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh {
|
||||
config_hop(config, host)
|
||||
} else {
|
||||
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
|
||||
};
|
||||
if let Some(p) = ssh.port {
|
||||
hop.port = p;
|
||||
}
|
||||
if !ssh.user.trim().is_empty() {
|
||||
hop.user = ssh.user.trim().to_owned();
|
||||
}
|
||||
let mut hops: Vec<Hop> = proxy_jump
|
||||
.filter(|j| !j.eq_ignore_ascii_case("none"))
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default();
|
||||
hops.push(hop);
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
// --- connection ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HostKeyIssue {
|
||||
Unknown(PublicKey),
|
||||
Changed(usize),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
known_hosts: PathBuf,
|
||||
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let key = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
||||
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
||||
};
|
||||
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
||||
Ok(true) => return Ok(true),
|
||||
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
||||
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
||||
};
|
||||
*self.issue.lock() = Some(issue);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, OpenError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(Duration::from_secs(30)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let issue = Arc::new(Mutex::new(None));
|
||||
let handler =
|
||||
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
|
||||
let result = match stream {
|
||||
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
||||
Some(s) => client::connect_stream(config, s, handler).await,
|
||||
};
|
||||
result.map_err(|e| match issue.lock().take() {
|
||||
Some(HostKeyIssue::Unknown(key)) => {
|
||||
OpenError::UnknownHostKey(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })
|
||||
}
|
||||
Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
|
||||
hop.host, hop.port
|
||||
),
|
||||
)),
|
||||
None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
|
||||
})
|
||||
}
|
||||
|
||||
// --- authentication ------------------------------------------------------------
|
||||
|
||||
fn denied(message: String) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
|
||||
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
|
||||
}
|
||||
|
||||
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
|
||||
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
|
||||
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
|
||||
Ok(r.success())
|
||||
}
|
||||
|
||||
/// Every identity of the running SSH agent, until one is accepted.
|
||||
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
let agent = keys::agent::client::AgentClient::connect_env().await;
|
||||
#[cfg(windows)]
|
||||
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
|
||||
let Ok(mut agent) = agent else { return false };
|
||||
let Ok(identities) = agent.request_identities().await else { return false };
|
||||
let hash = rsa_hash(handle).await;
|
||||
for id in identities {
|
||||
let ok = match id {
|
||||
keys::agent::AgentIdentity::PublicKey { key, .. } => {
|
||||
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
|
||||
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
};
|
||||
if ok.unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
handle: &mut Handle<Client>,
|
||||
hop: &Hop,
|
||||
mode: DbSshAuth,
|
||||
key_file: &str,
|
||||
secret: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
let user = hop.user.as_str();
|
||||
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
|
||||
match mode {
|
||||
DbSshAuth::Password => {
|
||||
let Some(password) = secret else {
|
||||
return Err(denied(format!("SSH password required for {}", hop.label())));
|
||||
};
|
||||
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
|
||||
if r.success() { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::KeyPair => {
|
||||
if key_file.trim().is_empty() {
|
||||
return Err(IdeError::invalid("SSH private key file is not set"));
|
||||
}
|
||||
let path = expand(key_file.trim());
|
||||
let key = load_key(&path, secret)?;
|
||||
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::OpenSsh => {
|
||||
if with_agent(handle, user).await {
|
||||
return Ok(());
|
||||
}
|
||||
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
|
||||
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
|
||||
};
|
||||
let mut locked: Option<PathBuf> = None;
|
||||
for path in files.iter().filter(|p| p.is_file()) {
|
||||
match load_key(path, secret) {
|
||||
Ok(key) => {
|
||||
if with_key(handle, user, key).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
|
||||
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
|
||||
}
|
||||
}
|
||||
match locked {
|
||||
// An encrypted key could not be tried: ask for its passphrase.
|
||||
Some(path) if secret.is_none() => {
|
||||
Err(denied(format!("SSH key passphrase required for {}", path.display())))
|
||||
}
|
||||
_ => Err(failed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
|
||||
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
|
||||
match keys::load_secret_key(path, passphrase) {
|
||||
Ok(k) => Ok(k),
|
||||
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
|
||||
Err(e) if passphrase.is_some() => {
|
||||
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
|
||||
}
|
||||
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
|
||||
)
|
||||
.unwrap();
|
||||
let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
|
||||
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
|
||||
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
|
||||
|
||||
// Explicit fields win over the config.
|
||||
let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh };
|
||||
assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "[email protected]:22");
|
||||
|
||||
// Without OpenSSH mode the host is used as is.
|
||||
let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
|
||||
assert!(resolve_hops(&DbSsh::default(), None).is_err());
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAv8/hYRcxIJgEjL+hPXcAy8mrA333fDz6N2ZFFh3NBfQAAAJASX7F0El+x
|
||||
dAAAAAtzc2gtZWQyNTUxOQAAACAv8/hYRcxIJgEjL+hPXcAy8mrA333fDz6N2ZFFh3NBfQ
|
||||
AAAEBSLd2Q2WN97Ah63Y8wkaXU3cXmp7c2cF4ekACcsO6Iui/z+FhFzEgmASMv6E9dwDLy
|
||||
asDffd8PPo3ZkUWHc0F9AAAABmNsaWVudAECAwQFBgc=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACA0RkZCwoM8h1swUPrQEmT0Bc3epzST7Rbe7U284ktdzgAAAIh+eWzjfnls
|
||||
4wAAAAtzc2gtZWQyNTUxOQAAACA0RkZCwoM8h1swUPrQEmT0Bc3epzST7Rbe7U284ktdzg
|
||||
AAAEBEtrWyErO0+FkfXRdm+DtPkooHpHfeFNzbnlkrozs83zRGRkLCgzyHWzBQ+tASZPQF
|
||||
zd6nNJPtFt7tTbziS13OAAAABGhvc3QB
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABChbqZuKr
|
||||
F8Geuv2FT6GBoiAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIJq7vUgGWDSrABg4
|
||||
LGP0MNJ06Lx0KP8WBbTKXYcxOVj2AAAAkE2bir9ZcCMTtvRvj2j6CuJEl2S1I1159pYIlT
|
||||
s5X5sDATTmrvI/dpc4igbri027uAH7lt63X8MAdCuUuWg8KbI/oHUua3Dh8s+59OFcfyeB
|
||||
+QaICI6bVo6gQB8eHSgNBV1CeRUjKjpi5hIMBN4V2MWC+N+OdPSpPZoGNII4PaL1JP3wEw
|
||||
glVEcquI7WpRuVSQ==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -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<_>>(), 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(())
|
||||
}
|
||||
@@ -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<std::net::SocketAddr>) -> Sshd {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for Sshd {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_direct_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
host: &str,
|
||||
port: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
tokio::spawn(async move {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo server standing in for the database.
|
||||
async fn echo() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut s, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = s.split();
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
async fn sshd() -> u16 {
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![key("host", None)],
|
||||
auth_rejection_time: std::time::Duration::from_millis(10),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
||||
..Default::default()
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
|
||||
tokio::spawn(async move {
|
||||
let _ = server.run_on_socket(config, &listener).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
known_hosts: PathBuf,
|
||||
ssh_port: u16,
|
||||
db_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let known_hosts = tmp.path().join("known_hosts");
|
||||
Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn ssh(&self, auth: DbSshAuth) -> DbSsh {
|
||||
DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
Tunnel::open(TunnelSpec {
|
||||
ssh,
|
||||
secret,
|
||||
target_host: "127.0.0.1",
|
||||
target_port: self.db_port,
|
||||
known_hosts: &self.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Trusts the server's key on the first connection, as the UI does.
|
||||
async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
match self.open(ssh, secret).await {
|
||||
Err(OpenError::UnknownHostKey(k)) => {
|
||||
k.trust(&self.known_hosts).unwrap();
|
||||
self.open(ssh, secret).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip(t: &Tunnel) {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
|
||||
s.write_all(b"SELECT 1").await.unwrap();
|
||||
let mut buf = [0u8; 8];
|
||||
s.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"SELECT 1");
|
||||
}
|
||||
|
||||
fn denied(r: Result<Tunnel, OpenError>) -> String {
|
||||
match r {
|
||||
Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
|
||||
Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"),
|
||||
Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
|
||||
Ok(_) => panic!("expected permission denied, connected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else {
|
||||
panic!("expected an unknown host key");
|
||||
};
|
||||
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
|
||||
k.trust(&env.known_hosts).unwrap();
|
||||
round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await;
|
||||
|
||||
// Another key recorded for the same host: refused, never offered for trust.
|
||||
let other = key("client", None).public_key().to_openssh().unwrap();
|
||||
std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
||||
let msg = denied(env.open(&ssh, Some("secret")).await);
|
||||
assert!(msg.contains("has changed"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_authentication() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap();
|
||||
round_trip(&tunnel).await;
|
||||
round_trip(&tunnel).await;
|
||||
assert!(!tunnel.is_closed());
|
||||
|
||||
assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required"));
|
||||
assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_authentication_with_and_without_passphrase() {
|
||||
let env = env().await;
|
||||
let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) };
|
||||
round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await;
|
||||
|
||||
// An encrypted key the server does not accept: passphrase first, then the key is refused.
|
||||
let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() };
|
||||
assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required"));
|
||||
assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase"));
|
||||
assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_database_fails_fast_with_the_reason() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap());
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let r = Tunnel::open(TunnelSpec {
|
||||
ssh: &ssh,
|
||||
secret: Some("secret"),
|
||||
target_host: "127.0.0.1",
|
||||
target_port: closed,
|
||||
known_hosts: &env.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await;
|
||||
let Err(OpenError::Other(e)) = r else { panic!("expected an error") };
|
||||
assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<PathBuf> {
|
||||
static DOCKER: OnceLock<Option<PathBuf>> = 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<PathBuf> = 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<PathBuf> {
|
||||
docker_executable()
|
||||
.ok_or_else(|| IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH"))
|
||||
}
|
||||
|
||||
fn command(args: &[&str]) -> IdeResult<Command> {
|
||||
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<String> {
|
||||
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<String>>| {
|
||||
h.join().unwrap_or_else(|_| Err(IdeError::internal("docker listing panicked")))
|
||||
};
|
||||
(join(c), join(i), join(v), join(n))
|
||||
});
|
||||
let mut take = |r: IdeResult<String>, 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 <object> 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<String> {
|
||||
let out = run(&["inspect", "--type", object.noun(), id], QUICK)?;
|
||||
match serde_json::from_str::<serde_json::Value>(&out) {
|
||||
// A one-element array for a single id: show the object itself.
|
||||
Ok(serde_json::Value::Array(mut items)) if items.len() == 1 => {
|
||||
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 <reference>`.
|
||||
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 <object> 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 <project> <op>` 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)
|
||||
}
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// No `docker` executable was found.
|
||||
NotInstalled,
|
||||
/// The CLI is there but cannot reach a daemon (not started, wrong context…).
|
||||
DaemonDown { message: String, context: Option<String> },
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub compose_service: Option<String>,
|
||||
/// `com.docker.compose.project.working_dir` label.
|
||||
pub compose_working_dir: Option<String>,
|
||||
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
|
||||
pub compose_files: Vec<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
/// `<none>` 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 == "<none>" || self.repository.is_empty() {
|
||||
short_id(&self.id).to_owned()
|
||||
} else if self.tag == "<none>" || 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub containers: Vec<DockerContainer>,
|
||||
pub images: Vec<DockerImage>,
|
||||
pub volumes: Vec<DockerVolume>,
|
||||
pub networks: Vec<DockerNetwork>,
|
||||
/// Lists that failed although the daemon answered (shown, not fatal).
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
let mut out: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut last: Option<String> = 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<T: for<'de> Deserialize<'de>>(text: &str) -> impl Iterator<Item = T> + '_ {
|
||||
text.lines().filter(|l| l.trim_start().starts_with('{')).filter_map(|l| match serde_json::from_str::<T>(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<DockerContainer> {
|
||||
let mut out: Vec<DockerContainer> = lines::<RawContainer>(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<DockerImage> {
|
||||
let mut out: Vec<DockerImage> = lines::<RawImage>(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 == "<none>", &a.repository, &a.tag).cmp(&(b.repository == "<none>", &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<DockerVolume> {
|
||||
let mut out: Vec<DockerVolume> = lines::<RawVolume>(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<DockerNetwork> {
|
||||
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(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<RawVersionPart>,
|
||||
#[serde(rename = "Server")]
|
||||
server: Option<RawVersionPart>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawVersionPart {
|
||||
version: String,
|
||||
context: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<DockerEvent> {
|
||||
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("<none>", "<none>").reference(), "feedfacecafe");
|
||||
assert_eq!(img("local/app", "<none>").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);
|
||||
}
|
||||
}
|
||||
@@ -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<Child> {
|
||||
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<String> {
|
||||
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<Output> {
|
||||
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<Event>, stream: Stream) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
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<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
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<String> = 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))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"Containers":"N/A","CreatedAt":"2026-09-20 08:00:00 +0200 CEST","CreatedSince":"4 weeks ago","Digest":"<none>","ID":"sha256:aaaaaaaaaaaabbbbbbbbbbbbccccccccccccddddddddddddeeeeeeeeeeeeffff","Repository":"<none>","SharedSize":"N/A","Size":"120MB","Tag":"<none>","UniqueSize":"N/A","VirtualSize":"120MB"}
|
||||
{"Containers":"N/A","CreatedAt":"2026-09-01 08:00:00 +0200 CEST","CreatedSince":"7 weeks ago","Digest":"<none>","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":"<none>","ID":"sha256:7777777777778888888888889999999999990000000000001111111111112222","Repository":"nginx","SharedSize":"N/A","Size":"192MB","Tag":"1.27","UniqueSize":"N/A","VirtualSize":"192MB"}
|
||||
@@ -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"}
|
||||
+3
@@ -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 <[email protected]>","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
|
||||
@@ -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"}
|
||||
@@ -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<String> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["bridge", "host", "shop_default"]);
|
||||
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(n[1].driver, "host");
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<PathBuf> {
|
||||
static GH: OnceLock<Option<PathBuf>> = 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<Command> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<T>(r: serde_json::Result<T>) -> IdeResult<T> {
|
||||
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<String> {
|
||||
let mut args: Vec<String> = ["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<Vec<PullRequestSummary>> {
|
||||
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<PullRequestDetails> {
|
||||
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<Vec<PrReviewComment>> {
|
||||
let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments?per_page=100");
|
||||
parse(parse_review_comments(&run(root, &["api", "--paginate", &path])?))
|
||||
}
|
||||
|
||||
/// `gh pr checkout <n>`: 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<String> {
|
||||
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<ide_vcs::ChangedFile>,
|
||||
/// 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/<n>/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<PrDiffRevisions> {
|
||||
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()) })
|
||||
}
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<PrLabel>,
|
||||
/// `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or `None`.
|
||||
pub review_decision: Option<String>,
|
||||
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<String>,
|
||||
pub status: CheckStatus,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub closed_at: Option<String>,
|
||||
pub checks: Vec<PrCheck>,
|
||||
pub reviewers: Vec<PrReviewer>,
|
||||
pub assignees: Vec<String>,
|
||||
pub commits: Vec<PrCommit>,
|
||||
pub files: Vec<PrFile>,
|
||||
/// Comments and reviews, oldest first.
|
||||
pub timeline: Vec<PrTimelineItem>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
pub path: String,
|
||||
/// 1-based line in the current diff; `None` when the comment is outdated.
|
||||
pub line: Option<u32>,
|
||||
pub original_line: Option<u32>,
|
||||
/// `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<RawActor>,
|
||||
state: String,
|
||||
is_draft: bool,
|
||||
head_ref_name: String,
|
||||
base_ref_name: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
labels: Vec<RawLabel>,
|
||||
review_decision: Option<String>,
|
||||
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<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = 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<String>,
|
||||
/// Team requests carry a name / slug instead of a login.
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawReview {
|
||||
author: Option<RawActor>,
|
||||
body: String,
|
||||
state: String,
|
||||
submitted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawComment {
|
||||
author: Option<RawActor>,
|
||||
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<RawCommitAuthor>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
closed_at: Option<String>,
|
||||
status_check_rollup: Vec<RawCheck>,
|
||||
review_requests: Vec<RawRequest>,
|
||||
reviews: Vec<RawReview>,
|
||||
assignees: Vec<RawActor>,
|
||||
commits: Vec<RawCommit>,
|
||||
files: Vec<RawFile>,
|
||||
comments: Vec<RawComment>,
|
||||
}
|
||||
|
||||
/// 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<PrReviewer> {
|
||||
let mut out: Vec<PrReviewer> = 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<PullRequestDetails> {
|
||||
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<PrTimelineItem> = 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<PrCheck> = 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<u64>,
|
||||
path: String,
|
||||
line: Option<u32>,
|
||||
original_line: Option<u32>,
|
||||
side: Option<String>,
|
||||
body: String,
|
||||
user: Option<RawActor>,
|
||||
created_at: String,
|
||||
html_url: String,
|
||||
diff_hunk: String,
|
||||
}
|
||||
|
||||
/// `gh api repos/{owner}/{repo}/pulls/<n>/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<Vec<PrReviewComment>> {
|
||||
let mut raw: Vec<RawReviewComment> = Vec::new();
|
||||
for value in serde_json::Deserializer::from_str(text).into_iter::<serde_json::Value>() {
|
||||
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::<Vec<RawReviewComment>>(page)?);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<PrReviewComment> = 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<RawBranchRef>,
|
||||
}
|
||||
|
||||
#[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<GhRepo> {
|
||||
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: "[email protected]: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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Child> {
|
||||
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<String> {
|
||||
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<Output> {
|
||||
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<Event>, stream: Stream) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
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<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
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<String> = 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))
|
||||
}
|
||||
@@ -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"}]
|
||||
@@ -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":"[email protected]","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"}
|
||||
@@ -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}}]
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String, BTreeMap<String, String>>;
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<EnvMap> {
|
||||
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<EnvMap> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let mut layer = |m: Option<&BTreeMap<String, String>>| {
|
||||
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<String> {
|
||||
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<String, String>,
|
||||
}
|
||||
|
||||
impl Resolver<'_> {
|
||||
fn lookup(&self, name: &str, depth: u32, missing: &mut BTreeSet<String>) -> Option<String> {
|
||||
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>) -> 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>) -> 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<_>>(), 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Body as text (lossy UTF-8); `None` for binary bodies.
|
||||
pub body: Option<String>,
|
||||
/// 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<PreparedRequest> {
|
||||
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<String> = 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<reqwest::Client> {
|
||||
static CLIENT: OnceLock<Result<reqwest::Client, String>> = 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<HttpResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
/// `None` when the request failed.
|
||||
pub status: Option<u16>,
|
||||
pub total_ms: Option<f64>,
|
||||
/// The `.http` file the request came from.
|
||||
pub file: Option<IpcPath>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<HttpResponse>,
|
||||
}
|
||||
|
||||
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<HistoryEntry> {
|
||||
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<PathBuf> {
|
||||
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<HistoryEntry> {
|
||||
let _g = self.lock.lock();
|
||||
self.read_index()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> IdeResult<HttpExchange> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<HttpExchange> {
|
||||
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<Vec<String>> {
|
||||
Ok(Environments::load(file, root)?.names())
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<BodyPart>,
|
||||
}
|
||||
|
||||
/// 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<RawRequest>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String>, Vec<&str>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0u32;
|
||||
let mut title: Option<String> = 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<BodyPart> {
|
||||
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<HttpRequestBlock> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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<StoredSymbol>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct StoredSymbol {
|
||||
pub name: String,
|
||||
pub kind: SymbolKind,
|
||||
pub container: Option<String>,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
pub(crate) struct Sym {
|
||||
pub name: Box<str>,
|
||||
pub kind: SymbolKind,
|
||||
pub container: Option<Box<str>>,
|
||||
pub range: Range,
|
||||
pub mask: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct FileEntry {
|
||||
pub path: PathBuf,
|
||||
/// `/`-separated path relative to the root, as displayed.
|
||||
pub rel: Box<str>,
|
||||
/// 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<Option<FileEntry>>,
|
||||
by_path: HashMap<PathBuf, u32>,
|
||||
free: Vec<u32>,
|
||||
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<u32> {
|
||||
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<FileEntry> {
|
||||
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<String> {
|
||||
let doomed: Vec<PathBuf> = 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<Item = &PathBuf> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<Found> {
|
||||
let (tx, rx) = mpsc::channel::<Vec<Found>>();
|
||||
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<Vec<Found>>,
|
||||
items: Vec<Found>,
|
||||
}
|
||||
|
||||
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<String> =
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<Shared>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<Syntax>,
|
||||
sink: SharedSink,
|
||||
) -> IdeResult<Arc<Self>> {
|
||||
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<FileMatch> {
|
||||
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<SymbolMatch> {
|
||||
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<PathBuf> = changed.iter().filter_map(|p| self.normalize(p)).collect();
|
||||
let removed: Vec<PathBuf> = 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
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<FileMatch> {
|
||||
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
|
||||
}
|
||||
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT.match_paths()));
|
||||
}
|
||||
|
||||
fn with_matcher<R>(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<char>) -> Option<u32> {
|
||||
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
|
||||
}
|
||||
|
||||
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
|
||||
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<char> = 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<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
|
||||
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<Cand>) + Sync,
|
||||
) -> Vec<Cand> {
|
||||
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<usize> {
|
||||
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<FileMatch> {
|
||||
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<u32>)> = 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<u32> = 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<char>) -> Option<u32> {
|
||||
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<char>) -> Option<Vec<u32>> {
|
||||
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<char>,
|
||||
) -> Option<u32> {
|
||||
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<SymbolMatch> {
|
||||
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<u32>)> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, String>;
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
pub(crate) struct Store {
|
||||
/// Write-locked only for [`Store::compact`].
|
||||
db: RwLock<Database>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Vec<(String, FileRecord)>> {
|
||||
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::<FileRecord>(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<bool> {
|
||||
self.db.write().compact().map_err(err)
|
||||
}
|
||||
|
||||
pub(crate) fn load_recent(&self) -> Vec<String> {
|
||||
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<String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectIndex> {
|
||||
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<ProjectIndex> {
|
||||
self.open_with(Arc::new(NullSink))
|
||||
}
|
||||
}
|
||||
|
||||
fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
|
||||
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
|
||||
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 <button/>; }\n"),
|
||||
("web/app.py", "class App:\n def run(self):\n pass\n\ndef main():\n pass\n"),
|
||||
("docs/maintenance.md", "# Maintenance\n"),
|
||||
("node_modules/lib/index.js", "function hidden() {}\n"),
|
||||
("target/debug/gen.rs", "fn generated() {}\n"),
|
||||
("data.json", "{\"a\": 1}\n"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexes_files_and_symbols_honouring_excludes() {
|
||||
let fx = sample();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let idx = fx.open_with(sink);
|
||||
let st = idx.status();
|
||||
assert!(!st.indexing);
|
||||
assert_eq!(st.files, 8, "node_modules and target are excluded");
|
||||
assert!(st.symbols >= 12, "{st:?}");
|
||||
assert!(files(&idx, "index.js").is_empty());
|
||||
assert!(symbols(&idx, "generated", SymbolFilter::All).is_empty());
|
||||
|
||||
let states: Vec<bool> = events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::IndexState { indexing, .. } => Some(*indexing),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(states.first(), Some(&true));
|
||||
assert_eq!(states.last(), Some(&false));
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
|
||||
|
||||
let m = &idx.find_symbols("load", SymbolFilter::All, 5)[0];
|
||||
assert_eq!((m.name.as_str(), m.container.as_deref(), m.kind), ("load", Some("Config"), SymbolKind::Method));
|
||||
assert_eq!(m.rel_path, "src/main.rs");
|
||||
assert_eq!(m.range.start.line, 2);
|
||||
assert_eq!(m.path, fx.root.join("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_ranking_is_idea_like() {
|
||||
let fx = sample();
|
||||
let idx = fx.open();
|
||||
// File-name matches beat directory matches.
|
||||
assert_eq!(files(&idx, "main")[..2], ["src/main.rs".to_owned(), "docs/maintenance.md".to_owned()]);
|
||||
assert_eq!(files(&idx, "order")[0], "src/order/Order.java");
|
||||
// Path segments.
|
||||
assert_eq!(files(&idx, "ui/btn")[0], "src/ui/Button.tsx");
|
||||
assert_eq!(files(&idx, "src/order/")[..2].len(), 2);
|
||||
assert!(files(&idx, "web/main").is_empty());
|
||||
// CamelHump.
|
||||
let oco = files(&idx, "OCo");
|
||||
assert!(oco[..2].contains(&"src/order/OrderController.java".to_owned()), "{oco:?}");
|
||||
assert!(oco[..2].contains(&"src/other/OtherCollection.ts".to_owned()), "{oco:?}");
|
||||
assert_eq!(files(&idx, "OrCo")[0], "src/order/OrderController.java");
|
||||
assert_eq!(files(&idx, "ordcon")[0], "src/order/OrderController.java");
|
||||
// `file:line` suffixes are ignored.
|
||||
assert_eq!(files(&idx, "Button.tsx:12")[0], "src/ui/Button.tsx");
|
||||
|
||||
let m = &idx.find_files("main", 1)[0];
|
||||
assert_eq!(m.indices, vec![4, 5, 6, 7]);
|
||||
let m = &idx.find_files("ui/btn", 1)[0];
|
||||
let shown: String = m.indices.iter().map(|&i| m.rel_path.chars().nth(i as usize).unwrap()).collect();
|
||||
assert_eq!(shown.to_lowercase(), "uibtn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_paths_win_ties_and_recent_files_boost() {
|
||||
let fx = Fixture::new(&[("a/b/c/util.rs", ""), ("x/util.rs", ""), ("y/util.rs", "")]);
|
||||
let idx = fx.open();
|
||||
assert_eq!(files(&idx, "util"), vec!["x/util.rs", "y/util.rs", "a/b/c/util.rs"]);
|
||||
idx.mark_recent(&fx.root.join("a/b/c/util.rs"));
|
||||
assert_eq!(files(&idx, "util")[0], "a/b/c/util.rs");
|
||||
// Empty query lists recent files.
|
||||
assert_eq!(files(&idx, ""), vec!["a/b/c/util.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_ranking_is_idea_like() {
|
||||
let fx = sample();
|
||||
let idx = fx.open();
|
||||
assert_eq!(symbols(&idx, "OrCo", SymbolFilter::Types)[0], "OrderController");
|
||||
assert_eq!(symbols(&idx, "OtCo", SymbolFilter::Types)[0], "OtherCollection");
|
||||
assert_eq!(symbols(&idx, "ordcon", SymbolFilter::All)[0], "OrderController");
|
||||
assert_eq!(
|
||||
symbols(&idx, "OC", SymbolFilter::Types)[..2],
|
||||
["OrderController".to_owned(), "OtherCollection".to_owned()]
|
||||
);
|
||||
// Exact beats prefix.
|
||||
assert_eq!(symbols(&idx, "order", SymbolFilter::Types)[0], "Order");
|
||||
// Types filter.
|
||||
let types = symbols(&idx, "a", SymbolFilter::Types);
|
||||
assert!(types.contains(&"App".to_owned()));
|
||||
assert!(!types.contains(&"add".to_owned()));
|
||||
// Container-qualified queries.
|
||||
let m = idx.find_symbols("OrderController.save", SymbolFilter::All, 5);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].container.as_deref(), Some("OrderController"));
|
||||
let m = idx.find_symbols("App.run", SymbolFilter::All, 5);
|
||||
assert_eq!(m[0].name, "run");
|
||||
// Indices into the name.
|
||||
let m = &idx.find_symbols("OCo", SymbolFilter::Types, 1)[0];
|
||||
assert_eq!(m.indices, vec![0, 5, 6]);
|
||||
assert!(idx.find_symbols("", SymbolFilter::All, 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_skips_unchanged_files_on_reopen() {
|
||||
let fx = sample();
|
||||
{
|
||||
let idx = fx.open();
|
||||
assert_eq!(idx.shared.parsed.load(Ordering::SeqCst), 8);
|
||||
idx.mark_recent(&fx.root.join("web/app.py"));
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
}
|
||||
{
|
||||
let idx = fx.open();
|
||||
assert_eq!(idx.shared.parsed.load(Ordering::SeqCst), 0, "nothing changed");
|
||||
assert_eq!(idx.status().files, 8);
|
||||
assert_eq!(symbols(&idx, "OrderController", SymbolFilter::Types)[0], "OrderController");
|
||||
assert_eq!(idx.recent_files(), vec![fx.root.join("web/app.py")]);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
fx.write("src/main.rs", "fn main() {}\nfn renamed_fn() {}\n");
|
||||
std::fs::remove_file(fx.root.join("docs/maintenance.md")).unwrap();
|
||||
{
|
||||
let idx = fx.open();
|
||||
assert_eq!(idx.shared.parsed.load(Ordering::SeqCst), 1, "only the changed file is re-parsed");
|
||||
assert_eq!(idx.status().files, 7);
|
||||
assert_eq!(symbols(&idx, "renamed_fn", SymbolFilter::All), vec!["renamed_fn"]);
|
||||
assert!(symbols(&idx, "Config", SymbolFilter::Types).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_updates() {
|
||||
let fx = sample();
|
||||
let idx = fx.open();
|
||||
let before = idx.status();
|
||||
|
||||
// New file.
|
||||
let p = fx.write("src/new/Fresh.kt", "class FreshThing {\n fun go() {}\n}\n");
|
||||
idx.update(std::slice::from_ref(&p), &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(symbols(&idx, "FreshThing", SymbolFilter::Types), vec!["FreshThing"]);
|
||||
assert_eq!(idx.status().files, before.files + 1);
|
||||
|
||||
// Modified file.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
fx.write("src/new/Fresh.kt", "class Renamed {\n}\n");
|
||||
idx.update(std::slice::from_ref(&p), &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "FreshThing", SymbolFilter::Types).is_empty());
|
||||
assert_eq!(symbols(&idx, "Renamed", SymbolFilter::Types), vec!["Renamed"]);
|
||||
|
||||
// Removed file and removed directory.
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
std::fs::remove_dir_all(fx.root.join("src/order")).unwrap();
|
||||
idx.update(&[], &[p, fx.root.join("src/order")]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(files(&idx, "Fresh").is_empty());
|
||||
assert!(symbols(&idx, "OrderController", SymbolFilter::All).is_empty());
|
||||
assert_eq!(idx.status().files, before.files - 2);
|
||||
|
||||
// New directory (reported as a directory), excluded and outside paths are ignored.
|
||||
fx.write("lib/a/one.go", "package a\nfunc One() {}\n");
|
||||
fx.write("lib/a/two.go", "package a\nfunc Two() {}\n");
|
||||
let ignored = fx.write("node_modules/x/y.js", "function nope() {}\n");
|
||||
idx.update(&[fx.root.join("lib"), ignored, PathBuf::from("/definitely/not/here.rs")], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(symbols(&idx, "One", SymbolFilter::All), vec!["One"]);
|
||||
assert_eq!(symbols(&idx, "Two", SymbolFilter::All), vec!["Two"]);
|
||||
assert!(symbols(&idx, "nope", SymbolFilter::All).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_and_huge_files_are_listed_without_symbols() {
|
||||
let fx = Fixture::new(&[("img/logo.rs", "")]);
|
||||
std::fs::write(fx.root.join("img/logo.rs"), b"fn a() {}\0\x01\x02").unwrap();
|
||||
let huge = "fn x() {}\n".repeat((MAX_SYMBOL_FILE as usize / 10) + 10);
|
||||
fx.write("big/huge.rs", &huge);
|
||||
let idx = fx.open();
|
||||
assert_eq!(files(&idx, "logo"), vec!["img/logo.rs"]);
|
||||
assert_eq!(files(&idx, "huge"), vec!["big/huge.rs"]);
|
||||
assert_eq!(idx.status().symbols, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_window_on_same_project_works_in_memory() {
|
||||
let fx = sample();
|
||||
let a = fx.open();
|
||||
let b = fx.open();
|
||||
assert_eq!(a.status().files, b.status().files);
|
||||
assert_eq!(symbols(&b, "Order", SymbolFilter::Types)[0], "Order");
|
||||
}
|
||||
|
||||
/// Synthetic 100k-file project. Run with:
|
||||
/// `cargo test --release -p ide-index bench_large_project -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_large_project() {
|
||||
use std::time::Instant;
|
||||
let n: usize = std::env::var("OPENIDEA_BENCH_FILES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
|
||||
let fx = Fixture::new(&[]);
|
||||
let t = Instant::now();
|
||||
let words = ["order", "user", "invoice", "payment", "cart", "session", "report", "config", "event", "queue"];
|
||||
for i in 0..n {
|
||||
let w = words[i % words.len()];
|
||||
let w2 = words[(i / 10) % words.len()];
|
||||
let cap = |s: &str| format!("{}{}", s[..1].to_uppercase(), &s[1..]);
|
||||
let dir = format!("mod{}/{}/{}", i % 50, w2, i % 7);
|
||||
let (rel, text) = match i % 4 {
|
||||
0 => (
|
||||
format!("{dir}/{w}_{i}.rs"),
|
||||
format!(
|
||||
"pub struct {C}{i} {{ id: u64 }}\nimpl {C}{i} {{\n pub fn new() -> Self {{ Self {{ id: {i} }} }}\n pub fn process_{w}(&self) {{}}\n}}\n",
|
||||
C = cap(w)
|
||||
),
|
||||
),
|
||||
1 => (
|
||||
format!("{dir}/{C}{D}Service{i}.ts", C = cap(w), D = cap(w2)),
|
||||
format!(
|
||||
"export class {C}{D}Service{i} {{\n handle{C}(x: number) {{ return x; }}\n}}\n",
|
||||
C = cap(w),
|
||||
D = cap(w2)
|
||||
),
|
||||
),
|
||||
2 => (
|
||||
format!("{dir}/{w}_{i}.py"),
|
||||
format!("class {C}Model{i}:\n def save(self):\n pass\n", C = cap(w)),
|
||||
),
|
||||
_ => (
|
||||
format!("{dir}/{C}Controller{i}.java", C = cap(w)),
|
||||
format!("class {C}Controller{i} {{\n void get() {{}}\n}}\n", C = cap(w)),
|
||||
),
|
||||
};
|
||||
fx.write(&rel, &text);
|
||||
}
|
||||
eprintln!("generated {n} files in {:?}", t.elapsed());
|
||||
|
||||
let t = Instant::now();
|
||||
let idx = fx.open();
|
||||
let first = t.elapsed();
|
||||
let st = idx.status();
|
||||
eprintln!("first index: {first:?} ({} files, {} symbols)", st.files, st.symbols);
|
||||
assert_eq!(st.files as usize, n);
|
||||
drop(idx);
|
||||
|
||||
let t = Instant::now();
|
||||
let idx = fx.open();
|
||||
eprintln!("re-open (cache hit): {:?}, parsed {}", t.elapsed(), idx.shared.parsed.load(Ordering::SeqCst));
|
||||
|
||||
let mut times = Vec::new();
|
||||
for q in ["order", "OCo", "usrsvc", "mod3/cart", "payment_1234", "Contr", "x", "InvoiceUserService", "zzzz"] {
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
let r = idx.find_files(q, 50);
|
||||
times.push(t.elapsed());
|
||||
std::hint::black_box(r);
|
||||
}
|
||||
}
|
||||
let mut stimes = Vec::new();
|
||||
for q in ["OCo", "process_order", "handleCart", "save", "UserModel1", "Order.new", "q", "zzzz"] {
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
let r = idx.find_symbols(q, SymbolFilter::All, 50);
|
||||
stimes.push(t.elapsed());
|
||||
std::hint::black_box(r);
|
||||
}
|
||||
}
|
||||
times.sort();
|
||||
stimes.sort();
|
||||
eprintln!(
|
||||
"find_files p50 {:?} p90 {:?} max {:?}",
|
||||
times[times.len() / 2],
|
||||
times[times.len() * 9 / 10],
|
||||
times.last().unwrap()
|
||||
);
|
||||
eprintln!(
|
||||
"find_symbols p50 {:?} p90 {:?} max {:?}",
|
||||
stimes[stimes.len() / 2],
|
||||
stimes[stimes.len() * 9 / 10],
|
||||
stimes.last().unwrap()
|
||||
);
|
||||
if n >= 100_000 {
|
||||
assert!(first < Duration::from_secs(30), "first index took {first:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_keeps_results() {
|
||||
let f = Fixture::new(&[("src/order.rs", "struct Order;\nfn place_order() {}\n"), ("b.rs", "fn b() {}\n")]);
|
||||
let idx = f.open();
|
||||
let before = (files(&idx, "order"), symbols(&idx, "Order", SymbolFilter::All));
|
||||
std::fs::remove_file(f.root.join("b.rs")).unwrap();
|
||||
idx.update(&[], &[f.root.join("b.rs")]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
idx.compact();
|
||||
assert_eq!((files(&idx, "order"), symbols(&idx, "Order", SymbolFilter::All)), before);
|
||||
assert_eq!(idx.status().files, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_index_answers_queries() {
|
||||
let s = bench_support::SyntheticIndex::new(3_000, 4);
|
||||
assert_eq!((s.file_count(), s.symbol_count()), (3_000, 12_000));
|
||||
assert!(!s.find_files("ordcon", 20).is_empty());
|
||||
assert!(!s.find_symbols("PaySe", SymbolFilter::All, 20).is_empty());
|
||||
assert!(s.find_symbols("PaySe", SymbolFilter::Types, 20).iter().all(|m| m.kind == SymbolKind::Class));
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! The background indexing thread: initial load + full scan, then incremental
|
||||
//! updates queued by the watcher.
|
||||
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
||||
use crate::filter::{Found, PathFilter, mtime_of};
|
||||
use crate::store::Store;
|
||||
use ide_core::{IdeEvent, SharedSink, TaskId};
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use rayon::prelude::*;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Files larger than this are listed but not parsed for symbols.
|
||||
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
|
||||
const CHUNK: usize = 512;
|
||||
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
|
||||
pub(crate) const MAX_RECENT: usize = 50;
|
||||
|
||||
pub(crate) enum Job {
|
||||
Changed(Vec<PathBuf>),
|
||||
Removed(Vec<PathBuf>),
|
||||
SaveRecent,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
jobs: VecDeque<Job>,
|
||||
busy: bool,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Shared {
|
||||
pub filter: PathFilter,
|
||||
pub languages: std::sync::Arc<LanguageRegistry>,
|
||||
pub syntax: std::sync::Arc<Syntax>,
|
||||
pub sink: SharedSink,
|
||||
pub data: RwLock<IndexData>,
|
||||
/// Most recent first.
|
||||
pub recent: Mutex<Vec<PathBuf>>,
|
||||
pub store: Option<Store>,
|
||||
pub indexing: AtomicBool,
|
||||
pub cancelled: AtomicBool,
|
||||
/// Files whose content was (re)read and parsed; observability for tests.
|
||||
pub parsed: std::sync::atomic::AtomicUsize,
|
||||
queue: Mutex<Queue>,
|
||||
wake: Condvar,
|
||||
idle: Condvar,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(crate) fn new(
|
||||
filter: PathFilter,
|
||||
languages: std::sync::Arc<LanguageRegistry>,
|
||||
syntax: std::sync::Arc<Syntax>,
|
||||
sink: SharedSink,
|
||||
store: Option<Store>,
|
||||
) -> Self {
|
||||
Self {
|
||||
filter,
|
||||
languages,
|
||||
syntax,
|
||||
sink,
|
||||
data: RwLock::default(),
|
||||
recent: Mutex::default(),
|
||||
store,
|
||||
indexing: AtomicBool::new(true),
|
||||
cancelled: AtomicBool::new(false),
|
||||
parsed: Default::default(),
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
idle: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue(&self, job: Job) {
|
||||
self.queue.lock().jobs.push_back(job);
|
||||
self.wake.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
let _q = self.queue.lock();
|
||||
self.wake.notify_all();
|
||||
self.idle.notify_all();
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Blocks until the initial scan and all queued updates are done.
|
||||
pub(crate) fn wait_idle(&self, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut q = self.queue.lock();
|
||||
while !(q.started && !q.busy && q.jobs.is_empty()) {
|
||||
if self.is_cancelled() || self.idle.wait_until(&mut q, deadline).timed_out() {
|
||||
return q.started && !q.busy && q.jobs.is_empty();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn emit_state(&self) {
|
||||
let (files, symbols) = {
|
||||
let d = self.data.read();
|
||||
(d.file_count, d.symbol_count)
|
||||
};
|
||||
self.sink.emit(IdeEvent::IndexState { indexing: self.indexing.load(Ordering::SeqCst), files, symbols });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- thread
|
||||
|
||||
pub(crate) fn run(&self) {
|
||||
{
|
||||
let mut q = self.queue.lock();
|
||||
q.busy = true;
|
||||
}
|
||||
self.load_persisted();
|
||||
self.full_scan();
|
||||
{
|
||||
let mut q = self.queue.lock();
|
||||
q.busy = false;
|
||||
q.started = true;
|
||||
self.idle.notify_all();
|
||||
}
|
||||
loop {
|
||||
let jobs: Vec<Job> = {
|
||||
let mut q = self.queue.lock();
|
||||
while q.jobs.is_empty() && !self.is_cancelled() {
|
||||
self.wake.wait(&mut q);
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
q.busy = true;
|
||||
q.jobs.drain(..).collect()
|
||||
};
|
||||
self.process(jobs);
|
||||
let mut q = self.queue.lock();
|
||||
q.busy = false;
|
||||
if q.jobs.is_empty() {
|
||||
self.idle.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_persisted(&self) {
|
||||
let Some(store) = &self.store else { return };
|
||||
let root = self.filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> =
|
||||
records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
}
|
||||
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
|
||||
let mut r = self.recent.lock();
|
||||
for p in recent {
|
||||
if !r.contains(&p) {
|
||||
r.push(p);
|
||||
}
|
||||
}
|
||||
r.truncate(MAX_RECENT);
|
||||
}
|
||||
|
||||
fn full_scan(&self) {
|
||||
let started = Instant::now();
|
||||
self.indexing.store(true, Ordering::SeqCst);
|
||||
self.emit_state();
|
||||
let task = TaskId::next();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some("Scanning files…".into()),
|
||||
fraction: None,
|
||||
done: false,
|
||||
});
|
||||
|
||||
let found = self.filter.walk(self.filter.root(), &self.cancelled);
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
||||
let (todo, stale): (Vec<&Found>, Vec<String>) = {
|
||||
let d = self.data.read();
|
||||
let todo = found
|
||||
.iter()
|
||||
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
|
||||
.collect();
|
||||
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).filter_map(|p| self.filter.rel(p)).collect();
|
||||
(todo, stale)
|
||||
};
|
||||
if !stale.is_empty() {
|
||||
let mut d = self.data.write();
|
||||
for rel in &stale {
|
||||
d.remove(&self.filter.root().join(rel));
|
||||
}
|
||||
}
|
||||
self.persist(&[], &stale, false);
|
||||
|
||||
let total = todo.len();
|
||||
let mut done = 0usize;
|
||||
let mut last_progress = Instant::now();
|
||||
for chunk in todo.chunks(CHUNK) {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.index_files(chunk.iter().copied());
|
||||
done += chunk.len();
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some(format!("{done} / {total} files")),
|
||||
fraction: Some(done as f32 / total.max(1) as f32),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.persist(&[], &[], true);
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: None,
|
||||
fraction: Some(1.0),
|
||||
done: true,
|
||||
});
|
||||
self.emit_state();
|
||||
let d = self.data.read();
|
||||
tracing::info!(
|
||||
"indexed {} files ({} parsed, {} symbols) in {:?}",
|
||||
d.file_count,
|
||||
total,
|
||||
d.symbol_count,
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts records in parallel, then publishes them to memory and disk.
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>) {
|
||||
let files: Vec<&Found> = files.collect();
|
||||
let extracted: Vec<(String, FileRecord, PathBuf)> = files
|
||||
.par_iter()
|
||||
.filter_map(|f| {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = self.filter.rel(&f.path)?;
|
||||
let prev_hash = self.data.read().get(&f.path).map(|e| e.hash);
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let d = self.data.read();
|
||||
let mut rec = d.get(&f.path)?.record();
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
Some((rel, rec, f.path.clone()))
|
||||
}
|
||||
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if extracted.is_empty() {
|
||||
return;
|
||||
}
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().map(|(rel, rec, _)| (rel.clone(), rec.clone())).collect();
|
||||
{
|
||||
let entries: Vec<FileEntry> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
}
|
||||
}
|
||||
self.persist(&puts, &[], false);
|
||||
}
|
||||
|
||||
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.apply(puts, deletes, durable)
|
||||
{
|
||||
tracing::warn!("index cache write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
|
||||
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ..Default::default() };
|
||||
if f.size > MAX_SYMBOL_FILE {
|
||||
return Extracted::New(rec);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(f.size as usize);
|
||||
match std::fs::File::open(&f.path).and_then(|mut file| file.read_to_end(&mut bytes)) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!("index: cannot read {}: {e}", f.path.display());
|
||||
return Extracted::New(rec);
|
||||
}
|
||||
}
|
||||
rec.hash = *blake3::hash(&bytes).as_bytes();
|
||||
if prev_hash == Some(rec.hash) {
|
||||
return Extracted::Unchanged;
|
||||
}
|
||||
self.parsed.fetch_add(1, Ordering::Relaxed);
|
||||
if bytes[..bytes.len().min(8192)].contains(&0) {
|
||||
return Extracted::New(rec);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let lang = self.languages.detect(&f.path, Some(first_line(&text)));
|
||||
if self.syntax.has_symbols(&lang) {
|
||||
rec.symbols = self
|
||||
.syntax
|
||||
.tags(&lang, &text)
|
||||
.into_iter()
|
||||
.map(|t| StoredSymbol { name: t.name, kind: t.kind, container: t.container, range: t.range })
|
||||
.collect();
|
||||
}
|
||||
Extracted::New(rec)
|
||||
}
|
||||
|
||||
fn process(&self, jobs: Vec<Job>) {
|
||||
let mut changed: Vec<PathBuf> = Vec::new();
|
||||
let mut removed: Vec<PathBuf> = Vec::new();
|
||||
let mut save_recent = false;
|
||||
for job in jobs {
|
||||
match job {
|
||||
Job::Changed(p) => changed.extend(p),
|
||||
Job::Removed(p) => removed.extend(p),
|
||||
Job::SaveRecent => save_recent = true,
|
||||
}
|
||||
}
|
||||
let mut touched = false;
|
||||
|
||||
// Removals first; a path removed then re-created shows up in `changed` too.
|
||||
let mut deletes = Vec::new();
|
||||
for path in removed {
|
||||
if path.exists() {
|
||||
changed.push(path);
|
||||
continue;
|
||||
}
|
||||
let mut d = self.data.write();
|
||||
if let Some(e) = d.remove(&path) {
|
||||
deletes.push(e.rel.into_string());
|
||||
} else {
|
||||
deletes.extend(d.remove_tree(&path));
|
||||
}
|
||||
}
|
||||
if !deletes.is_empty() {
|
||||
touched = true;
|
||||
self.persist(&[], &deletes, false);
|
||||
}
|
||||
|
||||
let mut files: Vec<Found> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for path in changed {
|
||||
if !seen.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
|
||||
if self.filter.is_excluded(&path, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
files.extend(self.filter.walk(&path, &self.cancelled));
|
||||
} else if meta.is_file() {
|
||||
files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path });
|
||||
}
|
||||
}
|
||||
let files: Vec<Found> = {
|
||||
let d = self.data.read();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
|
||||
.collect()
|
||||
};
|
||||
if !files.is_empty() {
|
||||
touched = true;
|
||||
for chunk in files.chunks(CHUNK) {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.index_files(chunk.iter());
|
||||
}
|
||||
}
|
||||
if save_recent && let Some(store) = &self.store {
|
||||
let rels: Vec<String> = self.recent.lock().iter().filter_map(|p| self.filter.rel(p)).collect();
|
||||
if let Err(e) = store.save_recent(&rels) {
|
||||
tracing::warn!("index cache: saving recent files failed: {e}");
|
||||
}
|
||||
}
|
||||
if touched {
|
||||
self.persist(&[], &[], true);
|
||||
self.emit_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first line, capped at 256 bytes (for shebang / `<?xml` detection).
|
||||
fn first_line(text: &str) -> &str {
|
||||
let line = text.lines().next().unwrap_or_default();
|
||||
let mut end = line.len().min(256);
|
||||
while !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&line[..end]
|
||||
}
|
||||
|
||||
enum Extracted {
|
||||
/// Content hash matches the indexed version.
|
||||
Unchanged,
|
||||
New(FileRecord),
|
||||
}
|
||||
Loaded 100 of 2034 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user