Compare commits

..
Author SHA1 Message Date
l0ng-aiandClaude 0ba50a9d67 fix(host): Allow the Unix-only post-start test helpers on Windows
Co-Authored-By: Claude <[email protected]>
2026-10-29 10:25:29 +08:00
l0ng-aiandClaude c1dece1164 feat(settings): HTTP Proxy settings for the IDE's outgoing connections
Add Appearance & Behavior › System Settings › HTTP Proxy with three modes:
No proxy, Auto-detect proxy settings (the default; environment proxy as
before) and Manual proxy configuration (HTTP or SOCKS, host and port, a
comma-separated "No proxy for" list with `*` wildcards, optional proxy
authentication). "Check connection…" GETs a URL with the settings as edited
and reports the status or the error.

- ide-core: `ProxySettings` plus routing (no-proxy globs, loopback always
  direct, credentials percent-encoded), the current proxy for the process,
  curl arguments and child-process variables.
- ide-http: `client::build` / `client::shared` is the one HTTP client
  constructor; the HTTP Client requests and the update check go through it.
- ide-tools: curl downloads (tools, JDKs, debug adapters, agent registry,
  remote server) follow the setting.
- A manual proxy reaches child processes (language servers, debug adapters,
  terminals, git) as HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.
- The proxy password is kept in the owner-only saved passwords file
  (`proxy-password.json` in the app data directory), not in settings.toml.

Co-Authored-By: Claude <[email protected]>
2026-10-29 10:19:11 +08:00
1501 changed files with 8986 additions and 137485 deletions

No files matched your search

-69
View File
@@ -1,69 +0,0 @@
name: Bug report
description: Something in OpenIDEA doesn't work as expected
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Keep logs and error messages exactly as they appear.
One problem per issue, please. If you have several, open one issue for each.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet.
required: true
- label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update.
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: |
Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development.
render: text
placeholder: |
About OpenIDEA
Version: 0.1.33
Build: ...
Platform: macOS aarch64
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot.
placeholder: |
1. Open a Gradle project whose settings.gradle includes modules in a loop
2. Open the Gradle tool window
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead. Screenshots or a recording are welcome.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting.
render: text
- type: textarea
id: extra
attributes:
label: Additional context
description: Anything else, such as whether it worked in an earlier version.
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
@@ -1,38 +0,0 @@
name: Feature request
description: Suggest a new feature or an improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
One request per issue, please.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet.
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How you'd like it to work. Mockups or examples from other tools are welcome.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives and workarounds
description: Other approaches you considered, or what you do now instead.
- type: textarea
id: extra
attributes:
label: Additional context
-12
View File
@@ -3,8 +3,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
workflow_dispatch:
concurrency:
@@ -71,15 +69,6 @@ jobs:
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
deny:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
frontend:
name: Frontend
runs-on: ubuntu-22.04
@@ -93,7 +82,6 @@ jobs:
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm knip
- run: pnpm test
- run: pnpm build
+1 -71
View File
@@ -70,12 +70,6 @@ jobs:
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
@@ -123,8 +117,6 @@ jobs:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
@@ -180,70 +172,8 @@ jobs:
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
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: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, updater-manifest]
needs: [create-release, build, server]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
-2
View File
@@ -9,8 +9,6 @@
"dist",
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
-1
View File
@@ -13,7 +13,6 @@
"src-tauri",
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"fixtures",
"plugins",
"test-results",
Generated
+146 -455
View File
File diff suppressed because it is too large. Load diff
+1 -9
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.34"
version = "0.1.23"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -36,11 +36,9 @@ ide-web = { path = "crates/ide-web" }
ide-spell = { path = "crates/ide-spell" }
ide-agent = { path = "crates/ide-agent" }
ide-clipboard = { path = "crates/ide-clipboard" }
ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -76,7 +74,6 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -87,9 +84,6 @@ redb = "4"
tree-sitter = "0.25"
# protocols & integrations
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
jupyter-protocol = "=2.0.3"
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
lsp-types = "0.97"
gix = { version = "0.89", default-features = false }
portable-pty = "0.9"
@@ -97,8 +91,6 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
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"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# testing
+10 -1
View File
@@ -14,7 +14,7 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
<a href="#build">Build</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -45,6 +45,15 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+3 -77
View File
@@ -1,14 +1,5 @@
# Third-party licenses
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
@@ -21,13 +12,12 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
`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`,
`icons/expui/gradle`, `icons/gradleSubproject` → `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
@@ -36,37 +26,10 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
`platform/icons/src/expui/actions/` at intellij-community commit
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
Their original copyright headers and Apache-2.0 licensing are retained.
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
Copyright (c) 2016 JetBrains. MIT licence, full text below.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`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, Ruby, Dart, Lua, Zig,
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.
@@ -77,31 +40,6 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
https://github.com/rsms/inter-tight).
### MIT License (Rust and Cargo icons)
The MIT License (MIT)
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
Copyright (c) 2016 JetBrains
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.
### MIT License (makefile icon)
Copyright (c) 2016 Victor Kropp
@@ -160,18 +98,6 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## CodeMirror Angular template language
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
`parser.ts` generated from them) is derived from
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
token for `{` and `}`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
-2
View File
@@ -6,8 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
-1
View File
@@ -147,7 +147,6 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Use the TodoWrite tool"),
provider: "claude-acp".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
+1 -44
View File
@@ -4,49 +4,6 @@
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use serde_json::{Value, json};
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval";
pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOption>) {
if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) {
return;
}
config.push(AgentConfigOption {
id: CURSOR_APPROVAL.into(),
name: "Approvals".into(),
description: Some("Tool approvals for this Cursor conversation".into()),
category: Some("approval".into()),
current: Some("ask".into()),
via: AgentConfigVia::Option,
values: vec![
AgentConfigValue {
value: "ask".into(),
name: "Ask for approval".into(),
description: Some("Show permission requests from Cursor".into()),
},
AgentConfigValue {
value: "bypass".into(),
name: "Bypass approvals".into(),
description: Some("Automatically approve tool requests in this conversation".into()),
},
],
});
}
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
Some((option, value))
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
@@ -107,7 +64,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
let legacy = std::mem::take(config);
config.extend(fresh);
config.extend(legacy);
+13 -158
View File
@@ -23,7 +23,6 @@ pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use ide_vcs::Repo;
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -47,8 +46,6 @@ const STDERR_LINES: usize = 20;
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -115,17 +112,7 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
o.current = Some(v);
}
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
o.current = Some("bypass".into());
}
config
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
}
/// The commands the agent offered last time in this project.
@@ -277,7 +264,6 @@ impl AgentManager {
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
f.task.status = AgentStatus::Ready;
}
f.task.retry = None;
let task = Task::restore(f, shared.clone());
task.state.lock().conversation.settle(now_ms());
tasks.insert(task.id.clone(), task);
@@ -314,28 +300,13 @@ impl AgentManager {
source: p.source.clone(),
})
.collect();
let mut prev = self.shared.providers.write();
let mut probes = self.shared.probes.lock();
probes.retain(|id, _| {
let old = prev.iter().find(|p| &p.id == id);
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs;
*self.shared.providers.write() = specs;
list
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
/// in the project: what the agent offered last time there, plus, for Claude Code, the
/// skills and custom commands on disk it has not listed.
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
let (provider, offered, cwd) = match task {
Some(id) => {
@@ -345,14 +316,6 @@ impl AgentManager {
}
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
};
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
}
}
if provider != "claude" && provider != "claude-acp" {
return Ok(offered);
}
@@ -381,16 +344,9 @@ impl AgentManager {
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?;
let title = title_of(&prompt);
let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone());
let project_root = Some(IpcPath(root.clone()));
let root = self.shared.paths.project_root.clone();
let (cwd, branch, base, base_commit, before) = match req.isolation {
Isolation::Worktree => {
// The UI offers worktrees for git projects only; state it when it isn't one.
if Repo::discover(&root)?.is_none() {
return Err(IdeError::invalid(
"worktrees need a git repository: run the task in the local checkout",
));
}
let dir = self.shared.paths.worktrees_dir.clone();
let (t, b) = (title.clone(), req.base.clone());
let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?;
@@ -414,7 +370,6 @@ impl AgentManager {
}
let now = now_ms();
let task = AgentTask {
project_root,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
@@ -434,7 +389,6 @@ impl AgentManager {
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
};
let file = TaskFile {
task: task.clone(),
@@ -558,7 +512,7 @@ impl AgentManager {
o.clone()
};
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
if let Some(live) = live {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
let reply: Value =
@@ -575,12 +529,7 @@ impl AgentManager {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
drop(s);
task.shared.remember(&provider, Some(&config), None);
}
task.approve_pending();
task.persist();
task.emit_task();
Ok(())
@@ -628,7 +577,6 @@ impl AgentManager {
usage: None,
queue: Vec::new(),
resets_ms: None,
retry: None,
..s.task.clone()
};
TaskFile {
@@ -788,43 +736,19 @@ impl AgentManager {
Ok(())
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.status != AgentStatus::Archived {
return Ok(());
}
if s.busy {
return Err(IdeError::invalid("the task is still stopping"));
}
s.task.status = AgentStatus::Ready;
s.task.error = None;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
task.kill();
let (isolation, cwd, root) = {
let (isolation, cwd) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
)
(s.task.isolation, s.task.cwd.0.clone())
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
let root = self.shared.paths.project_root.clone();
blocking(move || worktree::remove(&root, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
@@ -860,8 +784,7 @@ impl AgentManager {
impl Task {
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
let mut task = file.task;
config::add_client_options(&task.provider, &mut task.config);
let task = file.task;
let conversation = Conversation::restore(file.entries, file.subagents);
Arc::new(Self {
id: task.id.clone(),
@@ -907,22 +830,9 @@ impl Task {
self.shared.emit(AgentEvent::Task { task });
}
/// Shows (or clears) the agent's pending retry; emitted only when it changes.
fn set_retry(&self, retry: Option<AgentRetry>) {
{
let mut s = self.state.lock();
if s.task.retry == retry {
return;
}
s.task.retry = retry;
}
self.emit_task();
}
fn set_status(&self, status: AgentStatus, error: Option<String>) {
{
let mut s = self.state.lock();
s.task.retry = None;
s.task.status = status;
s.task.error = error;
s.task.updated_ms = now_ms();
@@ -1081,7 +991,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text,
attachments,
images: prompt.images,
sent_ms: Some(now_ms()),
done_ms: None,
steered: true,
@@ -1102,7 +1011,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text.clone(),
attachments,
images: prompt.images.clone(),
sent_ms: Some(now_ms()),
done_ms: None,
steered: false,
@@ -1248,16 +1156,10 @@ impl Task {
let s = self.state.lock();
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
self.shared.remember(&provider_id, Some(&options), None);
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
if o.id == config::CURSOR_APPROVAL {
if o.values.iter().any(|v| v.value == choice.value) {
options.iter_mut().find(|o| o.id == choice.id).unwrap().current = Some(choice.value.clone());
}
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
if already || !o.values.iter().any(|v| v.value == choice.value) {
continue;
@@ -1277,7 +1179,6 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1302,7 +1203,7 @@ impl Task {
s.wanted
.iter()
.filter_map(|c| {
let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?;
let o = s.task.config.iter().find(|o| o.id == c.id)?;
let due = o.current.as_deref() != Some(c.value.as_str())
&& o.values.iter().any(|v| v.value == c.value)
&& sent.insert((c.id.clone(), c.value.clone()));
@@ -1363,12 +1264,6 @@ impl Task {
}
return;
}
if kind == RETRY_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
self.set_retry(retry_of(update, now_ms()));
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
@@ -1437,8 +1332,6 @@ impl Task {
}
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
// Output again: the retried request went through.
self.set_retry(None);
let applied = self.state.lock().conversation.apply(session, update, now_ms());
self.mark_dirty(applied);
// A subagent can start or finish after the turn that started it: saved right away.
@@ -1481,7 +1374,6 @@ impl Task {
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
let answer = rx.recv().ok().flatten();
let name = answer
@@ -1505,22 +1397,6 @@ impl Task {
}
}
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
return;
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
let _ = reply.send(Some(option.id.clone()));
}
}
}
fn cancel_permissions(&self) {
for (_, reply) in self.permissions.lock().drain() {
let _ = reply.send(None);
@@ -1680,31 +1556,10 @@ fn is_zero(n: &u32) -> bool {
*n == 0
}
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
let n = |k: &str| update.get(k).and_then(Value::as_u64);
Some(AgentRetry {
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
})
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
/// Commands the agent implements on its own and never lists in `availableCommands` —
/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`.
fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] {
match provider {
// OpenCode ACP handles `/compact` via session.summarize but omits it from its command list.
"opencode" => &[("compact", "compact the conversation")],
_ => &[],
}
}
fn file_name(path: &Path) -> String {
path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned())
}
-26
View File
@@ -47,9 +47,6 @@ pub enum Isolation {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentTask {
/// Original project folder; absent in tasks saved before per-task roots.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub id: String,
pub title: String,
/// [`AgentProvider::id`].
@@ -84,21 +81,6 @@ pub struct AgentTask {
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
#[serde(default)]
pub resets_ms: Option<f64>,
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
}
/// An API request the agent is about to retry.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentRetry {
pub attempt: u32,
pub max_retries: Option<u32>,
/// When the next attempt starts (ms since the epoch).
pub retry_at_ms: f64,
pub error: Option<String>,
}
/// One value of a select-type session setting.
@@ -243,9 +225,6 @@ pub enum AgentEntry {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// The images sent with it, shown in the conversation.
#[serde(default)]
images: Vec<AgentImage>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
@@ -367,9 +346,6 @@ pub struct AgentProbe {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NewAgentTask {
/// Validated by the host; defaults to the window project root.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub prompt: AgentPrompt,
pub provider: String,
/// Settings for the new session (model, mode…).
@@ -413,6 +389,4 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// The installed or configured agent providers changed.
Providers,
}
+3 -8
View File
@@ -296,7 +296,7 @@ impl Shim {
let mut cmd = std::process::Command::new(&self.config.program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
let text = String::from_utf8_lossy(&out.stdout);
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
}
@@ -915,11 +915,7 @@ impl Session {
} else {
"end_turn"
};
finished = st.turn.take().map(|mut t| {
// Claude Code reports a turn that failed as cancelled here.
let failure = t.failure.take().filter(|_| !t.cancelled);
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
});
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
}
}
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
@@ -1143,8 +1139,7 @@ where
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
// Prompts are sent with the origin `human`, and their results echo it.
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
if result.get("origin").is_some_and(|o| !o.is_null()) {
return false;
}
let mut uuids = strings(result.get("user_message_uuids"));
@@ -738,50 +738,6 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_turn_reports_its_failure() {
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
let lifecycle = |uuid: &str, state: &str| {
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
};
let result = |uuid: &str| {
Frame::Cli(json!({
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
}))
};
// As Claude Code sends it, and with the turn's end ahead of its result.
for lifecycle_first in [false, true] {
let mut process = load_fixture("simple").remove(0);
process.truncate(6);
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
process.push(Frame::Cli(json!({
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
})));
if lifecycle_first {
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
} else {
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
}
let cli = Arc::new(Replay {
processes: Mutex::new(VecDeque::from([process])),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
let s = h.new_session().await;
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
};
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
}
}
/// Fork and undo against the real CLI, across connections, in a symlinked directory
/// (macOS `/tmp`).
#[tokio::test(flavor = "multi_thread")]
@@ -873,7 +829,6 @@ fn which_result_ends_a_turn() {
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
}
@@ -962,20 +917,6 @@ fn pending_starts_round_trip() {
}
}
#[test]
fn api_retries_are_announced() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(
out[0].update,
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
"error": "Connection error."})
);
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -5,7 +5,6 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::RETRY_UPDATE;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -169,7 +168,7 @@ impl Translator {
Some("tool_use") => {
// The input streams after this: shown by name until the message has it.
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
}
}
@@ -428,23 +427,6 @@ impl Translator {
}
Vec::new()
}
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
Some("api_retry") => {
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
let error = msg.get("error").and_then(|e| {
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
});
vec![Update {
session: self.root.clone(),
update: json!({
"sessionUpdate": RETRY_UPDATE,
"attempt": n("attempt").unwrap_or(1),
"maxRetries": n("max_retries"),
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
"error": error,
}),
}]
}
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
let stop = match msg.get("status").and_then(Value::as_str) {
Some("failed") => "error",
+2 -45
View File
@@ -199,12 +199,7 @@ fn invalid(message: impl Into<String>) -> ResponseError {
fn from_codex(e: RpcError) -> ResponseError {
match e {
RpcError::Server { code, message, data } => {
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
let protocol = matches!(
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -431,48 +426,10 @@ impl CodexShim {
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
Ok(_) => {}
Err(e)
if e.code == codes::METHOD_NOT_FOUND
|| (e.code == codes::INVALID_REQUEST
&& e.message.contains("unknown variant `thread/rollback`")) =>
{
self.revert(session, turns).await?;
}
Err(e) => return Err(e),
}
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
Ok(json!({}))
}
/// New Codex versions replace rollback counts with an exclusive turn id.
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page = self
.call(
"thread/turns/list",
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
)
.await?;
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
if remaining <= turns.len() as u64 {
let before = str_param(&turns[remaining as usize - 1], "id")?;
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
return Ok(());
}
remaining -= turns.len() as u64;
let next = page.get("nextCursor").and_then(Value::as_str);
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
return Err(invalid(
"Codex history has fewer turns than the conversation; reload the task before editing",
));
};
cursor = json!(next);
}
}
/// Stops following the session's thread (codex unloads it once nothing follows it).
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
@@ -593,104 +593,6 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
}
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
let frames = replies
.into_iter()
.enumerate()
.flat_map(|(i, (method, mut reply))| {
let id = i + 1;
reply["id"] = json!(id);
[(true, json!({ "id": id, "method": method })), (false, reply)]
})
.collect();
let transcript = Transcript { meta: Value::Null, frames };
let (ours, theirs) = tokio::io::duplex(1 << 16);
let acp = attach(Arc::new(Client::default()), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
let (sent, _read, _write) = player.await.unwrap();
acp.kill();
(result, sent)
}
fn removed_rollback() -> Value {
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
}
#[tokio::test(flavor = "multi_thread")]
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
),
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
let pages = sent(&frames, "thread/turns/list");
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
assert_eq!(pages[1]["params"]["cursor"], "older");
assert_eq!(pages[1]["params"]["limit"], 1);
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
}
#[tokio::test(flavor = "multi_thread")]
async fn method_not_found_reverts_the_latest_turn() {
let (result, frames) = rollback_replies(
1,
vec![
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
("thread/revert", json!({ "result": {} })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
}
#[tokio::test(flavor = "multi_thread")]
async fn rollback_refusals_are_not_retried_as_revert() {
let (result, frames) = rollback_replies(
1,
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
)
.await;
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
assert_eq!(frames.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
for next in [Value::Null, json!("older")] {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
],
)
.await;
assert!(result.unwrap_err().to_string().contains("reload the task"));
assert!(sent(&frames, "thread/revert").is_empty());
}
}
#[tokio::test(flavor = "multi_thread")]
async fn steering_adds_to_the_running_turn() {
let (results, _client, frames, _) = replay(Scenario::Steer).await;
+20 -72
View File
@@ -21,27 +21,18 @@ use std::sync::Arc;
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
/// on `PATH` is often an old one that the service no longer serves the current models to.
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
/// A path, or a name found once (or not at all), is used as it is.
pub(crate) fn newest_install(command: &str) -> String {
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
.unwrap_or_default();
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
newest_install_in(command, &ide_core::process::with_user_path(&path))
}
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
if Path::new(command).components().count() > 1 {
return command.to_owned();
}
let pathext = ide_core::process::host_var_os("PATHEXT");
let names =
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()));
let mut seen = Vec::new();
let candidates: Vec<PathBuf> = std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
let candidates: Vec<PathBuf> = path
.iter()
.flat_map(std::env::split_paths)
.map(|dir| dir.join(command))
.filter(|p| p.is_file())
.filter(|p| {
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
@@ -51,7 +42,7 @@ fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
})
.collect();
if candidates.len() < 2 {
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
return command.to_owned();
}
candidates
.into_iter()
@@ -65,7 +56,7 @@ fn version_of(program: &Path) -> Option<Vec<u64>> {
let mut cmd = std::process::Command::new(program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
parse_version(&String::from_utf8_lossy(&out.stdout))
}
fn parse_version(text: &str) -> Option<Vec<u64>> {
@@ -75,6 +66,16 @@ fn parse_version(text: &str) -> Option<Vec<u64>> {
})
}
#[cfg(test)]
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
/// The agent side of an in-process ACP connection.
pub(crate) trait Shim: Handler {
/// Called once, before any message arrives: the connection to the client, for
@@ -93,56 +94,3 @@ pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
shim.attach(to_client);
Peer::connect_framed(or, ow, client, Framing::Lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
#[test]
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
} else {
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
}
assert_eq!(newest_install_in("nope", &search), "nope");
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
}
#[cfg(unix)]
#[test]
fn newest_install_compares_versions_across_path_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old");
let new = dir.path().join("new");
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
std::fs::create_dir_all(path).unwrap();
let executable = path.join("agent");
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for paths in [[&old, &new], [&new, &old]] {
let search = std::env::join_paths(paths).unwrap();
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
}
}
}
+4 -14
View File
@@ -80,16 +80,9 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
/// Whether `id` names a built-in agent.
pub(crate) fn is_builtin(id: &str) -> bool {
BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx")))
}
/// Display name of a built-in agent, when `id` is one.
pub(crate) fn builtin_name(id: &str) -> Option<&'static str> {
BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name)
BUILTIN.iter().any(|b| b.0 == id)
}
/// An agent file, `<config>/agents/<id>.toml`.
@@ -169,7 +162,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
Some(r) => AgentSource::Registry { version: r.version, update: None },
None => AgentSource::Custom,
};
let mut spec = ProviderSpec {
let spec = ProviderSpec {
name: agent.name.unwrap_or_else(|| id.clone()),
driver: agent.driver,
command: agent.command,
@@ -180,10 +173,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
id,
};
match specs.iter_mut().find(|s| s.id == spec.id) {
Some(existing) => {
spec.login_hint = existing.login_hint.clone();
*existing = spec;
}
Some(existing) => *existing = spec,
None => specs.push(spec),
}
}
+4 -99
View File
@@ -165,13 +165,6 @@ fn parse(text: &str) -> IdeResult<Vec<Entry>> {
.into_iter()
.filter_map(|v| serde_json::from_value::<Entry>(v).ok())
.filter(|e| safe_segment(&e.id) && safe_segment(&e.version))
.map(|mut e| {
e.id = canonical_id(&e.id).to_owned();
if let Some(name) = provider::builtin_name(&e.id) {
e.name = name.to_owned();
}
e
})
.collect())
}
@@ -229,26 +222,14 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
{
return Some(Choice::Uvx(p));
}
// Last resort: npx and uvx run natively, this one under OS emulation.
if platform.os == Os::Windows
&& platform.arch == Arch::Arm64
&& let Some(b) = d.binary.get("windows-x86_64")
{
return Some(Choice::Binary(b));
}
None
}
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
const BUILTIN_ALIASES: &[(&str, &str)] =
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
fn canonical_id(id: &str) -> &str {
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
}
/// Registry agents that are built-in ones under another id.
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
fn is_builtin(id: &str) -> bool {
provider::is_builtin(canonical_id(id))
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
}
fn on_path(command: &str) -> bool {
@@ -354,7 +335,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) {
/// HTTPS only, redirects included. Tests serve downloads over `file://`.
fn http() -> IdeResult<Http> {
let http = Http::new(&ide_core::tool_path::search_path())?;
let http = Http::new(&ide_tools::default_search_path())?;
Ok(if cfg!(test) { http } else { http.https_only() })
}
@@ -399,7 +380,6 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
/// Installs (or updates to) the registry's current version of `id`, streaming progress
/// through `emit`, and points the agent file at it.
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let id = canonical_id(id);
// The saved index is what the user picked from; fetch only when there is none.
let text = match fs::read_to_string(&paths.cache_file) {
Ok(text) => text,
@@ -631,7 +611,6 @@ fn install_uv(
/// Deletes a registry agent's file and installed files.
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
let id = canonical_id(id);
if !safe_segment(id) {
return Err(IdeError::invalid(format!("invalid agent id {id}")));
}
@@ -710,20 +689,6 @@ mod tests {
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
#[test]
fn windows_arm_binary_prefers_native_then_x64() {
let mut entry = parse(INDEX).unwrap().remove(0);
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
let mut native = entry.distribution.binary["windows-x86_64"].clone();
native.cmd = "native.exe".into();
entry.distribution.binary.insert("windows-aarch64".into(), native);
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
}
const INDEX: &str = r#"{"version":"1.0.0","agents":[
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
@@ -960,64 +925,4 @@ mod tests {
assert!(remove(&p, "amp-acp").is_err());
assert!(p.agent_file("amp-acp").exists());
}
#[test]
fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let index = r#"{"agents":[
{"id":"github-copilot-cli","name":"Copilot","version":"1.0.105",
"description":"GitHub's AI pair programmer",
"distribution":{"npx":{"package":"@github/[email protected]","args":["--acp"]}}}
]}"#;
let entries = parse(index).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot"));
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()),
("copilot", "GitHub Copilot", None)
);
CustomAgent {
name: Some("GitHub Copilot".into()),
driver: Driver::Acp,
command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(),
args: vec!["--acp".into()],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }),
}
.write(&p.agent_file("copilot"))
.unwrap();
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(spec.name, "GitHub Copilot");
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
let mut providers = vec![AgentProvider {
id: spec.id,
name: spec.name,
command: spec.command,
installed: true,
config: vec![],
commands: vec![],
source: spec.source,
}];
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) }
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}
+1 -17
View File
@@ -185,23 +185,7 @@ pub(crate) fn recorder(out: PathBuf) -> Connector {
let file = Arc::new(Mutex::new(String::new()));
let connections = Arc::new(Mutex::new(0u32));
Arc::new(move |spec, cwd, client, on_stderr| {
let program = {
let path = Path::new(&spec.command);
if path.components().count() > 1 || path.is_absolute() {
path.to_path_buf()
} else {
let search = spec
.env
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(_, v)| std::ffi::OsString::from(v))
.or_else(|| ide_core::process::shell_env().find(|(k, _)| k == "PATH").map(|(_, v)| v))
.or_else(|| ide_core::process::host_var_os("PATH"))
.unwrap_or_default();
ide_core::process::find_executable(&spec.command, &search).unwrap_or_else(|| path.to_path_buf())
}
};
let mut cmd = tokio::process::Command::new(&program);
let mut cmd = tokio::process::Command::new(&spec.command);
ide_core::process::prepare(cmd.as_std_mut());
let mut child = cmd
.args(&spec.args)
-1
View File
@@ -56,7 +56,6 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String {
let req = NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: provider.into(),
config: config(provider),
+1 -309
View File
@@ -210,11 +210,10 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: only the skills on disk.
// Before any session: the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
@@ -228,8 +227,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.cancel(&task.id, None).unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::Ready).await;
mgr_a.shutdown();
// Another project shares the settings but not the commands.
@@ -242,46 +239,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent);
for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] {
assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}");
}
let commands = mgr.commands("opencode", None).await.unwrap();
assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["compact"]);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "opencode".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let advertised = AgentCommand {
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
let commands = mgr.commands("claude", Some(&task.id)).await.unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, advertised.name);
assert_eq!(commands[0].description, advertised.description);
assert_eq!(commands[0].hint, advertised.hint);
// Finish the in-process agent before Tokio stops driving its permission response.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
let task = mgr.task(id).unwrap();
@@ -311,7 +268,6 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer\nmore detail"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
@@ -418,7 +374,6 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
@@ -473,7 +428,6 @@ async fn subagents_get_their_own_conversation() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("delegate"),
provider: "claude".into(),
config: vec![],
@@ -567,7 +521,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("do it"),
provider: "codex".into(),
config: vec![],
@@ -589,7 +542,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
assert!(
mgr.create(NewAgentTask {
project_root: None,
prompt: text(" "),
provider: "codex".into(),
config: vec![],
@@ -635,7 +587,6 @@ async fn real_claude_task_in_a_worktree() {
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
@@ -684,7 +635,6 @@ async fn forks_rollbacks_and_steering() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = |prompt: &str| NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "claude".into(),
config: Vec::new(),
@@ -735,7 +685,6 @@ async fn usage_limits_and_late_settings() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("limit"),
provider: "claude".into(),
// `effort` isn't offered until the session runs.
@@ -760,260 +709,3 @@ async fn usage_limits_and_late_settings() {
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
assert_eq!(
retry_of(&update, 1000.0),
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
);
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = || NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
// Agent config updates must not erase the client's approval setting.
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let t = mgr.task(&task.id).unwrap();
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
t.kill();
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A task created without choosing approvals still asks; the New Task page sends the default.
let other = mgr.create(new()).await.unwrap();
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
mgr.cancel(&other.id, None).unwrap();
wait_for(&mgr, &other.id, AgentStatus::Ready).await;
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
mgr.archive(&task.id).unwrap();
assert!(mgr.prompt(&task.id, text("again"), false).await.is_err());
mgr.unarchive(&task.id).unwrap();
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(mgr.task(&task.id).unwrap().live.lock().is_none());
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len());
drop(mgr);
let mgr = manager(tmp.path(), agent.clone());
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|call| call == "session/load"));
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2);
mgr.unarchive(&task.id).unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn task_project_root_controls_local_and_worktree_repositories() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let member = root.join("project-b");
std::fs::create_dir(&member).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&member)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
git(&["init", "-q", "-b", "member-main"]);
std::fs::write(member.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
let mgr = manager(&root, Arc::new(FakeAgent::default()));
for isolation in [Isolation::Local, Isolation::Worktree] {
let task = mgr
.create(NewAgentTask {
project_root: Some(IpcPath(member.clone())),
prompt: text("member task"),
provider: "claude".into(),
config: vec![],
isolation,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let permission = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
assert_eq!(task.cwd.0, member);
} else {
assert_ne!(task.cwd.0, member);
assert_eq!(std::fs::read_to_string(task.cwd.0.join("file.txt")).unwrap(), "original\n");
}
std::fs::write(task.cwd.0.join("file.txt"), "changed\n").unwrap();
let changes = mgr.changes(&task.id).await.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path.0, task.cwd.0.join("file.txt"));
assert_eq!(mgr.base_text(&task.id, changes[0].path.0.clone()).await.unwrap().as_deref(), Some("original\n"));
let saved = std::fs::read(root.join("store").join(format!("{}.json", task.id))).unwrap();
assert!(String::from_utf8(saved).unwrap().contains("projectRoot"));
let mut old = serde_json::to_value(&task).unwrap();
old.as_object_mut().unwrap().remove("projectRoot");
assert!(serde_json::from_value::<AgentTask>(old).unwrap().project_root.is_none());
mgr.delete(&task.id, true).await.unwrap();
if isolation == Isolation::Worktree {
assert!(!task.cwd.0.exists());
}
std::fs::write(member.join("file.txt"), "original\n").unwrap();
}
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
let tmp = tempfile::tempdir().unwrap();
let agents_dir = tmp.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
std::fs::write(&fake_bin, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let agent = Arc::new(FakeAgent::default());
let connector: Connector = {
let agent = agent.clone();
Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
agent.servers.lock().push(server);
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
})
};
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
// Seed a stale failed probe for copilot before installing its custom TOML.
mgr.shared.probes.lock().insert(
"copilot".into(),
AgentProbe {
provider: "copilot".into(),
name: None,
version: None,
protocol: "ACP".into(),
capabilities: AgentCapabilities::default(),
auth_methods: Vec::new(),
error: Some("copilot --acp was not found on PATH.".into()),
},
);
let events = Arc::new(Mutex::new(Vec::new()));
let events_sink = events.clone();
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
let toml = format!(
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
fake_bin.to_string_lossy()
);
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
mgr.reload_providers();
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
assert!(copilot.installed);
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
let probe = mgr.probe("copilot", false).await.unwrap();
assert!(probe.error.is_none());
}
-1
View File
@@ -446,7 +446,6 @@ mod tests {
let user = |sent: f64| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered: false,
+5 -72
View File
@@ -48,30 +48,13 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas
Ok(Created { path, branch, base, base_commit })
}
/// `git worktree remove --force -- <path>`. When git no longer recognizes the
/// worktree (a pruned registration, a moved repository, a deleted `.git` file)
/// it fails with "is not a working tree". A folder under `worktrees_dir` is still
/// ours, so it is removed directly to keep the task deletable (a stale
/// registration prunes later); anything else keeps git's error.
pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> {
let repo = repo_at(project_root)?;
let err = match repo.remove_worktree(path, true) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if !path.exists() {
return Ok(());
}
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
return Err(err);
}
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
repo_at(project_root)?.remove_worktree(path, true)
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
/// Outside a repository there is nothing to compare against: no changes.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let repo = repo_at(cwd)?;
let root = repo.root().to_path_buf();
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
@@ -113,12 +96,7 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
};
repo.file_at(path, Some(rev), encoding)
repo_at(cwd)?.file_at(path, Some(rev))
}
/// A short ASCII branch-safe name from the task title.
@@ -158,20 +136,6 @@ mod tests {
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
}
#[test]
fn outside_a_repository_there_is_nothing_to_compare() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
assert!(changes(&dir, "HEAD").unwrap().is_empty());
assert!(snapshot(&dir).unwrap().is_empty());
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
assert!(remove(&dir, tmp.path(), &dir).is_err());
}
fn git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
@@ -218,38 +182,7 @@ mod tests {
// A second task with the same title gets its own worktree.
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
remove(&repo, &created.path).unwrap();
assert!(!created.path.exists());
}
#[test]
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
// A pruned registration (the repository moved, `git worktree prune` ran).
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap());
std::fs::remove_dir_all(registration).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A deleted `.git` file in the worktree itself.
let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap();
std::fs::remove_file(created.path.join(".git")).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A folder outside the worktrees directory is never deleted by hand.
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap();
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
}
+3 -1
View File
@@ -8,12 +8,14 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.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
-218
View File
@@ -1,218 +0,0 @@
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
use crate::BuildModel;
use ide_core::{IdeError, IdeResult};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct ResourceBuild {
pub command: String,
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
pub kind: &'static str,
}
fn literal(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
fs::create_dir_all(cache)?;
let manifest = cache.join("resource-output.txt");
if manifest.exists() {
fs::remove_file(&manifest)?;
}
let module = dunce::canonicalize(root.join(module))?;
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
let mut args = p.options.clone();
args.extend([
"process-resources".into(),
"help:evaluate".into(),
"-Dexpression=project.build.outputDirectory".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(ResourceBuild {
command: p.command.clone(),
args,
cwd: p.dir.clone(),
env: p.env.clone(),
kind: "maven",
});
}
if let Some(g) = &model.gradle
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
fs::write(
&script,
format!(
r#"gradle.projectsEvaluated {{
def p = gradle.rootProject.project({})
def resources = p.tasks.named('processResources')
p.tasks.register('openideaUpdateResources') {{
dependsOn resources
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
}}
}}
"#,
literal(&p.path),
literal(&manifest.to_string_lossy())
),
)?;
return Ok(ResourceBuild {
command: g.command.clone(),
args: vec![
"--init-script".into(),
script.to_string_lossy().into_owned(),
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
],
cwd: g.dir.clone(),
env: g.env.clone(),
kind: "gradle",
});
}
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
}
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
if !output.is_absolute() || !output.is_dir() {
return Err(IdeError::invalid("The resource build did not report an output directory"));
}
let output = dunce::canonicalize(output)?;
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
if paths.contains(&output) {
return Ok(());
}
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
IdeError::invalid("Cannot find the running main class output directory; restart the application")
})?;
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
let mut current = Vec::new();
copy_resources(&output, &output, destination, &mut current)?;
for rel in previous.iter().filter(|p| !current.contains(p)) {
let path = destination.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(
cache.join("published-resources.json"),
serde_json::to_vec(&current).map_err(|e| IdeError::internal(e.to_string()))?,
)?;
Ok(())
}
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
copy_resources(root, &path, to, copied)?;
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
let dest = to.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, dest)?;
copied.push(rel.to_string_lossy().into_owned());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_plans_use_the_selected_module_and_build_configuration() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
fs::create_dir(root.join("app")).unwrap();
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: "app".into(),
command: "/tools/mvnw".into(),
options: vec!["-Pdev".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
let cache = root.join("cache");
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(maven.command, "/tools/mvnw");
assert_eq!(maven.cwd, "app");
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
model.gradle = Some(crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
});
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
}
#[test]
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cache = root.join("cache");
let output = root.join(layout);
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
fs::create_dir_all(output.join("static")).unwrap();
fs::create_dir_all(runtime.join("example")).unwrap();
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
fs::write(output.join("static/index.html"), "after").unwrap();
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
fs::write(runtime.join("example/Application.class"), "running").unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
if output != runtime {
assert!(!runtime.join("unrelated.class").exists());
fs::remove_file(output.join("static/index.html")).unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert!(!runtime.join("static/index.html").exists());
}
}
}
#[test]
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let output = root.join("build/resources/main");
let cache = root.join("cache");
fs::create_dir_all(&output).unwrap();
fs::create_dir(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
}
}
-321
View File
@@ -1,321 +0,0 @@
//! Metadata fingerprints for successful Maven launch builds.
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let meta = match fs::metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
files.insert(path.to_path_buf(), None);
return Ok(());
}
Err(e) => return Err(e.into()),
};
if meta.is_dir() {
let canonical = dunce::canonicalize(path)?;
if !ancestors.insert(canonical.clone()) {
return Err(IdeError::internal("cyclic launch input directory"));
}
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
Ok(())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
key.starts_with("MAVEN_")
|| key.starts_with("JAVA_")
|| key.starts_with("JDK_")
|| matches!(
key.as_str(),
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
)
})
.collect();
// Explicit step values override the inherited environment just as they do in Command.
for step in &plan.steps {
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
for dir in step.cwd.ancestors() {
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
if dir == root {
break;
}
}
let program = process::program_path(&step.program, &step.env);
inputs.push(program.clone());
// Maven installed via a symlink still reads conf from its distribution.
if let Ok(program) = dunce::canonicalize(program)
&& let Some(home) = program.parent().and_then(Path::parent)
{
for conf in ["conf", "libexec/conf"] {
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
}
}
if !environment.contains_key("JAVA_HOME") {
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
inputs.push(java.clone());
if let Ok(java) = dunce::canonicalize(java)
&& let Some(home) = java.parent().and_then(Path::parent)
{
inputs.push(home.join("release"));
}
}
for (i, arg) in step.args.iter().enumerate() {
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
&& let Some(file) = step.args.get(i + 1)
{
inputs.push(step.cwd.join(file));
}
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
if let Some(file) = arg.strip_prefix(prefix) {
inputs.push(step.cwd.join(file));
}
}
}
}
if let Some(home) = dirs::home_dir() {
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
}
for key in ["MAVEN_HOME", "M2_HOME"] {
if let Some(home) = environment.get(key) {
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
}
}
if let Some(home) = environment.get("JAVA_HOME") {
inputs.extend([
Path::new(home).join("release"),
Path::new(home).join("lib/modules"),
Path::new(home).join("lib/tools.jar"),
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "lib/src/main/kotlin/Channel.kt", "value");
write(root, "app/src/main/resources/app.properties", "key=value");
write(root, "lib/conf/custom.properties", "custom=value");
write(root, "lib/code/Custom.java", "class Custom {}");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
}
#[test]
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
for rel in [
"lib/src/main/kotlin/Channel.kt",
"app/src/main/resources/app.properties",
"lib/conf/custom.properties",
"lib/code/Custom.java",
"app/pom.xml",
"lib/pom.xml",
"pom.xml",
".mvn/maven.config",
"lib/src/main/groovy/New.groovy",
"app/src/test/scala/Test.scala",
] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let before = fingerprint(root, &model);
assert!(before.is_some());
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
let serialized = serde_json::to_vec(&before).unwrap();
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
let value = if rel.ends_with("pom.xml") {
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
} else {
"changed input".to_owned()
};
write(root, rel, &value);
let changed = fingerprint(root, &model);
assert_ne!(before, changed, "changed/added {rel}");
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
let touched = fingerprint(root, &model);
assert_ne!(changed, touched, "mtime-only {rel}");
fs::remove_file(root.join(rel)).unwrap();
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
}
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut model = fixture(root);
let before = fingerprint(root, &model);
write(root, "lib/target/classes/Channel.class", "JDT stub");
write(root, "app/target/classes/Main.class", "JDT stub");
assert_eq!(before, fingerprint(root, &model));
let mut previous = before.clone();
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
let current = fingerprint(root, &model);
assert_ne!(previous, current, "{option}");
previous = current;
}
model.maven.as_mut().unwrap().projects[0].options.clear();
model.maven.as_mut().unwrap().projects[0]
.env
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
let jdk = fingerprint(root, &model);
assert_ne!(before, jdk);
write(root, "jdk/release", "JAVA_VERSION=21");
assert_ne!(jdk, fingerprint(root, &model));
model.maven.as_mut().unwrap().projects[0].env.clear();
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
.unwrap()
.unwrap();
assert_ne!(before, Some(test));
write(root, "mvnw", "wrapper changed");
assert_ne!(before, fingerprint(root, &model));
}
#[test]
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(
root,
"lib/pom.xml",
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
);
assert!(
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
.unwrap()
.is_none()
);
let model = fixture(root);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
}
#[test]
fn external_parent_and_selected_settings_files_invalidate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let repo = dir.path().join("repository");
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
);
write(
&root,
"pom.xml",
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
);
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
let settings = dir.path().join("custom-settings.xml");
fs::write(&settings, "<settings/>").unwrap();
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
let get =
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
let before = get();
assert!(before.is_some());
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
);
let parent = get();
assert_ne!(before, parent);
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
.unwrap();
assert_ne!(parent, get());
}
}
+11 -280
View File
@@ -3,11 +3,8 @@
//! background with cancellation and parses the compiler output into messages
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod parse;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
@@ -158,11 +155,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
}
fn rel_str(root: &Path, dir: &Path) -> String {
@@ -188,10 +182,8 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.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 {
@@ -242,10 +234,12 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
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);
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for 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, &[])]);
@@ -614,90 +608,6 @@ pub fn has_non_java_sources(module: &Path) -> bool {
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// 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(
@@ -707,7 +617,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -722,14 +632,6 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -740,11 +642,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| subdir(module, &format!("build/{d}/{s}")))
.map(|d| module.join("build").join(d).join(s))
})
.collect(),
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
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()
@@ -754,30 +656,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -843,17 +721,6 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
@@ -951,142 +818,6 @@ mod tests {
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
-6
View File
@@ -399,12 +399,6 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
out
}
/// Static fallback until Gradle has reported its tasks.
pub(crate) fn has_war_plugin(script: &str) -> bool {
let script = strip_comments(script);
script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war")
}
#[cfg(test)]
mod tests {
#[test]
-475
View File
@@ -1,475 +0,0 @@
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
use crate::BuildModel;
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Module {
key: String,
project_uri: String,
output: PathBuf,
test_output: Option<PathBuf>,
dependencies: Vec<Dependency>,
has_kotlin: bool,
kotlin_goals: Vec<KotlinGoal>,
}
#[derive(Clone, Debug, Deserialize)]
struct KotlinGoal {
execution: String,
status: String,
}
#[derive(Clone, Debug, Deserialize)]
struct Dependency {
key: String,
scope: String,
#[serde(rename = "type")]
kind: Option<String>,
classifier: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Library {
pub path: PathBuf,
pub test: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectLibraries {
pub project_uri: String,
pub libraries: Vec<Library>,
}
pub struct Outcome {
pub projects: Vec<ProjectLibraries>,
pub messages: Vec<BuildMessage>,
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
if kind.is_file() {
entry.path().extension().is_some_and(|e| e == "kt")
} else {
kind.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
}
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
root: &Path,
model: &BuildModel,
bundle: &Path,
generation: &Path,
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
let directory = generation.join(format!("reactor-{index}"));
std::fs::create_dir_all(&directory)?;
manifests.push(directory.join("modules.json"));
let mut extensions = vec![bundle.to_path_buf()];
step.args.retain(|arg| {
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
extensions.extend(std::env::split_paths(value));
false
} else {
true
}
});
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
}
let mut messages = Vec::new();
// Independent linked reactors must still run after another reactor's javac fails.
for step in &plan.steps {
let mut reactor = plan.clone();
reactor.steps = vec![step.clone()];
let result = compile::run(root, &reactor, cancel, &|event| {
if let BuildEvent::Output { text, .. } = event {
tracing::debug!("kotlin bridge: {}", text.trim_end());
}
})?;
messages.extend(result.messages);
}
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
messages.retain(|message| {
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
});
let mut modules = Vec::new();
for manifest in manifests {
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
modules.extend(parsed);
}
messages.extend(kotlin_failures(&modules));
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
return Ok(Outcome { projects: Vec::new(), messages, success: false });
}
let pending = generation.join("libraries.pending");
let published = generation.join("libraries");
std::fs::create_dir_all(&pending)?;
let mut libraries = BTreeMap::new();
for (index, module) in modules.iter().enumerate() {
let mut outputs = ModuleLibraries::default();
for (source, target, name) in [
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
] {
let Some(source) = source else { continue };
if !source.starts_with(generation) {
return Err(IdeError::internal("Kotlin build output escaped staging"));
}
if copy_kotlin(source, &pending.join(&name), cancel)? {
*target = Some(published.join(name));
}
}
libraries.insert(module.key.clone(), outputs);
}
publish(&pending, &published, cancel)?;
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
}
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
let mut messages = Vec::new();
for module in modules {
if !module.has_kotlin {
continue;
}
if module.kotlin_goals.is_empty() {
messages.push(kotlin_failure(module, "compile target was not executed"));
}
for goal in &module.kotlin_goals {
if goal.status != "success" {
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
}
}
}
messages
}
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
BuildMessage {
severity: ide_core::Severity::Error,
text: format!("Kotlin compilation for {}: {reason}", module.key),
path: None,
range: None,
code: None,
}
}
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
std::fs::rename(pending, published)?;
Ok(())
}
#[derive(Default)]
struct ModuleLibraries {
main: Option<PathBuf>,
test: Option<PathBuf>,
}
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
modules
.iter()
.map(|consumer| {
let mut visible = Vec::<Library>::new();
let mut add = |path: &Option<PathBuf>, test: bool| {
if let Some(path) = path {
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
previous.test &= test;
} else {
visible.push(Library { path: path.clone(), test });
}
}
};
if let Some(own) = libraries.get(&consumer.key) {
add(&own.test, true);
add(&own.main, false);
}
// Maven's resolved artifacts already apply exclusions, mediation and optional
// propagation. Preserve their classpath order, after the module's own output.
for dependency in &consumer.dependencies {
let Some(output) = libraries.get(&dependency.key) else { continue };
let test = match dependency.scope.as_str() {
"compile" | "provided" | "system" | "" => false,
"test" | "runtime" => true,
_ => continue,
};
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
(None | Some("jar"), None | Some("")) => add(&output.main, test),
_ => {}
}
}
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
})
.collect()
}
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
let mut found = false;
for entry in entries {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let entry = entry?;
let kind = entry.file_type()?;
let path = entry.path();
let output = destination.join(entry.file_name());
if kind.is_dir() {
found |= copy_kotlin(&path, &output, cancel)?;
} else if kind.is_file() {
let bytes = std::fs::read(&path)?;
let class = path.extension().is_some_and(|e| e == "class");
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
std::fs::create_dir_all(destination)?;
std::fs::write(output, bytes)?;
found |= class;
}
}
}
Ok(found)
}
/// Read the actual class-level SourceFile attribute, rather than searching strings
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
self.pos += count;
Some(value)
}
fn u2(&mut self) -> Option<usize> {
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
}
fn u4(&mut self) -> Option<usize> {
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
}
fn attributes(&mut self) -> Option<()> {
for _ in 0..self.u2()? {
self.u2()?;
let n = self.u4()?;
self.take(n)?;
}
Some(())
}
}
let mut reader = Reader { bytes, pos: 0 };
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
return None;
}
reader.take(4)?;
let count = reader.u2()?;
let mut utf8 = BTreeMap::new();
let mut index = 1;
while index < count {
match reader.take(1)?[0] {
1 => {
let n = reader.u2()?;
utf8.insert(index, reader.take(n)?);
}
3 | 4 | 9..=12 | 17 | 18 => {
reader.take(4)?;
}
5 | 6 => {
reader.take(8)?;
index += 1;
}
7 | 8 | 16 | 19 | 20 => {
reader.take(2)?;
}
15 => {
reader.take(3)?;
}
_ => return None,
}
index += 1;
}
reader.take(6)?;
let interfaces = reader.u2()?;
reader.take(interfaces.checked_mul(2)?)?;
for _ in 0..2 {
for _ in 0..reader.u2()? {
reader.take(6)?;
reader.attributes()?;
}
}
for _ in 0..reader.u2()? {
let name = reader.u2()?;
let length = reader.u4()?;
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
let source = reader.u2()?;
return Some(utf8.get(&source)?.ends_with(b".kt"));
}
reader.take(length)?;
}
Some(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
let mut modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
]"#).unwrap();
assert!(kotlin_failures(&modules).is_empty());
for status in ["failed", "skipped"] {
modules[0].kotlin_goals[1].status = status.into();
let failures = kotlin_failures(&modules);
assert_eq!(failures.len(), 1);
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
}
modules[0].kotlin_goals.clear();
assert_eq!(kotlin_failures(&modules).len(), 1);
modules[0].has_kotlin = false;
assert!(kotlin_failures(&modules).is_empty());
assert!(
serde_json::from_str::<Vec<Module>>(
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
)
.is_err()
);
}
#[test]
fn respects_mavens_resolved_closure_and_scopes() {
let json = r#"[
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
{"key":"lib","scope":"compile","optional":false},
{"key":"optional","scope":"compile","optional":true},
{"key":"test","scope":"test","optional":false},
{"key":"provided","scope":"provided","optional":false}]},
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
]"#;
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
let libraries = ["zapp", "lib", "optional", "test", "provided"]
.into_iter()
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
.collect();
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
);
assert_eq!(result[1].libraries.len(), 4);
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
}
#[test]
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
let modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
]"#).unwrap();
let libraries =
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
);
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
}
#[test]
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
let directory = tempfile::tempdir().unwrap();
let pending = directory.path().join("pending");
let final_path = directory.path().join("published");
std::fs::create_dir(&pending).unwrap();
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(publish(&pending, &final_path, &cancelled).is_err());
assert!(!final_path.exists());
assert!(pending.exists());
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
assert!(!pending.exists());
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
}
#[test]
fn never_runs_another_build_system_with_maven_output_properties() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
let error = build(
directory.path(),
&BuildModel::default(),
Path::new("unused.jar"),
&directory.path().join("private"),
&CancellationToken::new(),
)
.err()
.unwrap();
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
}
#[test]
fn parses_sourcefile_and_rejects_truncated_classes() {
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
class.extend_from_slice(b"SourceFile");
class.extend_from_slice(&[1, 0, 10]);
class.extend_from_slice(b"Channel.kt");
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
assert_eq!(kotlin_class(&class), Some(true));
for end in 0..class.len() {
assert!(kotlin_class(&class[..end]).is_none());
}
let offset = 26;
class[offset..offset + 10].copy_from_slice(b"Other.java");
assert_eq!(kotlin_class(&class), Some(false));
}
}
-14
View File
@@ -5,15 +5,12 @@
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
//! Maven / Gradle in the background) cached for the project.
pub mod application_update;
pub mod compile;
pub mod gradle;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
@@ -42,19 +39,8 @@ pub struct BuildModel {
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
pub fn load_linked(
root: &Path,
cache_dir: &Path,
maven_repo: Option<PathBuf>,
settings: &ide_core::settings::MavenSettings,
) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
+17 -462
View File
@@ -11,9 +11,7 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -287,14 +285,6 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
}
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
/// which Maven 4 (model 4.1.0) uses instead.
fn module_names(e: &Element) -> Vec<String> {
let mut names = texts(e, "modules", "module");
names.extend(texts(e, "subprojects", "subproject"));
names
}
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
@@ -318,7 +308,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: module_names(project),
modules: texts(project, "modules", "module"),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -339,7 +329,7 @@ fn read_raw(project: &Element) -> RawPom {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
modules: module_names(p),
modules: texts(p, "modules", "module"),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -411,9 +401,6 @@ struct Inherited {
managed: Vec<RawDep>,
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
/// Maven lets a child enable a parent's profiles with `-P`.
profiles: Vec<(String, bool)>,
}
fn merge_by<T: Clone>(base: &mut Vec<T>, own: &[T], key: impl Fn(&T) -> String) {
@@ -458,20 +445,13 @@ struct Resolver {
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
return None;
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
{
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
}
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
Some(m2.join("repository"))
}
impl Resolver {
@@ -543,11 +523,6 @@ impl Resolver {
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &p.plugins);
}
merge_by(
&mut ctx.profiles,
&pom.profiles.iter().map(|p| (p.id.clone(), p.active_by_default)).collect::<Vec<_>>(),
|(id, _)| id.clone(),
);
// Managed dependencies: own entries win; imported BOMs fill the gaps.
let props = self.properties_for(&pom, &ctx, &dir);
let mut imported: Vec<RawDep> = Vec::new();
@@ -618,72 +593,6 @@ impl Resolver {
}
}
/// Files and source/resource trees consumed by a launch build. Unresolved custom
/// roots disable reuse rather than silently omitting an input.
pub(crate) fn launch_inputs(
root: &Path,
projects: &[&MavenProject],
local_repo: Option<PathBuf>,
) -> Option<Vec<PathBuf>> {
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
let input =
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|| (parent == "filters" && el.name == "filter");
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
roots(child, &el.name, dir, out)?;
}
Some(())
}
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
let mut out = Vec::new();
for project in projects {
let dir = root.join(&project.dir);
// Include all conventional language/resource roots, including nonstandard files under src.
out.push(dir.join("src"));
let pom = root.join(&project.pom);
resolver.context(&pom, 0);
let mut path = pom;
let mut seen = HashSet::new();
loop {
let canonical = dunce::canonicalize(&path).ok()?;
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
path = resolver.parent_path(path.parent()?, &parent)?;
}
}
// Also track imported BOMs read while resolving inheritance.
out.extend(resolver.poms.into_keys());
out.sort();
out.dedup();
Some(out)
}
// ---------------------------------------------------------------- plugins
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
@@ -697,76 +606,7 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// Goals of every plugin jar read so far, by the path of that jar.
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
/// is not in the local repository yet.
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
// between runs, and a test's empty repository must not answer for another's.
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
// every platform, Windows' separator included.
let mut dir = repo.to_path_buf();
dir.extend(group_id.split('.'));
dir.push(artifact_id);
dir.push(version);
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
let key = jar.to_string_lossy().into_owned();
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return cached.clone();
}
// A failed read is not remembered: the jar may be half written by Maven right now.
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
goals
}
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
if exact.is_file() {
return Some(exact);
}
let prefix = format!("{artifact_id}-");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
continue;
}
if name.ends_with("-javadoc.jar") {
continue;
}
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
}
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
let file = std::fs::File::open(jar).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
let root = xml::parse(&text)?;
let mut goals: Vec<String> =
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
goals.retain(|g| !g.is_empty());
goals.sort();
goals.dedup();
Some(goals)
}
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
/// Goals of well-known plugins (their plugin descriptors are not read).
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -918,27 +758,9 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
/// still shows every goal it offers instead of only the bound one.
pub(crate) fn plugin(
group_id: &str,
artifact_id: &str,
version: Option<String>,
goals: &[String],
local_repo: Option<&Path>,
) -> MavenPlugin {
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
for g in descriptor_goals(repo, group_id, artifact_id, version) {
if !all.contains(&g) {
all.push(g);
}
}
}
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -1103,7 +925,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
})
.collect();
@@ -1113,10 +935,9 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| pom.compiler_release.as_deref().map(i))
.and_then(|v| java_version(&v));
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
for p in &pom.profiles {
if !model.profiles.iter().any(|x| x.id == p.id) {
model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default });
}
}
let artifact_id = i(&pom.artifact_id);
@@ -1133,7 +954,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
aggregated,
plugins,
dependencies,
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
options: Vec::new(),
@@ -1146,67 +967,6 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
model
}
/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory.
#[derive(Default)]
pub struct UnlinkedProjects {
poms: std::collections::BTreeSet<String>,
}
impl UnlinkedProjects {
pub fn load(root: &Path, settings: &ide_core::settings::MavenSettings) -> Self {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load(root), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
let mut result = Self::default();
let mut pending = settings.unlinked_projects.clone();
while let Some(pom) = pending.pop() {
let pom = normalized_pom(&pom);
if !result.poms.insert(pom.clone()) {
continue;
}
if let Some(project) = model.projects.iter().find(|p| normalized_pom(&p.pom) == pom) {
pending.extend(project.modules.iter().cloned());
}
}
result
}
pub fn contains(&self, pom: &str) -> bool {
self.poms.contains(&normalized_pom(pom))
}
pub fn poms(&self) -> impl Iterator<Item = &str> {
self.poms.iter().map(String::as_str)
}
pub fn filter(&self, model: &mut MavenModel) {
model.projects.retain(|p| !self.contains(&p.pom));
for project in &mut model.projects {
project.modules.retain(|p| !self.contains(p));
}
model.profiles.retain(|p| model.projects.iter().any(|project| project.profiles.contains(&p.id)));
}
}
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let mut parts = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
}
/// Every POM of the model (for change detection).
pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
model.projects.iter().map(|p| root.join(&p.pom)).collect()
@@ -1215,16 +975,6 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
parse_effective_pom_in(text, None)
}
/// The same, with the local repository the project actually builds against (a
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
/// rather than from the user's default one.
pub fn parse_effective_pom_in(
text: &str,
local_repo: Option<&Path>,
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1236,7 +986,7 @@ pub fn parse_effective_pom_in(
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.collect();
let deps = raw
.deps
@@ -1367,107 +1117,6 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
mod tests {
use super::*;
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
std::fs::create_dir_all(&dir).unwrap();
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
}
#[test]
fn a_plugin_lists_the_goals_its_jar_declares() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
write_plugin_jar(
repo,
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
"3.3.1",
&["checkstyle", "checkstyle-aggregate", "help", "check"],
);
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(repo),
);
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join("settings.xml");
std::fs::write(
&settings,
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
)
.unwrap();
let repo = local_repository_of(&settings).unwrap();
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
assert!(!repo.to_string_lossy().contains("${user.home}"));
// A settings file that names none leaves Maven's default alone.
let plain = tmp.path().join("plain.xml");
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
assert!(local_repository_of(&plain).is_none());
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
}
#[test]
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
let tmp = tempfile::tempdir().unwrap();
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
</plugin></plugins></build></project>";
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
// Without that repository the plugin is unknown: only what the project binds.
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
}
#[test]
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
std::fs::create_dir_all(&dir).unwrap();
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
// A sibling sources jar must not win.
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
assert_eq!(p.goals, ["run"]);
}
#[test]
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
let tmp = tempfile::tempdir().unwrap();
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(tmp.path()),
);
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
let known =
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -1491,26 +1140,6 @@ mod tests {
</profiles>
</project>"#;
#[test]
fn maven_4_subprojects_are_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<subprojects><subproject>core</subproject></subprojects>
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
</project>"#,
);
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
let model = load_with_repo(root, None);
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
@@ -1590,41 +1219,9 @@ mod tests {
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("dev", true), ("extra", false)]);
assert_eq!(parent.profiles, ["dev", "extra"]);
assert_eq!(core.profiles, ["dev", "extra"], "a module inherits its parent's profiles");
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
#[test]
fn profiles_of_a_parent_pom_are_inherited() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version>
<packaging>pom</packaging><modules><module>child</module></modules>
<profiles>
<profile><id>jdk8</id></profile>
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
</profiles>
</project>"#,
);
write(
root,
"child/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
<artifactId>child</artifactId>
<profiles><profile><id>own</id></profile></profiles>
</project>"#,
);
let model = load_with_repo(root, None);
let child = &model.projects[1];
// `-Pjdk8` on the module activates the parent's profile, so it is listed.
assert_eq!(child.profiles, ["jdk8", "jdk21", "own"]);
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("jdk8", false), ("jdk21", true), ("own", false)]);
}
#[test]
fn parent_and_bom_from_the_local_repository_and_wrapper() {
let tmp = tempfile::tempdir().unwrap();
@@ -1862,45 +1459,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod linkage_tests {
use super::*;
use ide_core::settings::MavenSettings;
#[test]
fn excludes_reactor_modules_and_profiles_but_keeps_other_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, body) in [
(
"a",
"<modules><module>child</module><module>../shared</module></modules><profiles><profile><id>only-a</id></profile></profiles>",
),
("a/child", ""),
("shared", ""),
("b", ""),
] {
std::fs::create_dir_all(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId>{body}</project>"),
)
.unwrap();
}
let original = load_with_repo(root, None);
let settings = MavenSettings { unlinked_projects: vec!["./a/pom.xml".into()], ..Default::default() };
let unlinked = UnlinkedProjects::from_model(&original, &settings);
assert!(unlinked.contains("a/child/pom.xml"));
assert!(unlinked.contains("shared/pom.xml"));
assert!(unlinked.contains("a/../shared/pom.xml"));
assert!(!unlinked.contains("b/pom.xml"));
let mut model = original.clone();
unlinked.filter(&mut model);
assert_eq!(model.projects.iter().map(|p| p.pom.as_str()).collect::<Vec<_>>(), ["b/pom.xml"]);
assert!(model.profiles.is_empty());
let mut restored = original.clone();
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
assert_eq!(restored, original);
}
}
+19 -42
View File
@@ -2,12 +2,11 @@
//! 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::process::kill_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
@@ -23,20 +22,16 @@ pub(crate) struct Output {
pub text: String,
}
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
/// search path — the login shell's `PATH` plus version-manager shims — since the
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
let path = std::path::Path::new(program);
if !path.is_relative() || path.components().count() > 1 {
return path.to_path_buf();
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 search = env
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(_, v)| std::ffi::OsString::from(v))
.unwrap_or_else(ide_core::tool_path::search_path);
ide_core::process::find_executable(program, &search).unwrap_or_else(|| path.to_path_buf())
let _ = child.kill();
let _ = child.wait();
}
/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills
@@ -47,35 +42,18 @@ pub(crate) fn run(
cwd: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
run_with_env(program, args, cwd, env, false, cancel, on_line)
}
pub(crate) fn run_with_env(
program: &str,
args: &[String],
cwd: &Path,
env: &BTreeMap<String, String>,
clear_env: bool,
cancel: &CancellationToken,
mut on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
// Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`).
let encoding =
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
let program = program_path(program, env);
let mut command = Command::new(&program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
ide_core::process::prepare(ide_core::process::own_group(&mut command));
if clear_env {
command.env_clear().envs(env).envs(encoding.env);
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);
}
let mut child =
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
ide_core::process::prepare(&mut command);
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
let (tx, rx) = mpsc::channel::<(Stream, String)>();
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
std::thread::spawn(move || {
@@ -86,7 +64,7 @@ pub(crate) fn run_with_env(
match reader.read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {
let line = ide_core::output_encoding::decode_tool_output(&buf);
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;
@@ -108,7 +86,6 @@ pub(crate) fn run_with_env(
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {}
Ok((stream, line)) => {
on_line(stream, &line);
text.push_str(&line);
+10 -125
View File
@@ -23,7 +23,6 @@ pub struct SyncProgress {
#[derive(Clone, Debug, Default)]
pub struct SyncOptions {
pub maven_settings: ide_core::settings::MavenSettings,
/// `-o` / `--offline`.
pub offline: bool,
/// Maven profiles (`-P`), `!id` deactivates.
@@ -32,8 +31,6 @@ pub struct SyncOptions {
pub env: BTreeMap<String, String>,
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
pub maven_command: Option<String>,
/// Resolved Gradle launcher from Settings.
pub gradle_command: Option<String>,
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
pub maven_options: Vec<String>,
}
@@ -48,11 +45,6 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
/// Bumped whenever what the cache holds stops matching what the model would be
/// built as now (plugin goals read from a descriptor, say): an older cache is
/// dropped instead of shadowing the model.
#[serde(default)]
version: u32,
hash: String,
at_ms: f64,
error: Option<String>,
@@ -71,7 +63,6 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -127,19 +118,6 @@ fn now_ms() -> 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 };
if cache.version != MAVEN_CACHE_VERSION {
return; // built by an older IDE: what it holds no longer matches the model
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
model.stale = !cache.verbose || changed;
// A cache older than the current build files: its effective-POM plugins and
// resolved dependencies would hide what the POMs declare now (a just added
// dependency never appears). The static parse stays until Reload.
if changed {
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() {
@@ -148,6 +126,9 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
p.dependencies = c.dependencies.clone();
}
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
}
/// Merges the cached task list into `model`.
@@ -217,34 +198,10 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
let model = maven::load(root);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(root, &model)),
verbose: true,
..MavenCache::default()
};
// Where the build resolves its jars, and therefore where the plugin goals are read
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
// then the user's default repository (which `plugin()` resolves itself).
let local_repo: Option<PathBuf> =
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|| {
let mut args = options.maven_options.iter();
while let Some(arg) = args.next() {
let path = match arg.as_str() {
"-s" | "--settings" => args.next().map(String::as_str),
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
};
if let Some(path) = path {
return maven::local_repository_of(Path::new(path));
}
}
None
},
);
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -273,9 +230,8 @@ pub fn sync_maven(
});
}
})?;
let effective_projects = std::fs::read_to_string(&effective)
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
.unwrap_or_default();
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);
@@ -310,14 +266,7 @@ pub fn sync_gradle(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_| {},
)?;
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(),
@@ -331,29 +280,6 @@ pub fn sync_gradle(
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn gradle_sync_uses_resolved_command_and_environment() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "").unwrap();
let launcher = root.join("configured-gradle");
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$JAVA_HOME\" \"$GRADLE_USER_HOME\" > actual-env\n")
.unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options = SyncOptions {
gradle_command: Some(launcher.to_string_lossy().into_owned()),
env: BTreeMap::from([
("JAVA_HOME".into(), "/jdk21".into()),
("GRADLE_USER_HOME".into(), "/gradle-cache".into()),
]),
..Default::default()
};
sync_gradle(root, &root.join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(std::fs::read_to_string(root.join("actual-env")).unwrap(), "/jdk21\n/gradle-cache\n");
}
#[test]
fn caches_merge_and_go_stale() {
let tmp = tempfile::tempdir().unwrap();
@@ -365,7 +291,6 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -377,7 +302,6 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -391,51 +315,12 @@ mod tests {
std::fs::write(
root.join("pom.xml"),
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
</project>",
"<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);
// The POM changed: the stale effective dependencies must not hide the new one (#249).
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
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);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
-268
View File
@@ -1,268 +0,0 @@
//! WAR modules and build-tool-owned archive resolution (including custom output names).
use crate::BuildModel;
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebArtifact {
pub id: String,
pub name: String,
pub module: String,
pub tool: String,
}
pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
let mut out = Vec::new();
if let Some(maven) = &model.maven {
for p in maven.projects.iter().filter(|p| p.packaging == "war") {
out.push(WebArtifact {
id: format!("maven:{}", p.pom),
name: p.artifact_id.clone(),
module: p.dir.clone(),
tool: "maven".into(),
});
}
}
if let Some(gradle) = &model.gradle {
for p in &gradle.projects {
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
out.push(WebArtifact {
id: format!("gradle:{}", p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
});
}
}
}
out
}
pub struct WebBuild {
pub command: String,
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
}
impl WebBuild {
/// Resolve the selected module's effective identity using the build's options and environment.
/// Runs off the UI thread, before launching the reactor build.
pub fn resolve_project(
&mut self,
root: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
) -> IdeResult<()> {
let Some((args, output)) = &self.maven_project else {
return Ok(());
};
if output.exists() {
fs::remove_file(output)?;
}
let cwd = root.join(&self.cwd).canonicalize()?;
let mut env = env.clone();
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
}
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
if !result.success {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
}
let id = fs::read_to_string(output)?;
let parts: Vec<_> = id.trim().split(':').collect();
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
}
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
self.maven_project = None;
Ok(())
}
}
fn groovy_literal(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
fs::create_dir_all(cache)?;
let manifest = cache.join("artifact-path.txt");
// A failed build must never silently reuse an older archive manifest.
if manifest.exists() {
fs::remove_file(&manifest)?;
}
fs::write(cache.join("artifact-id.txt"), id)?;
if let Some(pom) = id.strip_prefix("maven:") {
let p = model
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| p.pom == pom && p.packaging == "war"))
.ok_or_else(|| IdeError::invalid("Choose a Maven WAR module"))?;
let projects = &model.maven.as_ref().unwrap().projects;
let top = projects
.iter()
.filter(|t| {
!t.aggregated && (t.dir.is_empty() || p.dir == t.dir || p.dir.starts_with(&format!("{}/", t.dir)))
})
.max_by_key(|t| t.dir.len())
.unwrap_or(p);
let mut args = top.options.clone();
if top.pom != p.pom {
let module = root
.join(&p.dir)
.strip_prefix(root.join(&top.dir))
.map_err(|e| IdeError::invalid(e.to_string()))?
.to_string_lossy()
.replace('\\', "/");
args.extend(["-pl".into(), module, "-am".into()]);
}
// help:evaluate runs once, in the reactor's first project. Query the selected
// POM's effective identity first; profiles and -D options can change its GAV.
let output = cache.join("artifact-project.txt");
let mut probe = top.options.clone();
probe.extend([
"-f".into(),
root.join(&p.pom).to_string_lossy().into_owned(),
"help:evaluate".into(),
"-Dexpression=project.id".into(),
format!("-Doutput={}", output.display()),
]);
args.extend([
"-DskipTests".into(),
"package".into(),
"help:evaluate".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(WebBuild {
command: top.command.clone(),
args,
cwd: top.dir.clone(),
env: p.env.clone(),
maven_project: Some((probe, output)),
});
}
if let Some(path) = id.strip_prefix("gradle:") {
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == id) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
fs::write(
&script,
format!(
r#"gradle.projectsEvaluated {{
def p = gradle.rootProject.project({})
def warTask = p.tasks.named('war')
p.tasks.register('openideaTomcatArtifact') {{
dependsOn warTask
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
}}
}}
"#,
groovy_literal(path),
groovy_literal(&manifest.to_string_lossy())
),
)?;
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
return Ok(WebBuild {
command: g.command.clone(),
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
cwd: g.dir.clone(),
env: g.env.clone(),
maven_project: None,
});
}
Err(IdeError::invalid("Choose a WAR artifact"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maven_war_modules_use_resolved_artifact_file() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("pom.xml"), "<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>web</artifactId><version>1</version><packaging>war</packaging><build><directory>custom</directory><finalName>renamed</finalName></build></project>").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let a = artifacts(tmp.path(), &model);
assert_eq!(a.len(), 1);
assert_eq!(a[0].name, "web");
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
let (probe, _) = plan.maven_project.as_ref().unwrap();
assert!(probe.contains(&"-Dexpression=project.id".into()));
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
assert!(
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|| a.ends_with("base with spaces\\artifact-path.txt"))
);
}
#[test]
fn maven_module_build_includes_reactor_dependencies() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("web")).unwrap();
fs::write(tmp.path().join("pom.xml"), "<project><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>web</module></modules></project>").unwrap();
fs::write(tmp.path().join("web/pom.xml"), "<project><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>web</artifactId><packaging>war</packaging></project>").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
assert_eq!(plan.cwd, "");
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
assert!(
plan.maven_project
.as_ref()
.unwrap()
.0
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
);
}
#[test]
fn gradle_war_uses_archive_provider_and_ignores_comments() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("build.gradle.kts"),
"plugins { war }\ntasks.war { archiveFileName.set(\"custom.war\") }",
)
.unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let a = artifacts(tmp.path(), &model);
assert_eq!(a.len(), 1);
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base")).unwrap();
assert!(plan.args.contains(&":openideaTomcatArtifact".into()));
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
}
#[test]
fn gradle_manifest_paths_preserve_windows_characters() {
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
let literal = groovy_literal(path);
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
}
}
#[test]
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
for module in [":", ":apps:timesheet"] {
let tmp = tempfile::tempdir().unwrap();
let dir = if module == ":" { "" } else { "apps/timesheet" };
fs::create_dir_all(tmp.path().join(dir)).unwrap();
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
.unwrap();
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let cache = tmp.path().join("工作 cache");
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
assert!(script.contains(&format!("rootProject.project('{}')", module)));
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
}
}
}
+1 -6
View File
@@ -1,13 +1,8 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
+20 -153
View File
@@ -1,23 +1,15 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//! writes so Explorer can paste the files it copied (IDEA does both).
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
//! in full rather than rendered on demand, so no window or message loop is
//! needed and the list survives after `CloseClipboard`.
//!
//! macOS keeps one `public.file-url` item per file on the general
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
//! and ours paste in Finder.
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
//! thread: AppKit and WebKit also access the general pasteboard there, and its
//! mutable type cache cannot be protected by a mutex in this crate.
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
//! [`set_files`] fails there.
use std::path::PathBuf;
@@ -25,8 +17,7 @@ use std::path::PathBuf;
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
/// Read failures remain errors so callers can retain an internal copy.
pub fn files() -> std::io::Result<Vec<PathBuf>> {
pub fn files() -> Vec<PathBuf> {
imp::files()
}
@@ -35,8 +26,9 @@ pub fn has_files() -> bool {
imp::has_files()
}
/// Replaces the clipboard with `paths`. True only when the file list was
/// accepted; writing just the accompanying text is not a successful hand-off.
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
/// to write; the clipboard is a shared resource, so callers treat this as
/// best-effort.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
@@ -116,8 +108,6 @@ mod imp {
/// `CF_HDROP`, the clipboard format holding a file list.
const CF_HDROP: u32 = 15;
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
const CF_UNICODETEXT: u32 = 13;
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
type Handle = *mut c_void;
@@ -163,29 +153,25 @@ mod imp {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
if !open() {
return Err(std::io::Error::last_os_error());
}
if !has_files() {
unsafe { CloseClipboard() };
return Ok(Vec::new());
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Err(std::io::Error::last_os_error())
Vec::new()
} else {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let data = GlobalLock(memory);
if data.is_null() {
Err(std::io::Error::last_os_error())
Vec::new()
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
Ok(paths)
paths
}
}
};
@@ -193,37 +179,12 @@ mod imp {
paths
}
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
/// byte size. Null when the allocation failed.
fn global_text(text: &str) -> (Handle, usize) {
let mut units: Vec<u16> = text.encode_utf16().collect();
units.push(0);
let bytes = units.len() * 2;
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
if memory.is_null() {
return (ptr::null_mut(), 0);
}
let data = GlobalLock(memory);
if data.is_null() {
GlobalFree(memory);
return (ptr::null_mut(), 0);
}
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
GlobalUnlock(memory);
(memory, bytes)
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
return false;
}
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// SAFETY: the block is allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
@@ -241,117 +202,23 @@ mod imp {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it.
// Replacing the list also drops the text the user copied before.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
return false;
}
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
CloseClipboard();
if !taken {
GlobalFree(memory);
}
let (text_memory, _) = global_text(&text);
if !text_memory.is_null() {
// The text alone still pastes somewhere useful.
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
if !text_taken {
GlobalFree(text_memory);
}
}
CloseClipboard();
taken
}
}
}
#[cfg(target_os = "macos")]
mod imp {
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
/// user copied is not a file to paste.
fn read_file_urls<R>(
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
) -> R {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
let classes = NSArray::from_slice(&[NSURL::class()]);
let yes = NSNumber::new_bool(true);
// SAFETY: the key is an AppKit constant, valid for the process' lifetime.
let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]);
f(&board, &classes, &options)
})
}
pub fn has_files() -> bool {
// SAFETY: the class list holds `NSURL`, which reads from pasteboards,
// and the option takes an `NSNumber`.
read_file_urls(|board, classes, options| unsafe {
board.canReadObjectForClasses_options(classes, Some(options))
})
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
read_file_urls(|board, classes, options| {
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
Err(std::io::Error::other("Cannot read clipboard files"))
} else {
Ok(Vec::new())
};
};
Ok(objects
.iter()
.filter_map(|object| object.downcast::<NSURL>().ok())
.filter_map(|url| url.to_file_path())
// Finder ends a folder's URL in `/`; the project view names it without.
.map(|path| path.components().collect())
.collect())
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// Folders get a directory URL (ending in `/`), as Finder writes them.
let urls: Vec<Retained<NSURL>> = paths
.iter()
.filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) })
.collect();
if urls.is_empty() {
return false;
}
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
}
}
#[cfg(not(any(windows, target_os = "macos")))]
#[cfg(not(windows))]
mod imp {
use std::path::PathBuf;
@@ -359,8 +226,8 @@ mod imp {
false
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
pub fn files() -> Vec<PathBuf> {
Vec::new()
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
-10
View File
@@ -7,19 +7,9 @@ license.workspace = true
[dependencies]
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
-13
View File
@@ -12,8 +12,6 @@ use std::collections::BTreeMap;
pub struct EditorGeneralSettings {
/// Change font size with ⌘ + mouse wheel.
pub wheel_zoom: bool,
pub markdown_image_folder: String,
pub markdown_image_name_pattern: String,
/// 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.
@@ -37,8 +35,6 @@ impl Default for EditorGeneralSettings {
fn default() -> Self {
Self {
wheel_zoom: false,
markdown_image_folder: "assets".into(),
markdown_image_name_pattern: "image-{timestamp}".into(),
drag_and_drop: true,
caret_scroll: "minimize".into(),
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
@@ -371,12 +367,6 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -444,8 +434,6 @@ pub struct EncodingSettings {
pub project: String,
/// Fallback for `.properties` files.
pub properties: String,
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
pub native_to_ascii: bool,
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
pub utf8_bom: String,
}
@@ -456,7 +444,6 @@ impl Default for EncodingSettings {
global: "utf-8".into(),
project: "utf-8".into(),
properties: "iso-8859-1".into(),
native_to_ascii: false,
utf8_bom: "never".into(),
}
}
+1 -52
View File
@@ -12,15 +12,6 @@ pub type IdeResult<T> = Result<T, IdeError>;
pub struct IdeError {
pub kind: ErrorKind,
pub message: String,
/// Structured detail for errors the UI acts on; `null` for plain errors.
#[specta(optional)]
pub data: Option<ErrorData>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ErrorData {
Tool { tool: String, missing: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -33,7 +24,6 @@ pub enum ErrorKind {
/// Document version mismatch; the UI must resync the full text.
OutOfSync,
Cancelled,
TimedOut,
/// A required external tool (language server, git, debugger) is unavailable.
Unavailable,
Io,
@@ -43,21 +33,7 @@ pub enum ErrorKind {
impl IdeError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into(), data: None }
}
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
if self.data.is_none() {
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
}
self
}
pub fn missing_tool(tool: impl Into<String>) -> Self {
let tool = tool.into();
Self {
kind: ErrorKind::NotFound,
message: format!("{tool} is not installed"),
data: Some(ErrorData::Tool { tool, missing: true }),
}
Self { kind, message: message.into() }
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, m)
@@ -89,30 +65,3 @@ impl From<std::io::Error> for IdeError {
Self::new(kind, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_errors_round_trip_without_changing_diagnostics() {
let message = "[error] file.css: Unexpected token\n 1 | }\n";
let error = IdeError::invalid(message).with_tool("prettier");
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
assert_eq!(decoded.message, message);
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
}
#[test]
fn legacy_errors_need_no_data() {
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
assert!(error.data.is_none());
assert_eq!(
serde_json::to_value(error).unwrap(),
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
);
}
}
+1 -40
View File
@@ -13,22 +13,12 @@ use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
},
/// Files or directories changed on disk (after debouncing, ignore rules applied).
FsChanged {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
dirs: 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 {
@@ -36,28 +26,16 @@ pub enum IdeEvent {
base_version: u32,
version: u32,
changes: Vec<TextChange>,
/// Part of a grouped transaction; the editor must not record a file-local undo.
#[serde(default)]
#[specta(optional)]
workspace_edit: bool,
},
/// A document's dirty flag changed (saved / modified externally).
DocDirty {
doc: DocId,
dirty: bool,
},
/// A complete workspace edit can be undone as one operation.
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
/// An open document's language changed (Settings › File Types, Override File Type).
DocLanguage {
doc: DocId,
language: crate::LanguageId,
/// See `DocumentInfo::ui_mode`.
ui_mode: Option<String>,
},
/// Full replacement of diagnostics for one file from one producer.
Diagnostics {
@@ -91,12 +69,6 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
@@ -140,22 +112,11 @@ pub enum IdeEvent {
id: u64,
prompt: String,
kind: AskpassKind,
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
remember: bool,
},
/// Prompt `id` was answered (in any window) or gave up.
AskpassDone {
id: u64,
},
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
/// `dead`); `message` says why it failed to start or died.
NotebookKernel {
/// Backend document incarnation; stale queued events cannot target a reopen.
id: String,
path: crate::IpcPath,
state: String,
message: Option<String>,
},
}
/// What an askpass prompt asks for.
-25
View File
@@ -1,25 +0,0 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
-4
View File
@@ -11,9 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
pub mod project_dir;
pub mod proxy_settings;
@@ -21,8 +19,6 @@ pub mod remote_settings;
pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod version_managers;
pub mod workflow_settings;
pub use error::{IdeError, IdeResult};
-538
View File
@@ -1,538 +0,0 @@
//! Makes the output of JVM and Python processes match the encoding the IDE decodes.
//!
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while
//! the console decodes the configured encoding (UTF-8 by default). They are told
//! which one to use:
//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*`
//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a
//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console
//! appenders write in on older JDKs too.
//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding`
//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
//! how a build reads sources that declare no encoding.
//! - Python: `PYTHONIOENCODING`.
use std::collections::BTreeMap;
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
/// Callers must buffer incomplete lines so multibyte characters are never split.
/// Run consoles and protocol streams have their own encoding contracts.
pub fn decode_tool_output(bytes: &[u8]) -> String {
#[cfg(windows)]
// SAFETY: GetACP is an argument-less query with no preconditions.
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
#[cfg(not(windows))]
let code_page = 65001;
decode_tool_output_with_code_page(bytes, code_page)
}
fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let encoding = encoding_for_code_page(code_page).unwrap_or(encoding_rs::UTF_8);
// Collected output can mix UTF-8 JVM lines with native launcher diagnostics.
let mut text = String::new();
for line in bytes.split_inclusive(|b| *b == b'\n') {
match std::str::from_utf8(line) {
Ok(line) => text.push_str(line),
Err(_) => text.push_str(&encoding.decode_without_bom_handling(line).0),
}
}
text
}
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
54936 => encoding_rs::GB18030,
950 => encoding_rs::BIG5,
932 => encoding_rs::SHIFT_JIS,
949 => encoding_rs::EUC_KR,
866 => encoding_rs::IBM866,
874 => encoding_rs::WINDOWS_874,
1250 => encoding_rs::WINDOWS_1250,
1251 => encoding_rs::WINDOWS_1251,
1252 => encoding_rs::WINDOWS_1252,
1253 => encoding_rs::WINDOWS_1253,
1254 => encoding_rs::WINDOWS_1254,
1255 => encoding_rs::WINDOWS_1255,
1256 => encoding_rs::WINDOWS_1256,
1257 => encoding_rs::WINDOWS_1257,
1258 => encoding_rs::WINDOWS_1258,
20866 => encoding_rs::KOI8_R,
21866 => encoding_rs::KOI8_U,
_ => return None,
})
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
/// Build tools and launcher scripts that run on (and may fork) JVMs.
const JVM_TOOLS: &[&str] =
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
/// What a launch needs: `-D` options to put first on a `java` command line,
/// environment variables to set, and the notice line its JVMs print because of them.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EncodingFix {
pub java_args: Vec<String>,
pub env: Vec<(String, String)>,
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
pub notice: Option<String>,
}
pub fn is_utf8(encoding: &str) -> bool {
matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
}
/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`.
fn program_name(command: &str) -> String {
let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase();
for ext in [".exe", ".cmd", ".bat"] {
if let Some(stem) = name.strip_suffix(ext) {
return stem.to_owned();
}
}
name
}
fn sets(text: &str, properties: &[&str]) -> bool {
properties.iter().any(|p| text.contains(&format!("-D{p}=")))
}
fn options(properties: &[&str], encoding: &str) -> Vec<String> {
properties.iter().map(|p| format!("-D{p}={encoding}")).collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Java,
JvmTool,
Python,
Other,
}
fn kind(program: &str) -> Kind {
let name = program_name(program);
if name == "java" || name == "javaw" {
Kind::Java
} else if JVM_TOOLS.contains(&name.as_str()) {
Kind::JvmTool
} else if is_python(&name) {
Kind::Python
} else {
Kind::Other
}
}
/// The options for a launch of `program` (`args`, environment `vars` over the
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
/// process already writes it (UTF-8 off Windows) or the user chose the options.
pub fn fix(
program: &str,
args: &[String],
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
fix_kind(kind(program), Some(args), vars, inherited, encoding, windows)
}
/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose
/// arguments cannot be changed: its first word decides, and only variables are set.
pub fn fix_command_line(
line: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
let line = line.trim_start();
let first = match line.chars().next() {
Some(q @ ('"' | '\'')) => line[1..].split(q).next(),
_ => line.split(char::is_whitespace).next(),
}
.unwrap_or_default();
fix_kind(kind(first), None, vars, inherited, encoding, windows)
}
fn fix_kind(
kind: Kind,
args: Option<&[String]>,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
let encoding = encoding.trim();
let mut out = EncodingFix::default();
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
return out;
}
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default();
let tool_options = current(TOOL_OPTIONS);
match (kind, args) {
(Kind::Java, Some(args)) => {
let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS"));
let chosen =
|properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties);
if !chosen(STD_PROPERTIES) {
out.java_args = options(STD_PROPERTIES, encoding);
}
if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) {
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
}
}
(Kind::Java | Kind::JvmTool, _) => {
if !sets(&tool_options, STD_PROPERTIES) {
let added = options(STD_PROPERTIES, encoding).join(" ");
let value = match tool_options.trim() {
"" => {
out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}"));
added
}
own => format!("{own} {added}"),
};
out.env.push((TOOL_OPTIONS.to_owned(), value));
}
}
(Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => {
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
}
_ => {}
}
out
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: Option<&str>,
windows: bool,
) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
return None;
}
let added = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
})
}
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
fn is_python(name: &str) -> bool {
name == "python"
|| name == "py"
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
}
/// Drops whole lines equal to `notice` from a stream of decoded text (the
/// [`EncodingFix::notice`] each JVM prints), passing everything else through.
/// The start of a line is held back only while it can still become the notice.
#[derive(Debug)]
pub struct NoticeFilter {
notice: String,
held: String,
at_line_start: bool,
}
impl NoticeFilter {
pub fn new(notice: String) -> Self {
Self { notice, held: String::new(), at_line_start: true }
}
pub fn filter(&mut self, text: &str, out: &mut String) {
for piece in text.split_inclusive('\n') {
if !self.at_line_start && self.held.is_empty() {
out.push_str(piece);
self.at_line_start = piece.ends_with('\n');
continue;
}
self.held.push_str(piece);
if self.held.ends_with('\n') {
if self.held.trim_end_matches(['\n', '\r']) != self.notice {
out.push_str(&self.held);
}
self.held.clear();
self.at_line_start = true;
} else if !self.could_be_notice() {
out.push_str(&self.held);
self.held.clear();
self.at_line_start = false;
}
}
}
/// The held text is the start of the notice, or all of it before a `\r\n`.
fn could_be_notice(&self) -> bool {
match self.held.strip_suffix('\r') {
Some(line) => line == self.notice,
None => self.notice.starts_with(self.held.as_str()),
}
}
/// The held-back start of a line, at the end of the stream.
pub fn finish(&mut self) -> String {
std::mem::take(&mut self.held)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_output_prefers_utf8() {
for cp in [936, 1252, 65001, 0] {
for text in ["", "plain ASCII\r\n", "中文 café 🦀\n"] {
assert_eq!(decode_tool_output_with_code_page(text.as_bytes(), cp), text);
}
}
}
#[test]
fn tool_output_decodes_native_diagnostics_and_mixed_lines() {
const GBK: &[u8] = b"\xcf\xb5\xcd\xb3\xd5\xd2\xb2\xbb\xb5\xbd\xd6\xb8\xb6\xa8\xb5\xc4\xc2\xb7\xbe\xb6\xa1\xa3";
assert_eq!(decode_tool_output_with_code_page(GBK, 936), "系统找不到指定的路径。");
let mut mixed = "JVM: 中文\r\n".as_bytes().to_vec();
mixed.extend_from_slice(GBK);
mixed.extend_from_slice(b"\r\nlast");
assert_eq!(decode_tool_output_with_code_page(&mixed, 936), "JVM: 中文\r\n系统找不到指定的路径。\r\nlast");
assert_eq!(decode_tool_output_with_code_page(b"caf\xe9", 1252), "café");
for cp in [65001, 0] {
assert_eq!(decode_tool_output_with_code_page(b"a\xffb", cp), "a�b");
}
}
const STD_UTF8: [&str; 4] = [
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
];
const TOOL_UTF8: &str =
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
fn vars(vars: &[(&str, &str)]) -> BTreeMap<String, String> {
vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
}
fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
fix(program, &args, &vars(env), |_| None, encoding, windows)
}
fn tool_env(f: &EncodingFix) -> Option<&str> {
f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str())
}
#[test]
fn java_launches_write_the_console_encoding_on_windows() {
let mut expected: Vec<&str> = STD_UTF8.to_vec();
expected.push("-Dfile.encoding=UTF-8");
// A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch.
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] {
for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] {
let f = run(program, args, &[], "UTF-8", true);
assert_eq!(f.java_args, expected, "{program}");
assert!(f.env.is_empty() && f.notice.is_none());
}
}
// Another console encoding: the streams follow it, the default charset stays the platform's.
assert_eq!(
run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args,
[
"-Dstdout.encoding=GBK",
"-Dstderr.encoding=GBK",
"-Dsun.stdout.encoding=GBK",
"-Dsun.stderr.encoding=GBK"
]
);
}
#[test]
fn nothing_changes_where_output_already_matches() {
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
}
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] {
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
}
// A console set to another encoding elsewhere still gets it.
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
}
#[test]
fn options_chosen_by_the_user_win() {
let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true);
assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]);
let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true);
assert_eq!(f.java_args, STD_UTF8);
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true);
assert!(f.java_args.is_empty(), "{key}");
}
assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
}
/// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs
/// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them.
#[test]
fn build_tools_reach_the_jvms_they_fork() {
for program in [
"mvn.cmd",
"mvnw.cmd",
r"C:\maven\bin\mvn.cmd",
"mvnd.cmd",
"gradlew.bat",
"gradle.bat",
"sbt.bat",
"kotlinc.bat",
] {
let f = run(program, &["spring-boot:run"], &[], "UTF-8", true);
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}");
assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str()));
assert!(f.java_args.is_empty());
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone");
}
// Existing options are kept, from the configuration or the inherited environment; the
// JVMs then print the user's own variable, which stays visible.
let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true);
assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str()));
assert!(f.notice.is_none());
let inherited =
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true);
assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str()));
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
assert_eq!(
run(program, &[], &[], "UTF-8", true).env,
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
"{program}"
);
}
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
}
#[test]
fn shell_command_lines_get_variables_from_their_first_word() {
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
for l in [
"mvn spring-boot:run -pl ruoyi-admin",
"\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test",
"java -jar app.jar",
] {
let f = line(l);
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}");
assert!(f.java_args.is_empty());
}
assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING");
assert_eq!(line("npm run dev && echo done"), EncodingFix::default());
assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default());
}
#[test]
fn java_debug_launches_write_what_java_debug_decodes() {
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
}
fn filtered(chunks: &[&str]) -> String {
let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"));
let mut out = String::new();
for c in chunks {
f.filter(c, &mut out);
}
out.push_str(&f.finish());
out
}
#[test]
fn notice_lines_are_dropped_wherever_the_chunks_split() {
let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}");
let text =
format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast");
let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast";
assert_eq!(filtered(&[&text]), expected);
// Every split point, also inside the notice and between `\r` and `\n`.
for i in 1..text.len() {
let (a, b) = text.split_at(i);
assert_eq!(filtered(&[a, b]), expected, "split at {i}");
}
// A line that only starts like it is not held: text mid-line passes through at once.
let mut f = NoticeFilter::new(notice.clone());
let mut out = String::new();
f.filter("Pick a number: ", &mut out);
assert_eq!(out, "Pick a number: ");
f.filter("Picked", &mut out);
assert_eq!(out, "Pick a number: Picked");
// A prefix at the end of the stream comes out on finish.
assert_eq!(filtered(&["Picked up"]), "Picked up");
}
}
+1 -369
View File
@@ -1,11 +1,8 @@
//! Child-process helpers shared by every crate that spawns tools.
use std::ffi::{OsStr, OsString};
#[cfg(windows)]
use std::process::Stdio;
use std::process::{Child, Command};
use std::process::Command;
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
@@ -28,70 +25,6 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
host_env(cmd)
}
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
pub fn own_group(cmd: &mut Command) -> &mut Command {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd
}
/// Kills the child and everything it started, then reaps it.
///
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
pub fn kill_tree(child: &mut Child) {
#[cfg(unix)]
signal_group(child, libc::SIGKILL);
#[cfg(windows)]
taskkill_tree(child);
let _ = child.kill();
let _ = child.wait();
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
/// so there it is [`kill_tree`].
pub fn terminate_tree(child: &mut Child, grace: Duration) {
#[cfg(unix)]
{
signal_group(child, libc::SIGTERM);
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
// group id) cannot be reused before the `SIGKILL` below.
std::thread::sleep(grace);
}
#[cfg(not(unix))]
let _ = grace;
kill_tree(child);
}
#[cfg(unix)]
fn signal_group(child: &Child, signal: libc::c_int) {
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
// no group at all when it was not spawned with `own_group`, and the call fails).
unsafe {
libc::kill(-pid, signal);
}
}
}
#[cfg(windows)]
fn taskkill_tree(child: &Child) {
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -285,224 +218,10 @@ pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
}
/// First `search` directory holding `name` (see [`executable_names`]).
pub fn find_executable_in(
name: &str,
search: &OsStr,
windows: bool,
pathext: Option<&str>,
) -> Option<std::path::PathBuf> {
let names = executable_names(name, windows, pathext);
std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.find_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
}
/// [`find_executable_in`] for this platform (`PATHEXT` from the environment).
pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf> {
let pathext = host_var_os("PATHEXT");
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
}
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
/// merged `PATH` a process may not inherit — an IDE started before the variable was
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
/// there a login shell already supplies the user's whole `PATH`.
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
unsafe {
let mut key: HKEY = std::ptr::null_mut();
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let value = read_registry_string(key, &wide("Path"));
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
/// against the current environment. `None` when the value is missing or unreadable.
///
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
/// that grows when expanded is re-read at the size the failed call reports. And the
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
/// the first NUL, not just stripped of trailing ones.
#[cfg(windows)]
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
let mut kind = 0u32;
let mut len = 0u32;
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
let status =
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let buf = loop {
let mut buf = vec![0u16; len as usize / 2 + 1];
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
let status = unsafe {
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
};
if status == ERROR_SUCCESS {
break buf;
}
if status != ERROR_MORE_DATA || len == 0 {
return None;
}
};
let text = String::from_utf16_lossy(&buf);
let end = text.find('\0').unwrap_or(text.len());
Some(text[..end].to_owned())
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
None
}
/// `path` followed by the [`user_path`] entries it lacks.
pub fn with_user_path(path: &OsStr) -> OsString {
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
}
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn user_path_reads_the_current_users_path() {
// This host has a user `Path` only when the test account set one; either way the call
// must not fail, and a present value is a non-empty `;`-joined list of directories.
if let Some(path) = user_path() {
let dirs: Vec<_> = std::env::split_paths(&path).collect();
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
}
#[test]
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
@@ -644,91 +363,4 @@ mod tests {
prepare(&mut cmd);
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
}
#[test]
fn find_executable_finds_bare_names_on_the_given_path() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("agent"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("agent.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
// The `.cmd` shim wins over a POSIX script `Command` can't start.
assert_eq!(find_executable("agent", &search), Some(b.join("agent.cmd")));
} else {
assert_eq!(find_executable("agent", &search), Some(a.join("agent")));
}
assert_eq!(find_executable("nope", &search), None);
}
}
#[cfg(all(test, unix))]
mod kill_tests {
use super::*;
use std::path::Path;
use std::time::Instant;
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
fn start(script: &str, ready: &Path) -> Child {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
let child = own_group(&mut cmd).spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(Instant::now() < deadline, "the script never got ready");
std::thread::sleep(Duration::from_millis(10));
}
child
}
fn assert_gone(pid: libc::pid_t) {
let deadline = Instant::now() + Duration::from_secs(5);
// SAFETY: signal 0 only checks that the process exists.
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "process {pid} survived");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn kill_tree_kills_what_the_child_started() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
let mut child = start(&script, &pid_file);
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
kill_tree(&mut child);
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert_gone(grandchild);
}
#[test]
fn terminate_tree_lets_the_child_clean_up() {
let dir = tempfile::tempdir().unwrap();
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
let script = format!(
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
cleaned.display(),
ready.display()
);
let mut child = start(&script, &ready);
terminate_tree(&mut child, Duration::from_millis(500));
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
}
#[test]
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
let dir = tempfile::tempdir().unwrap();
let ready = dir.path().join("ready");
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
let mut child = start(&script, &ready);
let started = Instant::now();
terminate_tree(&mut child, Duration::from_millis(50));
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert!(started.elapsed() < Duration::from_secs(5));
}
}
-80
View File
@@ -207,58 +207,6 @@ impl ProxySettings {
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
/// System properties that make a JVM follow this configuration. A JVM reads none of
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
/// HTTP client) goes direct without these, and a repository its proxy answers in
/// milliseconds can then hang it instead.
pub fn jvm_args(&self) -> Vec<String> {
match self.mode {
// macOS and Windows JVMs read the system proxy with this one.
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
ProxyMode::None => Vec::new(),
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
ProxyMode::Manual => Vec::new(),
}
}
fn manual_jvm_args(&self) -> Vec<String> {
let (host, port) = (self.host.trim(), self.port);
match self.kind {
ProxyKind::Http => {
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
let mut args = vec![
format!("-Dhttp.proxyHost={host}"),
format!("-Dhttp.proxyPort={port}"),
format!("-Dhttps.proxyHost={host}"),
format!("-Dhttps.proxyPort={port}"),
];
let hosts = self.non_proxy_hosts();
if !hosts.is_empty() {
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
}
args
}
// SOCKS proxies every TCP connection, so there is one property pair.
ProxyKind::Socks => vec![
format!("-DsocksProxyHost={host}"),
format!("-DsocksProxyPort={port}"),
"-Djava.net.useSystemProxies=false".into(),
],
}
}
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
fn non_proxy_hosts(&self) -> Vec<String> {
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
for entry in self.no_proxy_entries() {
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
hosts.push(entry);
}
}
hosts
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
@@ -344,34 +292,6 @@ mod tests {
assert!(p.child_env().is_empty());
}
#[test]
fn jvm_args_carry_the_proxy_as_system_properties() {
// A JVM reads none of `child_env`: a Java language server needs these to not go
// direct while curl, git and Node follow the environment.
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
assert_eq!(
manual("proxy.corp", 3128).jvm_args(),
[
"-Dhttp.proxyHost=proxy.corp",
"-Dhttp.proxyPort=3128",
"-Dhttps.proxyHost=proxy.corp",
"-Dhttps.proxyPort=3128",
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
]
);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
let p = ProxySettings {
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
..manual("proxy.corp", 3128)
};
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
// A manual proxy without a host connects directly, and so does its JVM.
assert!(manual(" ", 80).jvm_args().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
+1 -30
View File
@@ -103,19 +103,10 @@ impl Default for DeploymentSettings {
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
#[default]
Sftp,
Local,
}
/// A deployment filesystem and how project folders map onto it.
/// An SFTP server and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentServer {
pub kind: DeploymentType,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
@@ -157,23 +148,3 @@ pub struct RunTarget {
pub node: String,
pub java: String,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
#[test]
fn deployment_type_defaults_and_round_trips() {
let legacy: DeploymentServer =
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
);
}
}
}
+2 -276
View File
@@ -58,43 +58,6 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -191,7 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -216,7 +178,7 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
/// 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,
@@ -235,7 +197,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: true,
confirm_exit: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -314,8 +276,6 @@ pub struct AppearanceSettings {
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
pub use_custom_font: bool,
pub ui_font_family: String,
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
pub ui_font_fallback: String,
pub contrast_scrollbars: bool,
/// Adjust colours for red-green vision deficiency.
pub red_green_vision: bool,
@@ -325,14 +285,11 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
pub show_tool_window_bars: bool,
pub show_tool_window_names: bool,
pub show_tool_window_badges: 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.
@@ -366,7 +323,6 @@ impl Default for AppearanceSettings {
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
ui_font_fallback: String::new(),
contrast_scrollbars: false,
red_green_vision: false,
different_tool_window_background: false,
@@ -375,12 +331,10 @@ impl Default for AppearanceSettings {
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
@@ -388,15 +342,6 @@ impl Default for AppearanceSettings {
}
}
/// Imported editor colour scheme on top of a built-in base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomColorScheme {
pub name: String,
pub parent: String,
pub attributes: BTreeMap<String, TextAttributesOverride>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct EditorSettings {
@@ -412,8 +357,6 @@ pub struct EditorSettings {
pub format_on_save: bool,
/// Editor colour scheme id; `theme-default` follows the IDE theme.
pub color_scheme: String,
/// Imported editor schemes, stored in user settings.
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
@@ -461,7 +404,6 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
@@ -492,8 +434,6 @@ impl Default for EditorSettings {
pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
/// Re-check open files against disk when the IDE window gains focus.
pub sync_on_focus: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
@@ -525,7 +465,6 @@ impl Default for FileSettings {
fn default() -> Self {
Self {
auto_save: true,
sync_on_focus: true,
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
trim_trailing_whitespace: true,
@@ -546,49 +485,7 @@ pub struct LanguagesSettings {
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer Prettier even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
fn default() -> Self {
Self {
path: String::new(),
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
]
.into_iter()
.map(String::from)
.collect(),
prefer: false,
run_on_save: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -606,13 +503,6 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RubySettings {
/// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH).
pub interpreter: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct NodeSettings {
@@ -656,35 +546,6 @@ pub struct GoSettings {
pub goroot: String,
/// Empty = `go env GOPATH`.
pub gopath: String,
pub build_tags: GoBuildTags,
pub modules: GoModules,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoModules {
pub env: BTreeMap<String, String>,
pub auto_vendor: bool,
}
impl Default for GoModules {
fn default() -> Self {
Self { env: BTreeMap::new(), auto_vendor: true }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoBuildTags {
/// Empty values inherit the selected toolchain's defaults.
pub os: String,
pub arch: String,
pub version: String,
pub compiler: String,
/// Empty = automatic, "1" = enabled, "0" = disabled.
pub cgo: String,
pub custom: String,
pub experiments: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -702,37 +563,12 @@ pub struct JavaSettings {
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
pub gradle: GradleSettings,
}
/// Build Tools › Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
/// Used only for the local distribution.
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleDistribution {
#[default]
Wrapper,
Local,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
@@ -811,62 +647,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
assert!(old.appearance.ui_font_fallback.is_empty());
for family in ["", "Songti SC", "Maple Mono NF CN"] {
let mut settings = Settings::default();
settings.appearance.ui_font_fallback = family.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["uiFontFallback"], family);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.ui_font_fallback, family);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
assert!(old.appearance.show_tool_window_badges);
assert!(Settings::default().appearance.show_tool_window_badges);
for enabled in [false, true] {
let mut settings = Settings::default();
settings.appearance.show_tool_window_badges = enabled;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
}
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
}
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
@@ -911,57 +691,3 @@ mod tests {
assert!(Settings::default().files.file_types.is_empty());
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.unlinked_projects.is_empty());
let settings =
MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
assert_eq!(old.gradle, GradleSettings::default());
for distribution in [GradleDistribution::Wrapper, GradleDistribution::Local] {
let mut settings = Settings::default();
settings.languages.java.gradle = GradleSettings {
jvm: "/jdk21".into(),
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
}
}
}
#[cfg(test)]
mod go_module_tests {
use super::*;
#[test]
fn old_go_settings_inherit_module_defaults() {
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
assert!(old.modules.auto_vendor);
assert!(old.modules.env.is_empty());
let configured: GoSettings =
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
.unwrap();
assert!(!configured.modules.auto_vendor);
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
}
}
+4 -109
View File
@@ -97,88 +97,15 @@ pub struct TextEdit {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEdit {
pub files: Vec<FileEdit>,
/// Content fingerprints captured before presenting an edit for confirmation.
#[serde(default)]
#[specta(optional)]
pub preconditions: Vec<WorkspaceEditPrecondition>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEditPrecondition {
pub path: crate::IpcPath,
pub hash: String,
}
impl WorkspaceEdit {
/// Coalesces aliases of the same file before applying edits against its original text.
/// Resource operations separate batches because later edits may use the new contents.
pub fn deduplicate(&mut self) {
let mut files = Vec::new();
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
let mut resolve_aliases = true;
for file in std::mem::take(&mut self.files) {
match file {
FileEdit::Edit { path, edits, version } => {
let path = if resolve_aliases {
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let index = *batch.entry(path.clone()).or_insert_with(|| {
files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version });
files.len() - 1
});
let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else {
unreachable!()
};
if expected.is_some() && version.is_some() && *expected != version {
// Keep incompatible constraints so application rejects them
// before changing any file instead of silently losing one.
files.push(FileEdit::Edit { path: path.into(), edits, version });
continue;
}
*expected = expected.or(version);
for edit in edits {
if !own.contains(&edit) {
own.push(edit);
}
}
}
op => {
batch.clear();
// Later paths refer to a filesystem state the resource operation has yet to create.
resolve_aliases = false;
files.push(op);
}
}
}
self.files = files;
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileEdit {
Edit {
path: crate::IpcPath,
edits: Vec<TextEdit>,
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
#[serde(default)]
#[specta(optional)]
version: Option<i32>,
},
Create {
path: crate::IpcPath,
overwrite: bool,
},
Rename {
from: crate::IpcPath,
to: crate::IpcPath,
},
Delete {
path: crate::IpcPath,
recursive: bool,
},
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)]
@@ -276,35 +203,3 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("main.cpp");
let alias = dir.path().join("alias.cpp");
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None };
let mut workspace = WorkspaceEdit {
files: vec![
edit(real.clone()),
edit(alias),
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
edit(real),
],
..Default::default()
};
workspace.deduplicate();
assert_eq!(workspace.files.len(), 3);
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
assert_eq!(edits, &[change]);
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once);
}
}
-233
View File
@@ -1,233 +0,0 @@
//! Where the IDE looks up the executables it runs: language servers, the tools
//! their settings come from, installers. An app started from the desktop gets a
//! minimal `PATH`, and version managers set theirs up in shell rc files.
use crate::process::{host_var_os, login_env};
use crate::version_managers;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
/// Where executables are looked up: this process's `PATH` (the login shell's in the
/// app), the login shell's `PATH` as last read, then per-user directories and mise's,
/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server
/// launchers, their settings hooks and tool detection all look here, so a settings
/// hook finds the executable its server is launched from.
pub fn search_path() -> OsString {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
// The Windows user `PATH` may not reach a process started before it was set; append its
// entries after the inherited ones so missing user tools still resolve.
let user_path = crate::process::user_path();
let dirs = search_dirs(
host_var_os("PATH").as_deref(),
shell_path.as_deref(),
user_path.as_deref(),
home.as_deref(),
&shims,
);
std::env::join_paths(dirs).unwrap_or_default()
}
/// Resolves a command on the shared path, or an explicit path relative to `root`.
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
let path = Path::new(command);
let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty());
let pathext = host_var_os("PATHEXT");
let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str));
let dirs = if explicit {
vec![root.to_path_buf()]
} else {
std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect()
};
dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p)))
}
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else { return false };
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which
/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a
/// directory name) is left out rather than failing the whole path.
fn search_dirs(
process_path: Option<&OsStr>,
shell_path: Option<&OsStr>,
user_path: Option<&OsStr>,
home: Option<&Path>,
shims: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> =
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
if let Some(home) = home {
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
}
// Version managers' shims after real tool directories: a shim exists for a command any
// installed version has, and fails when the selected one lacks it. Before the system's,
// as the managers' shell setup puts them.
dirs.extend(home.map(|h| h.join(".pyenv/shims")));
dirs.extend(shims.iter().cloned());
dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from));
let mut seen = HashSet::new();
dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone()));
dirs
}
/// Tool directories below the home directory.
const USER_DIRS: &[&str] = &[
".cargo/bin",
".local/bin",
"go/bin",
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
".dotnet/tools",
".bun/bin",
".deno/bin",
".npm-global/bin",
".volta/bin",
// Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS).
".local/share/fnm/aliases/default/bin",
"Library/Application Support/fnm/aliases/default/bin",
".fnm/aliases/default/bin",
".local/share/pnpm/bin",
".local/share/pnpm",
"Library/pnpm/bin",
"Library/pnpm",
];
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
/// whatever `PATH` holds.
pub fn is_system_dir(dir: &Path) -> bool {
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
}
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin")))
.collect();
versions.sort();
versions.into_iter().rev().map(|(_, bin)| bin).collect()
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_resolve_on_path_and_explicit_paths_under_root() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" };
std::fs::write(bin.join(name), "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
}
let search = std::env::join_paths([&bin]).unwrap();
assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command("absent", &search, dir.path()), None);
for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] {
assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name)));
}
}
#[cfg(unix)]
#[test]
fn non_executable_files_do_not_shadow_commands() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dirs = [tmp.path().join("first"), tmp.path().join("second")];
for (dir, mode) in dirs.iter().zip([0o644, 0o755]) {
std::fs::create_dir(dir).unwrap();
let file = dir.join("ngserver");
std::fs::write(&file, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap();
}
let path = std::env::join_paths(&dirs).unwrap();
assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver")));
assert_eq!(find_command("first/ngserver", &path, tmp.path()), None);
}
#[cfg(unix)]
#[test]
fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() {
let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()];
let dirs = search_dirs(
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
// nushell prints `$PATH` as is.
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
None,
Some(Path::new("/home/me")),
&shims,
);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
assert!(dirs.iter().all(|d| d.is_absolute()));
let unique: HashSet<&PathBuf> = dirs.iter().collect();
assert_eq!(unique.len(), dirs.len());
// A `:` can't be in a `PATH` entry: those below such a home are left out.
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
}
#[test]
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
let (system, dup, usertool) =
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
let inherited = join(&[system, dup]);
let user = join(&[dup, usertool]);
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
assert!(at(dup) < at(usertool));
// And before this platform's home/system fallbacks.
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
assert!(at(usertool) < fallback);
}
#[test]
fn nvm_versions_newest_first() {
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] {
std::fs::create_dir_all(dir.join(v).join("bin")).unwrap();
}
let bins = nvm_bins(&dir);
std::fs::remove_dir_all(&dir).unwrap();
let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect();
assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]);
}
}
-243
View File
@@ -1,243 +0,0 @@
//! Version managers that keep one install per tool version (mise, asdf): where their
//! installs and shims live. SDK detection lists the installs; child processes get the
//! shims on `PATH` when the manager's shell hook didn't run (an app started from the Dock,
//! `mise activate` only in an interactive rc file).
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
pub const MISE: &str = "mise";
pub const ASDF: &str = "asdf";
/// A toolchain the version managers install.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tool {
Java,
Node,
Python,
Go,
Maven,
Gradle,
Ruby,
}
impl Tool {
/// The tool's names in mise and asdf plugins (`nodejs`, `golang` are asdf's).
pub fn names(self) -> &'static [&'static str] {
match self {
Tool::Java => &["java"],
Tool::Node => &["node", "nodejs"],
Tool::Python => &["python"],
Tool::Go => &["go", "golang"],
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
}
}
}
/// One version manager's data directory (`installs/`, `shims/`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Manager {
pub name: &'static str,
pub dir: PathBuf,
}
impl Manager {
pub fn shims(&self) -> PathBuf {
self.dir.join("shims")
}
/// `installs/<tool>/<version>` directories of `tool`, sorted. The alias links mise
/// adds next to an install (`21 -> ./21.0.2`, `latest`) are left out; a link to an
/// install elsewhere (`mise link`) is kept.
pub fn installs(&self, tool: Tool) -> Vec<PathBuf> {
let mut out = Vec::new();
for name in tool.names() {
let Ok(entries) = std::fs::read_dir(self.dir.join("installs").join(name)) else { continue };
for e in entries.flatten() {
let path = e.path();
let alias = e.file_type().is_ok_and(|t| t.is_symlink())
&& std::fs::read_link(&path).is_ok_and(|t| t.is_relative() && t.components().count() <= 2);
if !alias && path.is_dir() {
out.push(path);
}
}
}
out.sort();
out
}
/// The install of `tool` named `version` exactly (alias links included).
pub fn install_named(&self, tool: Tool, version: &str) -> Option<PathBuf> {
if version.is_empty() || version.contains(['/', '\\']) || version.starts_with('.') {
return None;
}
tool.names().iter().map(|n| self.dir.join("installs").join(n).join(version)).find(|p| p.is_dir())
}
}
/// mise's and asdf's data directories that exist, as this process's environment (and the
/// login shell's) set them.
pub fn managers() -> Vec<Manager> {
let lookup = |k: &str| {
crate::process::host_var_os(k)
.or_else(|| crate::process::login_env()?.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()))
};
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
managers_with(&lookup, home.as_deref()).into_iter().filter(|m| m.dir.is_dir()).collect()
}
/// mise: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise`, else `~/.local/share/mise`
/// (`%LOCALAPPDATA%\mise` on Windows). asdf: `ASDF_DATA_DIR`, else `~/.asdf`.
pub fn managers_with(env: &dyn Fn(&str) -> Option<OsString>, home: Option<&Path>) -> Vec<Manager> {
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let mise = var("MISE_DATA_DIR")
.or_else(|| var("XDG_DATA_HOME").map(|d| d.join("mise")))
.or_else(|| if cfg!(windows) { var("LOCALAPPDATA").map(|d| d.join("mise")) } else { None })
.or_else(|| home.map(|h| h.join(".local/share/mise")));
let asdf = var("ASDF_DATA_DIR").or_else(|| home.map(|h| h.join(".asdf")));
let mut out = Vec::new();
out.extend(mise.map(|dir| Manager { name: MISE, dir }));
out.extend(asdf.map(|dir| Manager { name: ASDF, dir }));
out
}
/// Every install of `tool` with the manager it came from, mise's first.
pub fn installs(tool: Tool) -> Vec<(PathBuf, &'static str)> {
managers().iter().flat_map(|m| m.installs(tool).into_iter().map(|p| (p, m.name))).collect()
}
/// The manager whose installs `path` lies in.
pub fn manager_of(path: &Path) -> Option<&'static str> {
manager_in(&managers(), path)
}
pub fn manager_in(managers: &[Manager], path: &Path) -> Option<&'static str> {
managers.iter().find_map(|m| {
let installs = m.dir.join("installs");
let real = dunce::canonicalize(&installs).ok();
(path.starts_with(&installs) || real.is_some_and(|r| path.starts_with(r))).then_some(m.name)
})
}
/// Whether `path` is a version manager's shim (it picks the version by the working directory).
pub fn is_shim(path: &Path) -> bool {
path.parent().is_some_and(is_shims_dir)
}
/// Whether `dir` is a version manager's `shims` folder.
pub fn is_shims_dir(dir: &Path) -> bool {
managers().iter().any(|m| m.shims() == dir)
}
/// `path` (a `PATH` value) with the shims of a manager it doesn't reach yet: mise's in
/// front (a mise shim runs the next tool on `PATH` when no version is set), asdf's at the
/// end (an asdf shim fails then). A `PATH` that has a manager's shims or installs already
/// (its shell hook ran) is left as it is for that manager.
pub fn path_with_shims(managers: &[Manager], path: &OsStr) -> OsString {
let mut dirs: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
let before = dirs.len();
for m in managers {
let shims = m.shims();
let installs = m.dir.join("installs");
if !shims.is_dir() || dirs.iter().any(|d| d == &shims || d.starts_with(&installs)) {
continue;
}
if m.name == MISE {
dirs.insert(0, shims);
} else {
dirs.push(shims);
}
}
if dirs.len() == before {
return path.to_owned();
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn mkdir(p: &Path) {
std::fs::create_dir_all(p).unwrap();
}
#[test]
fn data_dirs_follow_the_environment() {
let home = Path::new("/home/me");
let none = |_: &str| None;
let dirs: Vec<_> = managers_with(&none, Some(home)).into_iter().map(|m| (m.name, m.dir)).collect();
if !cfg!(windows) {
assert_eq!(dirs[0], (MISE, home.join(".local/share/mise")));
}
assert_eq!(dirs[1], (ASDF, home.join(".asdf")));
let xdg = |k: &str| (k == "XDG_DATA_HOME").then(|| OsString::from("/xdg"));
assert_eq!(managers_with(&xdg, Some(home))[0].dir, Path::new("/xdg/mise"));
let custom = |k: &str| match k {
"MISE_DATA_DIR" => Some(OsString::from("/opt/mise")),
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
"ASDF_DATA_DIR" => Some(OsString::from("/opt/asdf")),
_ => None,
};
let dirs: Vec<_> = managers_with(&custom, Some(home)).into_iter().map(|m| m.dir).collect();
assert_eq!(dirs, [PathBuf::from("/opt/mise"), PathBuf::from("/opt/asdf")]);
}
#[cfg(unix)]
#[test]
fn installs_skip_alias_links() {
let tmp = tempfile::tempdir().unwrap();
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
let node = mise.dir.join("installs/node");
mkdir(&node.join("20.11.0"));
mkdir(&node.join("22.1.0"));
std::os::unix::fs::symlink("./22.1.0", node.join("22")).unwrap();
std::os::unix::fs::symlink("./22.1.0", node.join("latest")).unwrap();
let elsewhere = tmp.path().join("opt-node");
mkdir(&elsewhere);
std::os::unix::fs::symlink(&elsewhere, node.join("linked")).unwrap();
std::fs::write(node.join(".mise.backend.toml"), "").unwrap();
let names: Vec<_> =
mise.installs(Tool::Node).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["20.11.0", "22.1.0", "linked"]);
assert_eq!(mise.install_named(Tool::Node, "22"), Some(node.join("22")));
assert_eq!(mise.install_named(Tool::Node, "18"), None);
assert_eq!(mise.install_named(Tool::Node, "../node"), None);
// asdf's plugin names.
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
mkdir(&asdf.dir.join("installs/golang/1.22.1"));
assert_eq!(asdf.installs(Tool::Go), [asdf.dir.join("installs/golang/1.22.1")]);
assert_eq!(manager_in(&[mise.clone(), asdf.clone()], &node.join("20.11.0/bin/node")), Some(MISE));
assert_eq!(manager_in(&[mise, asdf], tmp.path()), None);
}
#[cfg(unix)]
#[test]
fn shims_join_a_path_the_hook_did_not_set() {
let tmp = tempfile::tempdir().unwrap();
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
let managers = [mise.clone(), asdf.clone()];
let path = OsString::from("/usr/bin:/bin");
assert_eq!(path_with_shims(&managers, &path), path, "no shims installed");
mkdir(&mise.shims());
mkdir(&asdf.shims());
let got = path_with_shims(&managers, &path);
let want = format!("{}:/usr/bin:/bin:{}", mise.shims().display(), asdf.shims().display());
assert_eq!(got, OsString::from(want));
assert_eq!(path_with_shims(&managers, &got), got, "applied once");
// `mise activate` put an install on PATH: mise is active, only asdf's shims join.
let active = format!("{}:/usr/bin", mise.dir.join("installs/node/22.1.0/bin").display());
let got = path_with_shims(&managers, OsStr::new(&active));
assert_eq!(got, OsString::from(format!("{active}:{}", asdf.shims().display())));
}
}
-3
View File
@@ -41,8 +41,6 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -73,7 +71,6 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+2 -6
View File
@@ -213,12 +213,8 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
.output()
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
if !out.status.success() {
let text = ide_core::output_encoding::decode_tool_output(&out.stderr).trim().to_owned();
let text = if text.is_empty() {
ide_core::output_encoding::decode_tool_output(&out.stdout).trim().to_owned()
} else {
text
};
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)?;
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
+2 -45
View File
@@ -63,12 +63,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
"js-debug" => cfg(
"js-debug-adapter",
@@ -348,17 +342,8 @@ pub struct AdapterIo {
pub port: Option<u16>,
/// Recent stderr lines of the adapter process, for error messages.
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
pub program_output: Option<ProgramOutput>,
}
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
/// Starts adapters. Tests substitute in-memory adapters.
@@ -394,7 +379,6 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(port),
stderr,
program_output: None,
});
}
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
@@ -417,7 +401,6 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(*port),
stderr,
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
@@ -456,14 +439,8 @@ impl AdapterLauncher for ProcessLauncher {
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
pump_lines(err, spec.adapter.clone(), stderr.clone());
}
match port {
None => {
@@ -475,12 +452,11 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
pump_lines(out, spec.adapter.clone(), stderr.clone());
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
@@ -502,7 +478,6 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
@@ -514,15 +489,9 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
program: Option<(ProgramSink, &'static str)>,
) {
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
if let Some((tx, category)) = &program
&& !line.starts_with(DELVE_BANNER)
{
let _ = tx.send((category, format!("{line}\n")));
}
push_line(&sink, line);
}));
}
@@ -574,18 +543,6 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let sink = Arc::default();
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
assert_eq!(rx.recv().await, None);
assert_eq!(sink.lock().len(), 3);
}
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
#[cfg(unix)]
#[tokio::test]
+2 -4
View File
@@ -70,10 +70,8 @@ pub async fn build(
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).split(b'\n');
while let Ok(Some(line)) = lines.next_segment().await {
let line = ide_core::output_encoding::decode_tool_output(&line);
let line = line.trim_end_matches('\r');
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
err_out(format!("{line}\n"));
}
});
-218
View File
@@ -1,218 +0,0 @@
//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project.
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
pub const ADAPTER: &str = "kotlin-debug-adapter";
pub struct SourceMirror {
dir: tempfile::TempDir,
paths: HashMap<PathBuf, PathBuf>,
copies: Vec<(PathBuf, PathBuf)>,
source_dir: PathBuf,
roots: HashMap<PathBuf, usize>,
}
impl SourceMirror {
pub fn create(project: &Path) -> IdeResult<Self> {
let base = fs::canonicalize(std::env::temp_dir())?;
// Check before creating anything, including when TMPDIR points into a project.
if base.starts_with(fs::canonicalize(project)?) {
return Err(IdeError::invalid("Kotlin source mirror must be outside the project"));
}
let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?;
let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() };
let mut mirror = Self {
dir,
paths: HashMap::new(),
copies: Vec::new(),
source_dir: source_dir.clone(),
roots: HashMap::new(),
};
mirror.scan(&source_dir)?;
Ok(mirror)
}
pub fn root(&self) -> &Path {
self.dir.path()
}
fn scan(&mut self, dir: &Path) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
if !matches!(
entry.file_name().to_str(),
Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea")
) {
self.scan(&path)?;
}
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) {
self.add(&path)?;
}
}
Ok(())
}
fn add(&mut self, real: &Path) -> IdeResult<()> {
if self.paths.contains_key(real) {
return Ok(());
}
let Ok(text) = fs::read_to_string(real) else {
tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display());
return Ok(());
};
let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" };
let relative = real.strip_prefix(&self.source_dir).unwrap_or(real);
let mut components = relative.components();
let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default();
let next_root = self.roots.len();
let index = *self.roots.entry(source_set).or_insert(next_root);
let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language);
for part in package(&text).split('.').filter(|s| !s.is_empty()) {
if part == ".." || part.contains(['/', '\\']) {
tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display());
return Ok(());
}
mirror.push(part);
}
mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?);
// Same package and file name in one source set (e.g. main and test of a module):
// the first wins; breakpoints in the other stay unverified instead of failing the session.
if self.paths.values().any(|p| p == &mirror) {
tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display());
return Ok(());
}
fs::create_dir_all(mirror.parent().unwrap())?;
if link(real, &mirror).is_err() {
fs::copy(real, &mirror)?;
self.copies.push((real.to_owned(), mirror.clone()));
}
self.paths.insert(real.to_owned(), mirror);
Ok(())
}
pub fn adapter_path(&self, real: &Path) -> Option<PathBuf> {
self.paths.get(real).cloned()
}
pub fn client_path(&self, mirror: &Path) -> Option<PathBuf> {
self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone()))
}
pub fn refresh(&mut self) -> IdeResult<()> {
for (real, mirror) in &self.copies {
if real.is_file() {
fs::copy(real, mirror)?;
}
}
self.scan(&self.source_dir.clone())
}
pub fn translate(&self, value: &mut Value) {
match value {
Value::Object(obj) => {
if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p)))
{
obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned()));
}
for child in obj.values_mut() {
self.translate(child);
}
}
Value::Array(items) => {
for child in items {
self.translate(child);
}
}
_ => {}
}
}
}
#[cfg(unix)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(real, mirror)
}
#[cfg(windows)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(real, mirror)
}
// Remove comments before reading the package directive (including nested Kotlin block comments).
fn package(text: &str) -> String {
let mut clean = String::new();
let mut chars = text.chars().peekable();
let mut depth = 0;
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
depth += 1;
clean.push(' ');
} else if depth > 0 && c == '*' && chars.peek() == Some(&'/') {
chars.next();
depth -= 1;
} else if depth == 0 && c == '/' && chars.peek() == Some(&'/') {
for c in chars.by_ref() {
if c == '\n' {
clean.push('\n');
break;
}
}
} else if depth == 0 {
clean.push(c);
}
}
clean
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("package")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.split(';').next().unwrap_or_default().trim().replace('`', ""))
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_mirror_round_trip_and_cleanup() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.kt");
fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap();
let mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
assert!(!path.starts_with(project.path()));
assert!(path.ends_with("src/main/kotlin/org/example/Main.kt"));
assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap());
let mut payload =
serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}});
mirror.translate(&mut payload);
assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref());
assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref());
let root = mirror.root().to_owned();
drop(mirror);
assert!(!root.exists());
assert!(real.exists());
}
#[test]
fn copies_refresh_after_save() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.java");
fs::write(&real, "package example;\nclass Main {}\n").unwrap();
let mut mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
fs::remove_file(&path).unwrap();
fs::copy(&real, &path).unwrap();
mirror.copies.push((real.clone(), path.clone()));
fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap();
mirror.refresh().unwrap();
assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap());
}
}
-166
View File
@@ -1,166 +0,0 @@
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
use ide_core::IdeResult;
use std::{io, time::Duration};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
task::JoinHandle,
};
pub(crate) struct StartupRelay {
release: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl StartupRelay {
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let connect = async {
let (adapter, _) = listener.accept().await?;
let jvm = TcpStream::connect((host.as_str(), port)).await?;
adapter.set_nodelay(true)?;
jvm.set_nodelay(true)?;
Ok::<_, io::Error>((adapter, jvm))
};
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
let (mut ar, mut aw) = adapter.into_split();
let (mut jr, mut jw) = jvm.into_split();
let upstream = forward(&mut ar, &mut jw, ready);
let downstream = tokio::io::copy(&mut jr, &mut aw);
// Close both sockets as soon as either side disconnects.
tokio::select! {
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
_ = downstream => {},
}
}
});
Ok((Self { release: Some(release), task }, local))
}
pub fn release(&mut self) {
if let Some(ready) = self.release.take() {
let _ = ready.send(());
}
}
}
impl Drop for StartupRelay {
fn drop(&mut self) {
self.task.abort();
}
}
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
let mut header = [0; 11];
reader.read_exact(&mut header).await?;
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
if !(11..=16 * 1024 * 1024).contains(&len) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
}
let mut packet = vec![0; len];
packet[..11].copy_from_slice(&header);
reader.read_exact(&mut packet[11..]).await?;
Ok(packet)
}
async fn forward(
reader: &mut (impl AsyncRead + Unpin),
writer: &mut (impl AsyncWrite + Unpin),
mut ready: oneshot::Receiver<()>,
) -> io::Result<()> {
let mut handshake = [0; 14];
reader.read_exact(&mut handshake).await?;
if &handshake != b"JDWP-Handshake" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
}
writer.write_all(&handshake).await?;
let mut released = false;
let mut held: Vec<Vec<u8>> = Vec::new();
loop {
// Retain the in-flight read when attach completes, including partial packets.
let read = packet(reader);
tokio::pin!(read);
let packet = tokio::select! {
result = &mut read => result?,
result = &mut ready, if !released => {
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
released = true;
for packet in held.drain(..) {
writer.write_all(&packet).await?;
}
read.await?
}
};
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
// Other commands must proceed: attach itself queries threads and installs requests.
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
if held.len() >= 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
}
held.push(packet);
} else {
writer.write_all(&packet).await?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
}
#[tokio::test]
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
let (mut adapter, mut reader) = tokio::io::duplex(4096);
let (mut writer, mut jvm) = tokio::io::duplex(4096);
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
adapter.write_all(b"JDWP-Handshake").await.unwrap();
let mut handshake = [0; 14];
jvm.read_exact(&mut handshake).await.unwrap();
assert_eq!(&handshake, b"JDWP-Handshake");
let resume = command(1, 1, 9);
let thread_resume = command(2, 11, 3);
let setup = command(3, 1, 4);
adapter.write_all(&resume).await.unwrap();
adapter.write_all(&thread_resume).await.unwrap();
adapter.write_all(&setup).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
let next = command(4, 1, 9);
adapter.write_all(&next[..6]).await.unwrap();
release.send(()).unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
adapter.write_all(&next[6..]).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), next);
drop(adapter);
assert!(task.await.unwrap().is_err());
}
#[tokio::test]
async fn dropping_session_closes_relay() {
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
drop(relay);
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_packet_lengths() {
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
let mut bytes = command(1, 1, 9);
bytes[..4].copy_from_slice(&len.to_be_bytes());
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
}
}
+3 -93
View File
@@ -13,8 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -565,14 +563,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
.and_then(|o| o.remove("__adapterJavaHome"))
.and_then(|v| v.as_str().map(str::to_owned))
{
spec.env.push(("JAVA_HOME".into(), home));
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -697,14 +687,6 @@ impl DebugManager {
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(mut output) = io.program_output {
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
});
}
self.sessions.lock().insert(id, session.clone());
self.order.lock().push(id);
// Announced before the event loop runs, so it precedes every adapter event.
@@ -740,19 +722,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| s.spec.cwd.clone());
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
let caps = s
.conn
.request_timeout(
@@ -779,32 +749,8 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
.to_owned();
let port = arguments
.get("port")
.and_then(Value::as_u64)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let launch = s.conn.send_request(&s.request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
@@ -834,7 +780,6 @@ impl DebugManager {
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
}
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
}
}
@@ -857,11 +802,7 @@ impl DebugManager {
let _ = self.send_exception_breakpoints(s).await;
}
if s.supports("supportsConfigurationDoneRequest") {
if s.adapter == kotlin::ADAPTER {
drop(s.conn.send_request("configurationDone", json!({}))?);
} else {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
@@ -872,9 +813,6 @@ impl DebugManager {
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -931,7 +869,6 @@ impl DebugManager {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
@@ -949,23 +886,6 @@ impl DebugManager {
Ok(states)
}
/// Refresh fallback copies after an editor save.
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
tokio::task::spawn_blocking(move || {
for s in sessions {
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
&& let Err(e) = mirror.refresh()
{
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
}
}
Ok(())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
self.states.lock().get(path).cloned().unwrap_or_default()
@@ -1389,16 +1309,6 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
+2 -109
View File
@@ -54,8 +54,6 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
}
/// Adapters that only match files by their resolved path: Godot maps a script to
@@ -97,8 +95,6 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
kotlin_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
}
}
@@ -108,14 +104,6 @@ impl Session {
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
pub fn adapter_path(&self, path: &Path) -> PathBuf {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
return path.to_path_buf();
}
@@ -130,14 +118,7 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
})
self.resolved_paths.lock().get(Path::new(path)).cloned()
}
pub fn supports(&self, cap: &str) -> bool {
@@ -255,7 +236,6 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
let _ = c.start_kill();
}
@@ -265,19 +245,6 @@ impl Session {
}
}
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|| path.starts_with("//")
|| path.starts_with(r"\\")
}
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
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();
@@ -330,10 +297,7 @@ pub(crate) async fn event_loop(
}
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut body: Value) {
if let Some(mirror) = s.kotlin_mirror.lock().as_ref() {
mirror.translate(&mut body);
}
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 {
@@ -522,74 +486,3 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
}
});
}
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default()
};
let classpath =
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
if !modules.is_empty() {
cmd.arg("--module-path")
.arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?);
}
cmd.arg(
args.get("mainClass")
.and_then(Value::as_str)
.ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?,
)
.args(strings("programArgs"))
.current_dir(&s.spec.cwd)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
if let Some(v) = v.as_str() {
cmd.env(k, v);
}
}
}
let mut child = cmd.spawn()?;
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
})
.await
.map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??;
forward_output(s, stdout, "stdout");
s.debuggees.lock().push(child);
args["hostName"] = json!("127.0.0.1");
args["port"] = json!(port);
args["timeout"] = json!(10000);
Ok(())
}
+4 -136
View File
@@ -15,8 +15,6 @@ struct Script {
run_in_terminal: Option<Vec<String>>,
/// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives.
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
}
struct FakeLauncher {
@@ -32,14 +30,7 @@ impl AdapterLauncher for FakeLauncher {
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(),
program_output: None,
})
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
})
}
}
@@ -139,9 +130,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
ok!(msg, Value::Null);
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -150,14 +139,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
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, "source": {"path": script.source_path}}}})
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": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"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"}},
]})
),
@@ -904,8 +893,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -1028,122 +1015,3 @@ fn root_and_address_placeholders() {
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
assert_eq!(adapter::parse_address("6006"), None);
}
#[tokio::test]
async fn kotlin_configures_without_configuration_done_response() {
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let mut config = config();
config.adapter = kotlin::ADAPTER.into();
config.request = "attach".into();
config.cwd = Some(project.path().to_owned().into());
config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string());
let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap();
let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap());
assert!(!root.starts_with(project.path()));
let request = h.request("setBreakpoints");
assert_eq!(request["arguments"]["source"]["name"], "Main.kt");
let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap());
assert!(mapped.starts_with(&root));
assert!(mapped.is_file());
assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path));
h.manager.stop(id).await.unwrap();
}
/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example,
/// loop at line 5, println(i) at line 6), and the unmodified official adapter.
/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable.
#[tokio::test]
#[ignore = "requires official Kotlin adapter and compiled reporter fixture"]
async fn kotlin_official_release_ten_no_delay_runs() {
let config: LaunchConfig = serde_json::from_slice(
&std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(),
)
.unwrap();
let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER");
let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt");
for run in 1..=10 {
let (owner, events) = recording_owner("kotlin-probe");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let h = Harness { manager, owner, events, log: Arc::default() };
h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap();
let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap();
let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 5);
assert_eq!(frames[0].path.as_deref(), Some(source.as_path()));
let scopes = h.manager.scopes(id, frames[0].id).await.unwrap();
let mut variables = Vec::new();
for scope in scopes {
variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap());
}
assert!(variables.iter().any(|v| v.name == "i" && v.value == "0"));
h.manager.step(id, thread_id, StepKind::Over).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await;
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 4);
h.manager.stop(id).await.unwrap();
println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped");
}
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
for (client, native, returned) in [
("C:/work/Main.java", r"C:\work\Main.java", r"C:\work\Main.java"),
("C:/work/Main.java", r"C:\work\Main.java", "C:/work/Main.java"),
(r"c:\work\Main.java", r"c:\work\Main.java", "c:/work/Main.java"),
(r"D:/my project\Main.java", r"D:\my project\Main.java", r"D:\my project\Main.java"),
("//server/share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
(r"\\server\share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
("/work/Main.java", "/work/Main.java", "/work/Main.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(4)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "java-debug".into();
cfg.request = request.into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("initialize")["arguments"]["pathFormat"], "path");
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let commands = h.commands();
assert!(
commands.iter().position(|c| c == "setBreakpoints").unwrap()
< commands.iter().position(|c| c == "configurationDone").unwrap()
);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.wait_event(|e| {
matches!(e, DebugEvent::BreakpointsChanged { path: p } if p.as_ref() == path.as_path())
&& h.manager.breakpoint_states(&path)[0].message.as_deref() == Some("moved")
})
.await;
assert_eq!(h.manager.breakpoint_states(&path)[0].message.as_deref(), Some("moved"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test]
async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
for (returned, expected) in [
(r"C:\work\Other.java", "C:/work/Other.java"),
(r"\\server\share\Other.java", "//server/share/Other.java"),
("C:/work/Other.java", "C:/work/Other.java"),
("/work/Other.java", "/work/Other.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let mut cfg = config();
cfg.adapter = "java-debug".into();
let id = h.start(cfg).await.unwrap();
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(PathBuf::from(expected).as_path()));
h.manager.stop(id).await.unwrap();
}
}
-3
View File
@@ -16,9 +16,6 @@ tracing.workspace = true
parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
ide-ssh.workspace = true
+34 -486
View File
@@ -5,9 +5,6 @@
//! whatever its type.
use crate::source::{DataSource, DbDriver, DbSslMode};
use bb8::ManageConnection;
use bb8::Pool as Bb8Pool;
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -47,65 +44,10 @@ pub struct QueryResult {
pub execution_ms: f64,
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
fn deref(&self) -> &Self::Target {
self.0.as_ref().expect("live SQL Server connection")
}
}
impl std::ops::DerefMut for MssqlConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().expect("live SQL Server connection")
}
}
impl ManageConnection for MssqlConnectionManager {
type Connection = MssqlConnection;
type Error = tiberius::error::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
use tokio_util::compat::TokioAsyncWriteCompatExt;
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
config.host(host);
config.port(port);
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
MssqlClient::connect(config, tcp.compat_write()).await?
}
result => result?,
};
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.simple_query("SELECT 1").await?.into_results().await?;
Ok(())
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.0.is_none()
}
}
pub enum Pool {
Sqlite(SqlitePool),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -115,7 +57,6 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) => unreachable!(),
}
};
}
@@ -138,129 +79,6 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
}
}
pub(crate) fn mssql_err(e: tiberius::error::Error) -> IdeError {
match &e {
tiberius::error::Error::Server(d) => {
let code = d.code();
// Wrong login: SQL Server 18456.
let auth = matches!(code, 18452 | 18456 | 18470);
IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("[{code}] {}", d.message()),
)
}
tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => {
IdeError::new(ErrorKind::Io, e.to_string())
}
_ => IdeError::new(ErrorKind::Protocol, e.to_string()),
}
}
fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
match e {
bb8::RunError::User(e) => mssql_err(e),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
fn format_date(days: i64) -> String {
let z = days + 306;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
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 year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}")
}
fn format_hms(secs: u64, frac: u64, scale: u8) -> String {
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
if scale == 0 || frac == 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
let frac = format!("{frac:0width$}", width = usize::from(scale));
format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0'))
}
}
fn format_time(t: tiberius::time::Time) -> String {
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
format_hms(t.increments() / div, t.increments() % div, scale)
}
fn format_numeric(n: tiberius::numeric::Numeric) -> String {
let scale = u32::from(n.scale());
if scale == 0 {
return n.value().to_string();
}
let sign = if n.value() < 0 { "-" } else { "" };
let abs = n.value().unsigned_abs();
let div = 10u128.pow(scale);
format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize)
}
fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option<String> {
use tiberius::ColumnData as C;
match cell {
C::U8(v) => v.map(|v| v.to_string()),
C::I16(v) => v.map(|v| v.to_string()),
C::I32(v) => v.map(|v| v.to_string()),
C::I64(v) => v.map(|v| v.to_string()),
C::F32(v) => v.map(|v| v.to_string()),
C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => {
// Beyond ~9e11, money precision is limited by the driver's f64 decoding.
v.map(|v| format!("{:.4}", v))
}
C::F64(v) => v.map(|v| v.to_string()),
C::Bit(v) => v.map(|v| v.to_string()),
C::String(v) => v.as_deref().map(str::to_owned),
C::Guid(v) => v.map(|v| v.to_string()),
C::Binary(v) => v.as_deref().map(hex),
C::Numeric(v) => v.map(format_numeric),
C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()),
C::Date(v) => v.map(|d| format_date(i64::from(d.days()))),
C::Time(v) => v.map(format_time),
C::DateTime(v) => v.map(|dt| {
let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3))
}),
C::SmallDateTime(v) => v.map(|dt| {
let secs = u64::from(dt.seconds_fragments()) * 60;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0))
}),
C::DateTime2(v) => {
v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time())))
}
C::DateTimeOffset(v) => v.map(|dto| {
let dt = dto.datetime2();
let t = dt.time();
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64;
let local_secs = utc_secs + i64::from(dto.offset()) * 60;
let days = local_secs.div_euclid(86_400);
let secs = local_secs.rem_euclid(86_400) as u64;
let sign = if dto.offset() < 0 { '-' } else { '+' };
let off = dto.offset().unsigned_abs();
format!(
"{} {}{sign}{:02}:{:02}",
format_date(days),
format_hms(secs, t.increments() % div, scale),
off / 60,
off % 60
)
}),
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
@@ -270,46 +88,10 @@ fn hex(bytes: &[u8]) -> String {
s
}
/// How [`cell`] shows a value whose bytes are not text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cells {
/// Result grids: binary values as `0x…` hex.
Display,
/// Catalog queries: names and definitions, so bytes are read as UTF-8 whatever the
/// column's type (MySQL flags `utf8mb3_bin` catalog columns BINARY, and sends every
/// column as binary under `character_set_results = binary`).
Text,
}
/// Whether a value of type `ty` is binary data (see [`binary_type`]).
fn is_binary<DB: sqlx::Database>(ty: &DB::TypeInfo) -> bool
where
String: sqlx::Type<DB>,
{
binary_type(ty.name(), <String as sqlx::Type<DB>>::compatible(ty))
}
/// A BLOB / BINARY type that the driver does not read as text: MySQL flags text in a
/// binary collation (`utf8mb4_bin`) BINARY too, and only the `binary` collation is bytes.
fn binary_type(name: &str, text: bool) -> bool {
let name = name.to_ascii_uppercase();
(name.contains("BLOB") || name.contains("BINARY")) && !text
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
(Err(e), Cells::Text) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
}
}
/// The text of column `i` of `row`.
fn cell<R: Row>(row: &R, i: usize, mode: Cells) -> Option<String>
fn cell<R: Row>(row: &R, i: usize) -> Option<String>
where
usize: ColumnIndex<R>,
String: sqlx::Type<R::Database>,
for<'r> String: Decode<'r, R::Database>,
for<'r> Vec<u8>: Decode<'r, R::Database>,
{
@@ -317,14 +99,18 @@ where
if raw.is_null() {
return None;
}
if mode == Cells::Display && is_binary::<R::Database>(&raw.type_info()) {
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| bytes_text(b, mode))
<Vec<u8> as Decode<R::Database>>::decode(raw)
.ok()
.map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
}
}
}
@@ -336,10 +122,9 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
($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();
@@ -360,7 +145,7 @@ macro_rules! fetch {
has_more = true;
break;
}
rows.push((0..row.len()).map(|i| cell(&row, i, $mode)).collect());
rows.push((0..row.len()).map(|i| cell(&row, i)).collect());
}
Err(e) => {
failure = Some(db_err(e));
@@ -379,41 +164,10 @@ macro_rules! fetch {
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
conn.simple_query(sql).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(())
}
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
.columns()
.await
.map_err(mssql_err)?
.unwrap_or_default()
.iter()
.map(|col| ResultColumn { name: col.name().to_owned(), type_name: format!("{:?}", col.column_type()) })
.collect();
let mut rows = Vec::new();
let mut has_more = false;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
if rows.len() >= max {
has_more = true;
break;
}
rows.push(row.cells().map(|(col, cell)| tiberius_cell(cell, col.column_type())).collect());
}
}
Ok((columns, rows, None, has_more, first))
}
/// 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, Cells::Text);
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1);
fetched
.ok()
.and_then(|f| f.1.into_iter().next())
@@ -526,7 +280,6 @@ impl Pool {
Pool::Sqlite(_) => DbDriver::Sqlite,
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
}
}
@@ -667,60 +420,20 @@ impl Pool {
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
let mut config = Config::new();
config.host(&source.host);
config.port(source.port.unwrap_or(1433));
if !source.user.is_empty() {
config.authentication(tiberius::AuthMethod::sql_server(
source.user.clone(),
password.unwrap_or_default().to_owned(),
));
}
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
.connection_timeout(timeout)
.build(manager)
.await
.map_err(mssql_err)?;
let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?;
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(Pool::SqlServer(pool))
}
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) => {}
_ => with_pool!(self, p => p.close().await),
}
with_pool!(self, p => p.close().await)
}
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
_ => with_pool!(self, p => p.is_closed()),
}
with_pool!(self, p => p.is_closed())
}
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
/// 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> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
Ok(fetched?.1)
}
@@ -739,47 +452,21 @@ impl Pool {
/// 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> {
match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
for (sql, expect_one) in statements {
let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total();
if *expect_one && n != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
mssql_command(&mut conn, "COMMIT TRAN").await?;
Ok(total)
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")));
}
.await;
if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() {
// A failed rollback must never return an open transaction to the pool.
conn.0.take();
}
result
total += n;
}
_ => 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)
}),
}
tx.commit().await.map_err(db_err)?;
Ok(total)
})
}
}
@@ -789,7 +476,6 @@ pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
}
impl Held {
@@ -798,25 +484,18 @@ impl Held {
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)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_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::SqlServer(c) => mssql_command(c, sql).await,
other => {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
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),
Held::SqlServer(_) => unreachable!(),
}
.map_err(db_err)
}
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
@@ -830,9 +509,6 @@ impl Held {
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
}
}
@@ -854,7 +530,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
r = async { fetch!(&mut **$conn, sql, max_rows) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -880,13 +556,6 @@ impl Held {
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}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
};
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
@@ -906,7 +575,6 @@ impl Held {
pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
}
}
@@ -923,95 +591,6 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
async fn sqlserver_connect_reports_io_errors() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = tiberius::Config::new();
config.host("127.0.0.1");
config.port(listener.local_addr().unwrap().port());
drop(listener);
let result = MssqlConnectionManager(config).connect().await;
assert!(matches!(result, Err(tiberius::error::Error::Io { .. })));
}
#[test]
fn sqlserver_cells_preserve_values_and_nulls() {
use std::borrow::Cow;
use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time};
use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric};
let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7));
let cases = [
(C::U8(Some(255)), "255"),
(C::I16(Some(-123)), "-123"),
(C::I32(Some(-456)), "-456"),
(C::I64(Some(i64::MAX)), "9223372036854775807"),
(C::F32(Some(1.25)), "1.25"),
(C::F64(Some(1.25)), "1.25"),
(C::Bit(Some(true)), "true"),
(C::String(Some(Cow::Borrowed("hello"))), "hello"),
(C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"),
(
C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())),
"abcdef01-2345-6789-abcd-ef0123456789",
),
(C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"),
(C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"),
(
C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))),
"99999999999999999999999999999999999999",
),
(C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("<a/>")))), "<a/>"),
(C::Date(Some(Date::new(0))), "0001-01-01"),
(C::Date(Some(Date::new(3_652_058))), "9999-12-31"),
(C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"),
(C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"),
(C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"),
(C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"),
(C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"),
];
for (cell, expected) in cases {
assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}");
}
for cell in [
C::U8(None),
C::I16(None),
C::I32(None),
C::I64(None),
C::F32(None),
C::F64(None),
C::Bit(None),
C::String(None),
C::Guid(None),
C::Binary(None),
C::Numeric(None),
C::Xml(None),
C::Date(None),
C::Time(None),
C::DateTime(None),
C::SmallDateTime(None),
C::DateTime2(None),
C::DateTimeOffset(None),
] {
assert_eq!(tiberius_cell(&cell, T::Null), None);
}
for ty in [T::Money, T::Money4] {
for (v, expected) in
[(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")]
{
assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected));
}
assert_eq!(tiberius_cell(&C::F64(None), ty), None);
}
}
fn prop(name: &str, value: &str) -> DbProperty {
DbProperty { name: name.into(), value: value.into() }
}
@@ -1070,35 +649,4 @@ mod tests {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
}
#[test]
fn only_bytes_typed_values_are_binary() {
// (type name as the driver reports it, the driver reads it as text, binary)
let cases = [
("VARBINARY", false, true),
("BLOB", false, true),
("LONGBLOB", false, true),
("BINARY", false, true),
// MySQL catalog columns (`utf8mb3_bin`) and `utf8mb4_bin` text: flagged BINARY.
("VARBINARY", true, false),
("BLOB", true, false),
("VARCHAR", true, false),
("TEXT", true, false),
("BYTEA", false, false),
("INT", false, false),
];
for (name, text, binary) in cases {
assert_eq!(binary_type(name, text), binary, "{name} text={text}");
}
}
#[test]
fn catalog_bytes_are_names_and_grid_bytes_are_hex() {
// `oi_数据` as MySQL sends it under `character_set_results = binary`.
let name = "oi_数据".as_bytes().to_vec();
assert_eq!(bytes_text(name.clone(), Cells::Text), "oi_数据");
assert_eq!(bytes_text(name, Cells::Display), "oi_数据");
assert_eq!(bytes_text(vec![0xCA, 0xFE], Cells::Display), "0xCAFE");
assert_eq!(bytes_text(vec![b'a', 0xFF], Cells::Text), "a\u{FFFD}");
}
}
-8
View File
@@ -549,7 +549,6 @@ impl DbManager {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
};
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
pool.close().await;
@@ -822,13 +821,6 @@ impl DbManager {
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::SqlServer => {
let obj = format!("{}.{}", schema, name).replace('\'', "''");
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
.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() {
-3
View File
@@ -36,7 +36,6 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
}
}
@@ -60,7 +59,6 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
DbDriver::SqlServer => Ok(Vec::new()),
}
}
@@ -199,7 +197,6 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
}
#[test]
-12
View File
@@ -16,8 +16,6 @@ pub enum DbDriver {
Postgres,
/// MySQL and MariaDB.
Mysql,
/// Microsoft SQL Server.
SqlServer,
}
impl DbDriver {
@@ -26,7 +24,6 @@ impl DbDriver {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some(5432),
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
}
}
}
@@ -251,7 +248,6 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
_ => None,
}
@@ -476,14 +472,6 @@ mod tests {
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
}
#[test]
fn sqlserver_defaults() {
assert_eq!(DbDriver::SqlServer.default_port(), Some(1433));
assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer));
}
#[test]
fn parses_urls() {
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
+3 -105
View File
@@ -8,7 +8,6 @@ use specta::Type;
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
match driver {
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
@@ -53,7 +52,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -63,10 +61,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1"
}
DbDriver::SqlServer => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1"
}
}
}
@@ -89,11 +83,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
DbDriver::SqlServer => 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)
),
}
}
@@ -121,16 +110,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \
ORDER BY ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT column_name, data_type, is_nullable, 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 = {s} \
AND tc.table_name = {t} 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"
),
}
}
@@ -155,13 +134,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \
ORDER BY index_name, seq_in_index"
),
DbDriver::SqlServer => format!(
"SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.name \
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
ORDER BY i.name, ic.key_ordinal"
),
}
}
@@ -199,20 +171,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
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"
}
DbDriver::SqlServer => {
"SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, c.is_nullable, \
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, \
CASE WHEN c.column_default IS NOT NULL 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 ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
}
}
@@ -244,13 +202,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000"
}
DbDriver::SqlServer => {
"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 ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
}
}
@@ -267,9 +218,6 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
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"
),
DbDriver::SqlServer => 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"
),
}
}
@@ -279,7 +227,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
}
}
@@ -303,14 +250,8 @@ pub fn table_page(
) -> 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 mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
if let Some(i) = f.find(';') {
f = &f[..i];
}
f = f.trim();
if !f.is_empty() {
sql.push_str(&format!(" WHERE {f}"));
}
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
@@ -319,14 +260,7 @@ pub fn table_page(
.collect();
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
}
if driver == DbDriver::SqlServer {
if sort.is_empty() {
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
sql
}
@@ -466,10 +400,6 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
),
DbDriver::SqlServer => format!(
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
),
}
}
@@ -508,10 +438,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::SqlServer => Some(format!(
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
}
}
@@ -633,10 +559,6 @@ mod tests {
"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");
assert_eq!(
table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500),
"SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY"
);
}
#[test]
@@ -664,30 +586,6 @@ mod tests {
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
}
#[test]
fn sqlserver_quoting_and_schema() {
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]");
assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]");
assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()"));
assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN");
assert_eq!(
schemas(DbDriver::SqlServer, true),
"SELECT schema_name FROM information_schema.schemata ORDER BY 1"
);
assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')"));
}
#[test]
fn sqlserver_introspection_queries() {
assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables"));
assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns"));
assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes"));
assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL"));
assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'"));
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
-87
View File
@@ -1,87 +0,0 @@
//! Introspection against a real MySQL / MariaDB server, skipped unless
//! `OPENIDEA_TEST_MYSQL=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager, DbOptions};
const SCHEMAS: [&str; 2] = ["oi_bin_meta", "oi_数据"];
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_MYSQL").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir, startup_script: &str) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "m".into(),
name: "mysql".into(),
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
options: DbOptions { startup_script: startup_script.into(), ..Default::default() },
..Default::default()
}])
.await
.unwrap();
db.set_password("m", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for s in SCHEMAS {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!(
"CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20) COLLATE utf8mb4_bin, raw VARBINARY(4))"
),
format!("CREATE INDEX `名字` ON `{s}`.`用户` (name)"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, 'ann', 0xCAFE)"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
}
async fn assert_metadata_is_text(db: &DbManager) -> IdeResult<()> {
let schemas = db.schemas("m").await?;
for s in SCHEMAS {
assert!(schemas.iter().any(|n| n == s), "{s} missing from {schemas:?}");
let tables = db.tables("m", s).await?;
assert_eq!(tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), ["用户"], "tables of {s}");
let d = db.table_details("m", s, "用户").await?;
let cols: Vec<_> = d.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(cols, ["id", "name", "raw"]);
assert!(d.indexes.iter().any(|i| i.name == "名字"), "{:?}", d.indexes);
assert!(db.ddl("m", s, "用户").await?.contains("CREATE TABLE `用户`"));
}
let completion = db.completion("m").await?;
let json = serde_json::to_string(&completion).unwrap();
assert!(json.contains("oi_数据") && json.contains("用户"), "{json}");
Ok(())
}
// One test: both phases share the schemas they create.
#[tokio::test]
async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
create_schemas(&db).await;
// MySQL 8 sends `utf8mb3_bin` catalog names flagged BINARY (lower_case_table_names = 0).
assert_metadata_is_text(&db).await?;
// `_bin` text is text in the grid; true binary stays hex.
let r = db.execute("m", "SELECT name, raw FROM `oi_数据`.`用户`", 10, &CancellationToken::new()).await?;
assert_eq!(r.rows, vec![vec![Some("ann".to_owned()), Some("0xCAFE".to_owned())]]);
// Every text column arrives with the `binary` collation.
let tmp = tempfile::tempdir().unwrap();
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
-97
View File
@@ -1,97 +0,0 @@
//! Live SQL Server checks, skipped unless OPENIDEA_TEST_SQLSERVER=host:port:user:password
//! names a throwaway server.
use ide_core::CancellationToken;
use ide_db::{DataSource, DbDriver, DbSslMode, Held, Pool};
use std::time::Duration;
#[tokio::test]
async fn sqlserver_values_paging_transactions_and_cancellation() {
let Ok(server) = std::env::var("OPENIDEA_TEST_SQLSERVER") else { return };
let mut parts = server.splitn(4, ':');
let source = DataSource {
driver: DbDriver::SqlServer,
host: parts.next().unwrap().into(),
port: Some(parts.next().unwrap().parse().unwrap()),
user: parts.next().unwrap().into(),
database: "tempdb".into(),
ssl: DbSslMode::Disable,
..Default::default()
};
let pool = Pool::connect(&source, parts.next(), None).await.unwrap();
let cancel = CancellationToken::new();
let mut held = Held::acquire(&pool).await.unwrap();
let table = format!("tempdb.dbo.oi_pr276_values_{}", std::process::id());
held.command(&format!("CREATE TABLE {table} (id int PRIMARY KEY, d decimal(38, 7), dt datetime2(7), guid uniqueidentifier, m money, tiny tinyint, x xml)")).await.unwrap();
held.command(&format!("INSERT INTO {table} VALUES (1, 12345678901234567890.1234567, '2024-02-29T12:34:56.1234567', 'abcdef01-2345-6789-abcd-ef0123456789', 12.3400, 255, '<a/>'), (2, NULL, NULL, NULL, NULL, NULL, NULL)")).await.unwrap();
let r = held.execute(&pool, &format!("SELECT * FROM {table} ORDER BY id"), 1, &cancel).await.unwrap();
assert!(r.has_more);
assert_eq!(
r.rows[0],
[
"1",
"12345678901234567890.1234567",
"2024-02-29 12:34:56.1234567",
"abcdef01-2345-6789-abcd-ef0123456789",
"12.3400",
"255",
"<a/>"
]
.map(|s| Some(s.to_owned()))
);
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 2"), 5, &cancel).await.unwrap();
assert!(!r.has_more);
assert!(r.rows[0][1..].iter().all(Option::is_none));
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 3"), 5, &cancel).await.unwrap();
assert_eq!(r.columns.len(), 7);
assert!(r.rows.is_empty());
let r = held.execute(&pool,
"SELECT CAST('2024-02-29T00:15:00.1234567+05:30' AS datetimeoffset(7)), CAST('2024-02-29' AS date), CAST('12:34:56.1234567' AS time(7)), CAST('2024-02-29T12:34:56.007' AS datetime), CAST('2024-02-29T12:34:00' AS smalldatetime), CAST(-12.3400 AS smallmoney)",
1, &cancel).await.unwrap();
assert_eq!(
r.rows[0],
[
"2024-02-29 00:15:00.1234567+05:30",
"2024-02-29",
"12:34:56.1234567",
"2024-02-29 12:34:56.007",
"2024-02-29 12:34:00",
"-12.3400"
]
.map(|s| Some(s.to_owned()))
);
held.command(&format!("DROP TABLE {table}")).await.unwrap();
drop(held);
// A unique permanent table lets transaction calls acquire any pooled connection.
let table = format!("tempdb.dbo.oi_pr276_{}", std::process::id());
pool.execute(&format!("CREATE TABLE {table} (id int PRIMARY KEY, v int)"), 1, &cancel).await.unwrap();
pool.transaction(&[(format!("INSERT INTO {table} VALUES (1, 10)"), true)]).await.unwrap();
for failing in [format!("INSERT INTO {table} VALUES (1, 20)"), format!("UPDATE {table} SET v = 30 WHERE id = 999")]
{
assert!(
pool.transaction(&[(format!("UPDATE {table} SET v = 99 WHERE id = 1"), true), (failing, true)])
.await
.is_err()
);
assert_eq!(pool.rows(&format!("SELECT v FROM {table}"), 1).await.unwrap()[0][0].as_deref(), Some("10"));
assert_eq!(pool.rows("SELECT @@TRANCOUNT", 1).await.unwrap()[0][0].as_deref(), Some("0"));
}
assert_eq!(pool.transaction(&[(format!("UPDATE {table} SET v = 11 WHERE id = 1"), true)]).await.unwrap(), 1);
pool.execute(&format!("DROP TABLE {table}"), 1, &cancel).await.unwrap();
let cancel = CancellationToken::new();
let stop = cancel.clone();
let task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
stop.cancel();
});
assert!(
tokio::time::timeout(Duration::from_secs(2), pool.execute("WAITFOR DELAY '00:00:10'; SELECT 1", 1, &cancel))
.await
.unwrap()
.is_err()
);
task.await.unwrap();
assert_eq!(pool.rows("SELECT 1", 1).await.unwrap()[0][0].as_deref(), Some("1"));
}
+3
View File
@@ -13,5 +13,8 @@ specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+30 -12
View File
@@ -1,16 +1,11 @@
//! Child processes of the docker CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
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};
/// How long a cancelled or timed-out child's process group (CLI plugins such as compose)
/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -25,18 +20,41 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
ide_core::process::prepare(ide_core::process::own_group(cmd));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
}
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);
ide_core::output_encoding::decode_tool_output(&buf)
String::from_utf8_lossy(&buf).into_owned()
})
}
@@ -70,11 +88,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::unavailable("docker did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -97,7 +115,7 @@ fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, s
let mut pending: Vec<u8> = Vec::new();
let flush = |pending: &mut Vec<u8>| {
if !pending.is_empty() {
let line = ide_core::output_encoding::decode_tool_output(pending).trim_end().to_string();
let line = String::from_utf8_lossy(pending).trim_end().to_string();
pending.clear();
let _ = tx.send(Event::Line(stream, line));
}
@@ -148,7 +166,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -171,7 +189,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "ide-fonts"
description = "Font families installed on this machine, for the font pickers: CoreText, DirectWrite or fontconfig"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
-541
View File
@@ -1,541 +0,0 @@
//! The font families installed on this machine, for the font pickers in
//! Settings (editor, typography fallback, terminal, UI font).
//!
//! Each platform asks the native font system for installed families: CoreText on
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! The list is read on first use and cached until explicitly refreshed; the
//! query can take a few hundred milliseconds, so callers run it off the UI and
//! command threads.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
/// One installed family. `monospaced` is the font system's own flag (fixed
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FontFamily {
pub name: String,
pub monospaced: bool,
}
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
/// The installed families, sorted case-insensitively, read on first use and
/// re-queried when `refresh` is true.
pub fn families(refresh: bool) -> Arc<[FontFamily]> {
static CACHE: Cache = Mutex::new(None);
cached(&CACHE, refresh, imp::list)
}
fn cached(cache: &Cache, refresh: bool, list: impl FnOnce(bool) -> Vec<(String, bool)>) -> Arc<[FontFamily]> {
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if !refresh && let Some(families) = guard.as_ref() {
return Arc::clone(families);
}
let families: Arc<[FontFamily]> = normalize(list(refresh)).into();
*guard = Some(Arc::clone(&families));
families
}
/// Merges the per-face records a font system reports into one entry per
/// family: trimmed, without empty or hidden (`.`-prefixed system UI) names,
/// monospaced when any face is, sorted case-insensitively.
pub fn normalize(faces: impl IntoIterator<Item = (String, bool)>) -> Vec<FontFamily> {
let mut by_key: BTreeMap<(String, String), bool> = BTreeMap::new();
for (name, mono) in faces {
let name = name.trim();
if name.is_empty() || name.starts_with('.') {
continue;
}
*by_key.entry((name.to_lowercase(), name.to_owned())).or_default() |= mono;
}
let mut out: Vec<FontFamily> = Vec::with_capacity(by_key.len());
for ((key, name), monospaced) in by_key {
// Same family under two casings (one font file each): keep the first.
if let Some(last) = out.last_mut()
&& last.name.to_lowercase() == key
{
last.monospaced |= monospaced;
continue;
}
out.push(FontFamily { name, monospaced });
}
out
}
/// Parses `fc-list -f '%{family[0]}\t%{spacing}\n'`: one line per face, the
/// spacing empty (proportional) or fontconfig's number or name for it.
pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
output
.lines()
.filter_map(|line| {
let (family, spacing) = line.split_once('\t').unwrap_or((line, ""));
let family = family.replace("\\-", "-");
let mono = match spacing.trim() {
"dual" | "mono" | "charcell" => true,
s => s.parse::<u32>().is_ok_and(|n| n >= 90),
};
(!family.trim().is_empty()).then_some((family, mono))
})
.collect()
}
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
/// files under user font directories can be read, selected by family name.
/// Includes only the nearest regular/bold faces and their optional italics.
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
#[cfg(target_os = "macos")]
{
let entries = imp::user_faces();
bundle_family(family, &entries)
}
#[cfg(not(target_os = "macos"))]
{
let _ = family;
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
}
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug)]
struct UserFace {
family: String,
path: std::path::PathBuf,
weight: u16,
italic: bool,
}
#[cfg(any(target_os = "macos", test))]
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
let path = path.canonicalize().ok()?;
roots.iter().any(|root| path.starts_with(root)).then_some(path)
}
#[cfg(any(target_os = "macos", test))]
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
let mut selected: Vec<&UserFace> = Vec::new();
for italic in [false, true] {
for weight in [400u16, 700] {
let closest = entries
.iter()
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
if let Some(face) = closest
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
{
selected.push(face);
}
}
}
selected
}
#[cfg(any(target_os = "macos", test))]
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
#[derive(Serialize)]
struct Face {
weight: u16,
style: &'static str,
offset: usize,
length: usize,
}
let mut faces = Vec::new();
let mut data = Vec::new();
let mut files = BTreeMap::new();
// Bound large CJK families to regular/bold and their nearest italic faces.
for entry in family_faces(family, entries) {
// Reject a file replaced by a symlink after enumeration.
if entry.path.canonicalize()? != entry.path {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
}
let (offset, length) = if let Some(range) = files.get(&entry.path) {
*range
} else {
let bytes = std::fs::read(&entry.path)?;
let range = (data.len(), bytes.len());
data.extend(bytes);
files.insert(entry.path.clone(), range);
range
};
faces.push(Face {
weight: entry.weight,
style: if entry.italic { "italic" } else { "normal" },
offset,
length,
});
}
if faces.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
}
let header = serde_json::to_vec(&faces)?;
let mut out = (header.len() as u32).to_le_bytes().to_vec();
out.extend(header);
out.extend(data);
Ok(out)
}
#[cfg(target_os = "macos")]
mod imp {
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
};
pub(crate) fn user_faces() -> Vec<super::UserFace> {
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
if let Some(home) = std::env::var_os("HOME") {
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
}
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
let mut seen = std::collections::BTreeSet::new();
descriptors
.cast_unchecked::<CTFontDescriptor>()
.iter()
.filter_map(|d| {
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
let path = super::user_font_path(&path, &roots)?;
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
let traits = traits.cast_unchecked::<CFString, CFType>();
let symbolic = traits
.get(kCTFontSymbolicTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0) as u32;
let weight = traits
.get(kCTFontWeightTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_f64())
.unwrap_or(0.0);
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
let weight = [
(-0.8, 100),
(-0.6, 200),
(-0.4, 300),
(0.0, 400),
(0.23, 500),
(0.3, 600),
(0.4, 700),
(0.56, 800),
(0.62, 900),
]
.into_iter()
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
.1;
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
return None;
}
Some(super::UserFace { family, path, weight, italic })
})
.collect()
}
}
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else {
return Vec::new();
};
let descriptors: &CFArray<CTFontDescriptor> = descriptors.cast_unchecked();
descriptors
.iter()
.filter_map(|d| {
let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let traits = d
.attribute(kCTFontTraitsAttribute)
.and_then(|t| t.downcast::<CFDictionary>().ok())
.and_then(|t| t.cast_unchecked::<CFString, CFType>().get(kCTFontSymbolicTrait))
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0);
Some((name, traits as u32 & CTFontSymbolicTraits::TraitMonoSpace.0 != 0))
})
.collect()
}
}
}
#[cfg(windows)]
mod imp {
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
};
use windows::core::{BOOL, Interface, w};
/// The system font collection's families, named in English when the
/// family has an English name (what CSS matches), else its first name.
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read(refresh) }.unwrap_or_default()
}
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
let mut collection = None;
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for i in 0..unsafe { collection.GetFontFamilyCount() } {
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
continue;
};
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
continue;
};
let mono = unsafe {
family.GetFirstMatchingFont(
DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
DWRITE_FONT_STYLE_NORMAL,
)
}
.ok()
.and_then(|f| f.cast::<IDWriteFont1>().ok())
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
out.push((name, mono));
}
Ok(out)
}
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
let mut index = 0u32;
let mut exists = BOOL(0);
unsafe { names.FindLocaleName(w!("en-us"), &mut index, &mut exists) }.ok()?;
if !exists.as_bool() {
index = 0;
}
let len = unsafe { names.GetStringLength(index) }.ok()? as usize;
let mut buf = vec![0u16; len + 1];
unsafe { names.GetString(index, &mut buf) }.ok()?;
Some(String::from_utf16_lossy(&buf[..len]))
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod imp {
use std::process::{Command, Stdio};
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
/// `fc-list` isn't installed.
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Command::new("fc-list")
.args(["-f", "%{family[0]}\t%{spacing}\n"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| super::parse_fc_list(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default()
}
}
#[cfg(not(any(unix, windows)))]
mod imp {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
let entries: Vec<_> = [false, true]
.into_iter()
.flat_map(|italic| {
(100..=900).step_by(100).map(move |weight| UserFace {
family: "CJK".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
})
.collect();
let selected = family_faces(" cjk ", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(400, false), (700, false), (400, true), (700, true),]
);
assert!(family_faces("unknown", &entries).is_empty());
}
#[test]
fn selects_nearest_available_weights_once_with_optional_italics() {
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
.into_iter()
.map(|(weight, italic)| UserFace {
family: "Sparse".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
.collect();
let selected = family_faces("Sparse", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(300, false), (800, false), (500, true),]
);
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
}
#[test]
fn bundle_only_reads_enumerated_family_faces() {
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let regular = root.join("regular.ttf");
let bold = root.join("bold.ttf");
std::fs::write(&regular, [1, 2, 3]).unwrap();
std::fs::write(&bold, [4, 5]).unwrap();
let entries = [
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
// A non-selected face is never opened, even if its file has disappeared.
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
];
let result = bundle_family("test nerd font", &entries).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["weight"], 400);
assert_eq!(faces[1]["weight"], 700);
assert_eq!(faces.as_array().unwrap().len(), 2);
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
// Existing files are not inputs: neither paths nor unenumerated families grant access.
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
assert!(bundle_family("Unenumerated", &entries).is_err());
assert!(user_font_path(&regular, &[]).is_none());
assert!(user_font_path(&regular, std::slice::from_ref(&root)).is_some());
// Multiple selected descriptors in a collection still transfer the file only once.
let shared = [
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
];
let result = bundle_family("Collection", &shared).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
assert_eq!(&result[4 + len..], &[1, 2, 3]);
#[cfg(unix)]
{
let link = root.join("outside.ttf");
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
}
std::fs::remove_dir_all(root).unwrap();
}
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
}
#[test]
fn normalize_merges_faces_sorts_and_drops_hidden() {
let faces = [
("Menlo", true),
("Arial", false),
("Menlo", true),
(".SF NS Mono", true),
(" ", false),
("arial", false),
("PingFang SC", false),
("Courier New", false),
("Courier New", true),
("andale mono", true),
]
.map(|(n, m)| (n.to_owned(), m));
assert_eq!(
normalize(faces),
vec![
fam("andale mono", true),
fam("Arial", false),
fam("Courier New", true),
fam("Menlo", true),
fam("PingFang SC", false),
]
);
}
#[test]
fn fc_list_spacing_marks_monospace() {
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
assert_eq!(
parse_fc_list(out),
[
("DejaVu Sans Mono", true),
("DejaVu Sans", false),
("Noto Sans Mono CJK SC", true),
("Terminus", true),
("Cantarell", false),
("Fira-Code", true),
]
.map(|(n, m)| (n.to_owned(), m))
);
}
#[test]
fn caches_until_refresh_requested() {
let cache = Mutex::new(None);
let calls = AtomicUsize::new(0);
let lister = |refresh: bool| {
let n = calls.fetch_add(1, Ordering::SeqCst);
vec![(format!("Font {n} (refresh={refresh})"), n.is_multiple_of(2))]
};
let first = cached(&cache, false, lister);
assert_eq!(&*first, &[fam("Font 0 (refresh=false)", true)]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let second = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let refreshed = cached(&cache, true, lister);
assert_eq!(&*refreshed, &[fam("Font 1 (refresh=true)", false)]);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let after_refresh = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&refreshed, &after_refresh));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// The real font system: every desktop OS ships some fonts, at least one
/// of them monospaced, and the list comes back sorted without duplicates.
#[cfg(any(target_os = "macos", windows))]
#[test]
fn lists_installed_families() {
let list = families(false);
assert!(list.len() > 20, "only {} families", list.len());
assert!(list.iter().any(|f| f.monospaced));
assert!(list.iter().any(|f| !f.monospaced));
let keys: Vec<String> = list.iter().map(|f| f.name.to_lowercase()).collect();
assert!(keys.windows(2).all(|w| w[0] < w[1]));
#[cfg(target_os = "macos")]
{
let menlo = list.iter().find(|f| f.name == "Menlo").expect("Menlo ships with macOS");
assert!(menlo.monospaced);
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
}
assert_eq!(&*families(true), &*list);
}
}
+3
View File
@@ -13,3 +13,6 @@ serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+1 -48
View File
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
}
let (repo, login) = std::thread::scope(|s| {
let repo = s.spawn(|| {
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
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"]));
@@ -278,53 +278,6 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
}
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
/// placeholders from the project's remote).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhApiRequest {
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
pub method: String,
pub endpoint: String,
/// JSON request body, sent on stdin.
pub body: Option<String>,
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
pub paginate: bool,
}
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
/// `gh api` arguments for `req`.
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
let method = req.method.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
}
let endpoint = req.endpoint.trim();
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
}
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
if req.body.is_some() {
args.push("--input".into());
args.push("-".into());
}
if req.paginate {
args.push("--paginate".into());
args.push("--slurp".into());
}
Ok(args)
}
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
let args = api_args(req)?;
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
}
/// Revisions to diff a pull request's files with, present in the local repository.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+2 -24
View File
@@ -34,12 +34,6 @@ pub struct GhRepo {
pub name_with_owner: String,
pub url: String,
pub default_branch: Option<String>,
/// Merge methods the repository allows (all of them when gh does not say).
pub merge_commit_allowed: bool,
pub squash_merge_allowed: bool,
pub rebase_merge_allowed: bool,
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
pub viewer_permission: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -158,8 +152,6 @@ pub struct PullRequestDetails {
pub base_oid: String,
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
pub mergeable: String,
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
pub merge_state_status: String,
pub is_cross_repository: bool,
pub merged_at: Option<String>,
pub closed_at: Option<String>,
@@ -250,7 +242,7 @@ impl RawSummary {
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,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
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)?;
@@ -356,7 +348,6 @@ struct RawDetails {
head_ref_oid: String,
base_ref_oid: String,
mergeable: String,
merge_state_status: String,
is_cross_repository: bool,
merged_at: Option<String>,
closed_at: Option<String>,
@@ -451,7 +442,6 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
head_oid: raw.head_ref_oid,
base_oid: raw.base_ref_oid,
mergeable: raw.mergeable,
merge_state_status: raw.merge_state_status,
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()),
@@ -544,10 +534,6 @@ struct RawRepo {
name_with_owner: String,
url: String,
default_branch_ref: Option<RawBranchRef>,
merge_commit_allowed: Option<bool>,
squash_merge_allowed: Option<bool>,
rebase_merge_allowed: Option<bool>,
viewer_permission: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -556,21 +542,13 @@ struct RawBranchRef {
name: String,
}
/// Fields requested from `gh repo view`.
pub const REPO_FIELDS: &str =
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
/// `gh repo view --json` with [`REPO_FIELDS`].
/// `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()),
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
viewer_permission: raw.viewer_permission.unwrap_or_default(),
})
}
+29 -32
View File
@@ -1,16 +1,11 @@
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
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};
/// How long a cancelled or timed-out child's process group (gh spawns git and helpers)
/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -25,13 +20,36 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
ide_core::process::prepare(ide_core::process::own_group(cmd));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
}
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();
@@ -42,30 +60,9 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
run_with_input(cmd, None, timeout, cancel)
}
/// [`run`] with `input` written to the child's stdin (then closed).
pub(crate) fn run_with_input(
mut cmd: Command,
input: Option<&str>,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<Output> {
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
prepare(&mut cmd);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = spawn(&mut cmd)?;
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
let input = input.to_owned();
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
std::thread::spawn(move || {
use std::io::Write;
let _ = stdin.write_all(input.as_bytes());
});
}
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
@@ -74,11 +71,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::unavailable("gh did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -152,7 +149,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -175,7 +172,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
kill(&mut child);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
+1 -1
View File
@@ -1 +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","mergeStateStatus":"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"}
{"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"}
-32
View File
@@ -22,7 +22,6 @@ fn pr_view() {
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.merge_state_status, "UNKNOWN");
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");
@@ -70,16 +69,6 @@ fn repo_view() {
.unwrap();
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
assert_eq!(r.default_branch.as_deref(), Some("main"));
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
assert_eq!(r.viewer_permission, "");
let r = parse_repo(
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
)
.unwrap();
assert!(r.merge_commit_allowed);
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
assert_eq!(r.viewer_permission, "WRITE");
}
#[test]
@@ -98,24 +87,3 @@ fn list_arguments() {
assert!(joined.contains("--limit 500 --state merged --author @me"));
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
}
#[test]
fn api_arguments() {
let req =
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
let req = GhApiRequest {
method: "GET".into(),
endpoint: "repos/{owner}/{repo}/labels".into(),
body: None,
paginate: true,
};
assert_eq!(
api_args(&req).unwrap(),
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
);
for bad in ["--hostname", "https://evil.example/x", ""] {
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
}
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
}
+2 -13
View File
@@ -10,7 +10,6 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
@@ -35,27 +34,17 @@ ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
tokio = { workspace = true, features = ["io-std"] }
tracing.workspace = true
tracing-subscriber.workspace = true
parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
tempfile.workspace = true
-1
View File
@@ -55,7 +55,6 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
-476
View File
@@ -1,476 +0,0 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+2 -13
View File
@@ -44,7 +44,6 @@ struct State {
project: Changes,
/// Project roots whose `postStartCommand` ran in this process.
post_started: HashSet<PathBuf>,
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
}
pub struct Environment {
@@ -79,12 +78,7 @@ impl Environment {
pub(crate) fn project_closed(&self) {
if self.project_env {
let mut state = self.state.lock();
for (_, token) in state.post_commands.drain() {
token.cancel();
}
state.project.clear();
drop(state);
self.state.lock().project.clear();
self.publish();
}
}
@@ -150,7 +144,6 @@ impl Environment {
/// a failure is a notification.
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
let cancel = self.cancel.child_token();
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
run_post_start(&cmd, &root, &sink, &cancel);
});
@@ -270,9 +263,6 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
/// to `on_line`; `Err` says how it failed.
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
if cancel.is_cancelled() {
return Err("cancelled".into());
}
let (program, args) = argv.split_first().ok_or("empty command")?;
let mut child = ide_core::process::prepare(&mut Command::new(program))
.args(args)
@@ -291,8 +281,7 @@ fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &d
for pipe in pipes.into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in BufReader::new(pipe).split(b'\n').map_while(Result::ok) {
let line = ide_core::output_encoding::decode_tool_output(&line).trim_end_matches('\r').to_owned();
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
-135
View File
@@ -1,135 +0,0 @@
//! Select the document's formatting provider before executing it on this host.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
&& settings.languages.iter().any(|l| l == language)
&& (settings.prefer || !lsp)
}
pub async fn format(
host: &Host,
window: &str,
doc: DocId,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let prettier = &settings.languages.prettier;
let language = project.languages.get(&snap.language);
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
let candidate =
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
return project.lsp.format(doc, range, options).await;
}
if options.on_save && !prettier.run_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
});
}
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_enablement_and_preference_preserve_lsp_formatters() {
let languages = ide_language::LanguageRegistry::load(None).unwrap();
let mut settings = PrettierSettings::default();
for lang in languages.all() {
for lsp in [false, true] {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
assert_eq!(
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
configured && !lsp,
"{}",
lang.id
);
}
}
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
settings.prefer = true;
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
settings.languages.clear();
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
-183
View File
@@ -1,183 +0,0 @@
//! Automatic runtime provisioning belongs to the host, not SDK discovery.
use ide_core::{CancellationToken, IdeEvent, SharedSink, TaskId};
use ide_language::LanguageServerConfig;
use ide_sdk::java::{jdtls_jdks, jdtls_min_java, jdtls_runtime, language_server_runtimes_dir};
use parking_lot::{Condvar, Mutex};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Weak};
use std::time::Duration;
#[derive(Default)]
struct State {
busy: bool,
sinks: Vec<Weak<dyn ide_core::EventSink>>,
attempted: HashSet<(PathBuf, u32)>,
}
#[derive(Default)]
struct Provisioner {
state: Mutex<State>,
changed: Condvar,
}
static PROVISIONER: LazyLock<Provisioner> = LazyLock::new(Provisioner::default);
fn needs_download(explicit: bool, suitable: bool, attempted: bool) -> bool {
!explicit && !suitable && !attempted
}
impl Provisioner {
fn subscribe(&self, sink: &SharedSink) {
let mut state = self.state.lock();
state.sinks.retain(|s| s.strong_count() > 0);
let weak = Arc::downgrade(sink);
if !state.sinks.iter().any(|s| s.ptr_eq(&weak)) {
state.sinks.push(weak);
}
}
fn emit(&self, event: IdeEvent) {
let sinks: Vec<_> = self.state.lock().sinks.iter().filter_map(Weak::upgrade).collect();
for sink in sinks {
sink.emit(event.clone());
}
}
fn run(&self, key: (PathBuf, u32), cancel: &CancellationToken, work: impl FnOnce()) {
let mut state = self.state.lock();
while state.busy && !cancel.is_cancelled() {
self.changed.wait_for(&mut state, Duration::from_millis(100));
}
if cancel.is_cancelled() || !state.attempted.insert(key) {
return;
}
state.busy = true;
drop(state);
// Even a panicking installer must release waiters and retain the attempt.
struct Flight<'a>(&'a Provisioner);
impl Drop for Flight<'_> {
fn drop(&mut self) {
self.0.state.lock().busy = false;
self.0.changed.notify_all();
}
}
let _flight = Flight(self);
work();
}
}
pub(crate) fn prepare(
cfg: &LanguageServerConfig,
data_dir: &Path,
explicit: bool,
sink: &SharedSink,
launch_cancel: &CancellationToken,
) {
let min_java = jdtls_min_java(cfg);
let jdks = jdtls_jdks(min_java, data_dir);
// A launch environment override is explicit too, even if it is invalid.
if !needs_download(explicit || cfg.env.contains_key("JAVA_HOME"), jdtls_runtime(&jdks, min_java).is_some(), false) {
return;
}
PROVISIONER.subscribe(sink);
PROVISIONER.run((data_dir.to_path_buf(), min_java), launch_cancel, || {
if jdtls_runtime(&jdtls_jdks(min_java, data_dir), min_java).is_some() {
return;
}
let id = TaskId::next();
let title = format!("Downloading Java {min_java} language server runtime");
// Closing one project must not cancel a download other projects await.
let cancel = CancellationToken::new();
let _handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let emit = |message, fraction, done| {
PROVISIONER.emit(IdeEvent::Task { id, title: title.clone(), message, fraction, done, cancellable: !done });
};
emit(Some("Eclipse Temurin".into()), None, false);
let result = ide_tools::Tools::new(data_dir).download_java_runtime(
min_java,
&language_server_runtimes_dir(data_dir),
&cancel,
&|event| match event {
ide_tools::InstallEvent::Line { text } => emit(Some(text), None, false),
ide_tools::InstallEvent::Progress { fraction, message } => {
emit(Some(message), fraction.map(|f| f as f32), false)
}
},
);
emit(result.as_ref().err().map(ToString::to_string), Some(1.0), true);
if let Err(error) = result {
// The configuration hook retains the existing manual download action.
tracing::warn!("automatic jdtls runtime download: {error}");
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn download_decision() {
assert!(needs_download(false, false, false));
for (explicit, suitable, attempted) in [(true, false, false), (false, true, false), (false, false, true)] {
assert!(!needs_download(explicit, suitable, attempted));
}
}
#[test]
fn simultaneous_projects_share_one_attempt_and_failures_do_not_retry() {
let provisioner = Provisioner::default();
let calls = AtomicUsize::new(0);
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
barrier.wait();
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
});
});
}
});
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || panic!("retried"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn different_versions_are_still_single_flight() {
let provisioner = Provisioner::default();
let active = AtomicUsize::new(0);
let calls = AtomicUsize::new(0);
std::thread::scope(|scope| {
for feature in [25, 26, 27] {
let provisioner = &provisioner;
let active = &active;
let calls = &calls;
scope.spawn(move || {
provisioner.run((PathBuf::from("/data"), feature), &CancellationToken::new(), || {
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
std::thread::sleep(Duration::from_millis(10));
calls.fetch_add(1, Ordering::SeqCst);
active.fetch_sub(1, Ordering::SeqCst);
});
});
}
});
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn cancelled_waiter_does_not_consume_an_attempt() {
let provisioner = Provisioner::default();
let cancel = CancellationToken::new();
cancel.cancel();
provisioner.run((PathBuf::from("/data"), 25), &cancel, || panic!("cancelled"));
assert!(provisioner.state.lock().attempted.is_empty());
}
}
-429
View File
@@ -1,429 +0,0 @@
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
use ide_core::event::NotifyLevel;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_lsp::LspManager;
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "kotlin-bridge";
const COMMAND: &str = "openidea.kotlin.replace";
#[derive(Default)]
struct Revision {
generation: u64,
cancel: CancellationToken,
}
impl Revision {
fn change(&mut self) {
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct MavenOptions {
offline: bool,
profiles: Vec<String>,
}
pub(crate) struct KotlinBridge {
root: PathBuf,
cache: PathBuf,
bundle: Option<PathBuf>,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
revision: Mutex<Revision>,
changed: Notify,
options: Mutex<MavenOptions>,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl KotlinBridge {
pub(crate) fn new(
root: PathBuf,
cache: PathBuf,
data: &Path,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
Arc::new(Self {
root,
cache,
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
settings,
lsp,
sink,
trusted,
lifetime: CancellationToken::new(),
revision: Mutex::new(Revision::default()),
changed: Notify::new(),
options: Mutex::new(options),
worker: Mutex::new(None),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let bridge = self.clone();
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
self.schedule();
}
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
let options = MavenOptions { offline, profiles };
if let Ok(bytes) = serde_json::to_vec(&options) {
let _ = std::fs::create_dir_all(&self.cache);
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
}
*self.options.lock() = options;
self.schedule();
}
pub(crate) async fn shutdown(&self) {
self.stop();
let worker = self.worker.lock().take();
if let Some(worker) = worker {
let _ = worker.await;
}
}
pub(crate) fn schedule(&self) {
if self.lifetime.is_cancelled() {
return;
}
self.revision.lock().change();
self.changed.notify_one();
}
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
let settings = self.settings.effective();
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
self.schedule();
}
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.revision.lock().cancel.cancel();
}
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
if response.as_u64().is_none() {
return Err(ide_core::IdeError::unavailable(
"Kotlin bridge command did not acknowledge its library snapshot",
));
}
Ok(())
}
async fn run(self: Arc<Self>) {
let mut diagnostics = BTreeSet::new();
let mut last_failure = String::new();
let mut initialized = false;
// Also retain directories from unacknowledged replacements: JDT may have applied them.
let mut published = Vec::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let state = self.revision.lock();
(state.generation, state.cancel.clone())
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let root = self.root.clone();
let cache = self.cache.clone();
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
let Ok((mut model, enabled)) = has_sources else { continue };
if !self.revision.lock().current(generation) {
continue;
}
if !enabled {
if self.cache.join("kotlin-bridge").exists()
&& let Err(error) = self.replace(&[], &cancel).await
{
if !cancel.is_cancelled() {
tracing::warn!("Kotlin library withdrawal: {error}");
}
continue;
}
for old in published.drain(..) {
remove_directory(old).await;
}
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
continue;
}
if let Some(maven) = &mut model.maven {
let options = self.options.lock();
for module in &mut maven.projects {
if options.offline {
module.options.push("-o".into());
}
if !options.profiles.is_empty() {
module.options.push(format!("-P{}", options.profiles.join(",")));
}
}
}
let Some(bundle) = self.bundle.clone() else {
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
continue;
};
// Clear entries persisted by an earlier incarnation only before our first build.
if !initialized {
if let Err(error) = self.replace(&[], &cancel).await {
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
continue;
}
initialized = true;
}
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
let staging = directory.clone();
let root = self.root.clone();
let token = cancel.clone();
let result =
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
.await;
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
remove_directory(directory).await;
continue;
}
match result {
Ok(Ok(Outcome { success: true, projects, .. })) => {
match self.replace(&projects, &cancel).await {
Ok(()) => {
for old in published.drain(..) {
remove_directory(old).await;
}
published.push(directory);
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
}
Err(error) => {
// Keep both snapshots until an acknowledged replacement.
published.push(directory);
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
}
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
};
self.failure(text, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
}
}
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
for path in std::mem::take(paths) {
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
}
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
let path = self.root.join("pom.xml");
paths.insert(path.clone());
self.sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: SOURCE.into(),
items: vec![Diagnostic {
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
severity: Severity::Error,
message: text.clone(),
source: Some(SOURCE.into()),
server: Some(SOURCE.into()),
code: None,
unnecessary: false,
deprecated: false,
}],
});
if previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
body: Some(text.clone()),
});
*previous = text;
}
}
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|| path.components().any(|c| c.as_os_str() == ".mvn");
let Ok(relative) = path.strip_prefix(root) else {
// Content roots outside the project only contribute Kotlin sources.
return path.extension().is_some_and(|e| e == "kt");
};
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
// so a build cannot schedule itself.
if relative.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
)
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
{
return false;
}
if input {
return true;
}
if path.extension().is_some() {
return false;
}
// Works for deleted directories too; the module POM remains on disk.
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "src")
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
})
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
async fn remove_directory(path: PathBuf) {
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
}
impl DocumentObserver for KotlinBridge {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_changed(std::iter::once(&path.to_path_buf()));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_package_directory_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
}
#[test]
fn deleted_source_package_triggers_unless_excluded() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("module")).unwrap();
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
let path = dir.path().join("module/src/main/kotlin/pkg");
let mut settings = ide_core::settings::Settings::default();
assert!(relevant_change(dir.path(), &path, &settings));
settings.project_structure.excluded.push("module/src/main/kotlin".into());
assert!(!relevant_change(dir.path(), &path, &settings));
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
}
#[tokio::test(start_paused = true)]
async fn burst_saves_cancel_the_pending_debounce() {
let mut state = Revision::default();
state.change();
let token = state.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for _ in 0..20 {
state.change();
}
assert!(!waiting.await.unwrap());
let current = state.generation;
assert!(debounce(&state.cancel, &lifetime).await);
assert!(state.current(current));
lifetime.cancel();
assert!(!debounce(&state.cancel, &lifetime).await);
}
#[test]
fn superseded_and_cancelled_builds_cannot_publish() {
let mut state = Revision::default();
state.change();
let first = state.generation;
let token = state.cancel.clone();
state.change();
assert!(token.is_cancelled());
assert!(!state.current(first));
assert!(state.current(state.generation));
state.cancel.cancel();
assert!(!state.current(state.generation));
}
}
+9 -158
View File
@@ -3,19 +3,13 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod project;
mod proxy;
mod requests;
pub mod rpc;
pub mod shell_env;
pub mod trust;
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
@@ -34,10 +28,7 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -51,16 +42,11 @@ pub struct HostConfig {
}
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
@@ -79,35 +65,21 @@ pub struct Host {
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
environment: environment::Environment::new(project_environment, data_dir.clone()),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
)),
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
processes: ProcessManager::new(),
@@ -116,13 +88,11 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
projects: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
host.apply_proxy();
host
@@ -147,10 +117,6 @@ impl Host {
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
}
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
self.project(window)?.require_trust()
}
/// The window that owns what `window` starts.
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
(self.owner)(window)
@@ -166,129 +132,26 @@ impl Host {
}
pub fn close_project(&self, window: &str) {
let project = self.projects.write().remove(window);
if let Some(project) = project {
if let Some(project) = self.projects.write().remove(window) {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
}
for service in &notebooks {
service.stop_admission();
}
let runtime = self.runtime.clone();
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
.is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
{
tokio::task::block_in_place(wait);
} else {
wait();
}
}
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
/// Called before desktop exit, which may bypass document destructors.
pub fn clear_workspace_edit_history_on_exit(&self) {
for project in self.projects() {
project.documents.clear_workspace_edit_history();
}
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
/// Stops what this host started: language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.environment.shutdown();
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
for project in projects {
project.agents.shutdown();
}
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
// outer LSP timeout must never abort it before owned children are reaped.
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.shutdown().await;
}
project.notebooks.shutdown().await;
}
let stop_language_servers = async {
for project in &projects {
project.lsp.shutdown_all().await;
}
};
if tokio::time::timeout(std::time::Duration::from_secs(10), stop_language_servers).await.is_err() {
tracing::warn!("language servers did not shut down in time");
project.lsp.shutdown_all().await;
}
}
@@ -298,19 +161,7 @@ impl Host {
self.requests.begin(id)
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
}
self.requests.end(id);
}
-814
View File
@@ -1,814 +0,0 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
use serde::Serialize;
use specta::Type;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex as AsyncMutex, watch};
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: String,
pub cell: String,
pub msg_id: String,
pub kind: String,
#[specta(type = specta_typescript::Unknown)]
pub content: serde_json::Value,
pub version: u32,
pub dirty: bool,
pub changed_cells: Vec<String>,
}
#[derive(Default)]
struct Registry {
stopped: bool,
paths: HashMap<PathBuf, String>,
documents: HashMap<String, Arc<ManagedDocument>>,
}
struct ManagedDocument {
id: String,
path: PathBuf,
state: Mutex<DocumentState>,
control: AsyncMutex<()>,
}
struct DocumentState {
document: NotebookDocument,
generation: u64,
transitioning: bool,
closed: bool,
active: Option<Arc<Batch>>,
kernel_status: Option<(String, Option<String>)>,
}
struct Batch {
cancel: CancellationToken,
done: watch::Sender<bool>,
}
struct BatchGuard {
document: Arc<ManagedDocument>,
batch: Arc<Batch>,
}
pub(crate) struct ExecutionAdmission {
guard: BatchGuard,
generation: u64,
plan: Vec<(String, String)>,
}
impl Drop for BatchGuard {
fn drop(&mut self) {
let mut state = self.document.state.lock();
if state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, &self.batch)) {
if let Err(error) = state.document.set_execution_active(false) {
tracing::error!("notebook batch release: {error}");
}
state.active = None;
}
self.batch.done.send_replace(true);
}
}
pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
}
impl NotebookService {
pub fn new(runtime_dir: PathBuf, sink: SharedSink) -> Arc<Self> {
let registry = Arc::new(Mutex::new(Registry::default()));
let status_registry = registry.clone();
let status_sink = sink.clone();
let kernels = KernelManager::new(
runtime_dir,
Arc::new(move |path, status, message| {
let document = {
let registry = status_registry.lock();
if registry.stopped {
return;
}
registry.paths.get(path).and_then(|id| registry.documents.get(id)).cloned()
};
if let Some(document) = document {
let deliver = {
let mut state = document.state.lock();
if state.closed {
return;
}
state.kernel_status = Some((status.as_str().to_owned(), message.clone()));
!state.transitioning
};
if deliver {
status_sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: path.to_path_buf().into(),
state: status.as_str().to_owned(),
message,
});
}
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
let path = dunce::canonicalize(path)?;
// Serialize first load with path insertion: split views share one incarnation.
let mut registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::cancelled());
}
if let Some(id) = registry.paths.get(&path) {
return snapshot(&registry.documents[id]);
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
id.clone(),
Arc::new(ManagedDocument {
id,
path,
state: Mutex::new(DocumentState {
document,
generation: 0,
transitioning: false,
closed: false,
active: None,
kernel_status: None,
}),
control: AsyncMutex::new(()),
}),
);
Ok(initial)
}
fn resolve(&self, id: &str) -> IdeResult<Arc<ManagedDocument>> {
let registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::not_found("notebook project has closed"));
}
registry.documents.get(id).cloned().ok_or_else(|| IdeError::not_found("notebook incarnation is closed"))
}
pub fn get(&self, id: &str) -> IdeResult<NotebookSnapshot> {
snapshot(self.resolve(id)?.as_ref())
}
pub fn edit(&self, id: &str, version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.edit(version, edit)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.save()
}
pub fn undo(&self, id: &str, redo: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
if redo { state.document.redo() } else { state.document.undo() }
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
let document = self.resolve(id)?;
let batch = document.state.lock().active.clone();
if let Some(batch) = &batch {
batch.cancel.cancel();
}
// Draining makes prepareClose/save a stable checkpoint, including when
// Python catches KeyboardInterrupt or cancel races with kernel startup.
let interrupt = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
interrupt
}
async fn drain(&self, document: &ManagedDocument, batch: Option<Arc<Batch>>) -> IdeResult<()> {
let Some(batch) = batch else { return Ok(()) };
let mut done = batch.done.subscribe();
match tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done)).await {
Ok(Ok(_)) => return Ok(()),
Ok(Err(_)) => return Err(IdeError::internal("notebook execution completion channel closed")),
Err(_) => {}
}
// An uncooperative kernel must not outlive a close/reload authorization.
self.kernels.shutdown_kernel(&document.path).await?;
tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done))
.await
.map_err(|_| IdeError::unavailable("notebook execution did not stop after kernel shutdown"))?
.map_err(|_| IdeError::internal("notebook execution completion channel closed"))?;
Ok(())
}
pub async fn reload(&self, id: &str, restart: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let _control = document.control.lock().await;
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
if document.state.lock().closed {
return Err(IdeError::cancelled());
}
if restart {
self.kernels.restart(&document.path).await?;
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
document.state.lock().document.reload()
}
}
.await;
let status = {
let mut state = document.state.lock();
state.transitioning = false;
if state.closed { None } else { state.kernel_status.clone() }
};
if let Some((state, message)) = status {
self.sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: document.path.clone().into(),
state,
message,
});
}
result
}
/// Frontend disposal may arrive after project replacement has already retired this id.
pub async fn close(&self, id: &str) -> IdeResult<()> {
let document = match self.resolve(id) {
Ok(document) => document,
Err(error) if error.kind == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let _control = document.control.lock().await;
if document.state.lock().closed {
return Ok(());
}
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
self.kernels.shutdown_kernel(&document.path).await
}
.await;
if let Err(error) = result {
document.state.lock().transitioning = false;
return Err(error);
}
document.state.lock().closed = true;
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
Ok(())
}
pub(crate) fn admit(
&self,
id: &str,
cells: Vec<String>,
cancel: CancellationToken,
) -> IdeResult<ExecutionAdmission> {
admit(self.resolve(id)?, cells, cancel)
}
pub(crate) async fn execute(
self: &Arc<Self>,
admission: ExecutionAdmission,
python: PathBuf,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
let document = admission.guard.document.clone();
let service = self.clone();
// Keep admission and native execution owned if an IPC receiver disappears.
tokio::spawn(async move {
let ExecutionAdmission { guard, generation, plan } = admission;
let result =
service.run_batch(document.clone(), generation, plan, python, guard.batch.clone(), on_output).await;
drop(guard);
match result {
Ok(()) => snapshot(&document),
Err(error) => Err(error),
}
})
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
async fn run_batch(
&self,
document: Arc<ManagedDocument>,
generation: u64,
plan: Vec<(String, String)>,
python: PathBuf,
batch: Arc<Batch>,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<()> {
for (cell, source) in plan {
{
let mut state = document.state.lock();
if batch.cancel.is_cancelled() || state.closed || state.generation != generation {
return Err(IdeError::cancelled());
}
// A user may delete a queued cell while an earlier one executes.
// Never resurrect it from the captured source or a late output.
match state.document.begin_execution(&cell) {
Ok(()) => {}
Err(error) if error.kind == ErrorKind::NotFound => continue,
Err(error) => return Err(error),
}
}
let output_error = Arc::new(Mutex::new(None));
let callback_document = document.clone();
let callback_batch = batch.clone();
let callback_error = output_error.clone();
let callback_cell = cell.clone();
let channel = on_output.clone();
let result = self
.kernels
.execute(
&document.path,
&python,
&source,
batch.cancel.clone(),
Arc::new(move |output: KernelOutput| {
let event = match commit_output(
&callback_document,
&callback_batch,
generation,
&callback_cell,
output,
) {
Ok(Some(event)) => event,
Ok(None) => return,
Err(error) => {
*callback_error.lock() = Some(error);
callback_batch.cancel.cancel();
return;
}
};
let _ = channel.send(event);
}),
)
.await;
if let Some(error) = output_error.lock().take() {
return Err(error);
}
let result = result?;
if batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if result.status != "ok" {
break;
}
}
let state = document.state.lock();
if state.closed || state.generation != generation || batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Synchronously revoke admission before handing cleanup to an async runtime.
pub fn stop_admission(&self) {
let mut registry = self.registry.lock();
registry.stopped = true;
for document in registry.documents.values() {
let mut state = document.state.lock();
state.closed = true;
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
}
pub async fn restrict(&self) {
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
for document in &documents {
let mut state = document.state.lock();
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
for document in documents {
let _ = self.kernels.shutdown_kernel(&document.path).await;
let batch = document.state.lock().active.clone();
if let Err(error) = self.drain(&document, batch).await {
tracing::warn!("notebook restriction: {error}");
}
}
}
pub async fn shutdown(&self) {
self.stop_admission();
self.kernels.shutdown().await;
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
for document in documents {
let batch = document.state.lock().active.clone();
if let Err(error) = self.drain(&document, batch).await {
tracing::error!("notebook cleanup: {error}");
}
}
let mut registry = self.registry.lock();
registry.paths.clear();
registry.documents.clear();
}
}
fn mutable(state: &DocumentState) -> IdeResult<()> {
if state.closed {
Err(IdeError::not_found("notebook incarnation is closed"))
} else if state.transitioning {
Err(IdeError::unavailable("notebook lifecycle operation is in progress"))
} else {
Ok(())
}
}
fn snapshot(document: &ManagedDocument) -> IdeResult<NotebookSnapshot> {
let state = document.state.lock();
if state.closed {
return Err(IdeError::not_found("notebook incarnation is closed"));
}
Ok(state.document.snapshot())
}
fn invalidate(document: &ManagedDocument) -> IdeResult<Option<Arc<Batch>>> {
let mut state = document.state.lock();
mutable(&state)?;
state.transitioning = true;
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
Ok(state.active.clone())
}
fn execution_plan(snapshot: &NotebookSnapshot, cells: Vec<String>) -> IdeResult<Vec<(String, String)>> {
let mut selected: HashSet<_> = cells.iter().map(String::as_str).collect();
if selected.is_empty() || selected.len() != cells.len() {
return Err(IdeError::invalid("select distinct code cells to execute"));
}
let mut plan = Vec::with_capacity(selected.len());
for cell in &snapshot.cells {
if selected.remove(cell.id.as_str()) {
if cell.cell_type != "code" {
return Err(IdeError::invalid("only code cells can execute"));
}
plan.push((cell.id.clone(), cell.source.clone()));
}
}
if !selected.is_empty() {
return Err(IdeError::not_found("an execution cell no longer exists"));
}
Ok(plan)
}
fn admit(
document: Arc<ManagedDocument>,
cells: Vec<String>,
cancel: CancellationToken,
) -> IdeResult<ExecutionAdmission> {
let (batch, generation, plan) = {
let mut state = document.state.lock();
mutable(&state)?;
if state.active.is_some() {
return Err(IdeError::unavailable("notebook execution batch is already running"));
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let plan = execution_plan(&state.document.snapshot(), cells)?;
state.document.set_execution_active(true)?;
let (done, _) = watch::channel(false);
let batch = Arc::new(Batch { cancel, done });
state.active = Some(batch.clone());
(batch, state.generation, plan)
};
Ok(ExecutionAdmission { guard: BatchGuard { document, batch }, generation, plan })
}
/// Commit under the short document lock; the caller delivers only after release.
fn commit_output(
document: &ManagedDocument,
batch: &Arc<Batch>,
generation: u64,
cell: &str,
output: KernelOutput,
) -> IdeResult<Option<NotebookOutputEvent>> {
let mut state = document.state.lock();
if state.closed
|| state.generation != generation
|| !state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, batch))
{
return Ok(None);
}
let changed_cells = match state.document.record_output(cell, &output) {
Ok(changed) => changed,
Err(error) if error.kind == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
path: document.path.to_string_lossy().into_owned(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
content: output.content,
version: state.document.version(),
dirty: state.document.is_dirty(),
changed_cells,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture() -> (tempfile::TempDir, Arc<NotebookService>, NotebookSnapshot) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.ipynb");
std::fs::write(&path, serde_json::to_vec(&json!({
"nbformat": 4, "nbformat_minor": 5, "metadata": {},
"cells": [
{"id":"first","cell_type":"code","metadata":{},"source":"x = 1","outputs":[],"execution_count":null},
{"id":"second","cell_type":"code","metadata":{},"source":"print(x)","outputs":[],"execution_count":null},
{"id":"prose","cell_type":"markdown","metadata":{},"source":"notes"}
]
})).unwrap()).unwrap();
let service = NotebookService::new(dir.path().join("kernels"), Arc::new(ide_core::NullSink));
let snapshot = service.open(&path).unwrap();
(dir, service, snapshot)
}
fn output(kind: &str, content: serde_json::Value) -> KernelOutput {
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
}
#[tokio::test]
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
let (dir, service, initial) = fixture();
let alternate = dir.path().join(".").join("session.ipynb");
assert_eq!(service.open(&alternate).unwrap().id, initial.id);
let other = NotebookService::new(dir.path().join("other-runtime"), Arc::new(ide_core::NullSink));
let independent = other.open(&alternate).unwrap();
assert_ne!(initial.id, independent.id);
assert_eq!(other.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
service.close(&initial.id).await.unwrap();
let reopened = service.open(&alternate).unwrap();
assert_ne!(reopened.id, initial.id);
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
// A delayed old-incarnation close cannot dispose a new session at that path.
service.close(&initial.id).await.unwrap();
assert_eq!(service.get(&reopened.id).unwrap().id, reopened.id);
service.shutdown().await;
other.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
// A batch that never drains models a kernel that ignores interrupt and shutdown.
let admission =
service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
let error = service.close(&initial.id).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::Unavailable);
assert!(admission.guard.batch.cancel.is_cancelled());
// The backend still owns the incarnation: a reopen shares it instead of
// starting a second kernel, and the failed close left it usable.
let reopened = service.open(&dir.path().join("session.ipynb")).unwrap();
assert_eq!(reopened.id, initial.id);
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
Err(error) if error.kind == ErrorKind::Unavailable));
drop(admission);
service.close(&initial.id).await.unwrap();
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
assert_ne!(service.open(&dir.path().join("session.ipynb")).unwrap().id, initial.id);
service.shutdown().await;
}
#[test]
fn batch_admission_captures_document_order_and_retains_busy_until_drained() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cells = vec![initial.cells[1].id.clone(), initial.cells[0].id.clone()];
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
assert_eq!(
admission.plan,
vec![(initial.cells[0].id.clone(), "x = 1".into()), (initial.cells[1].id.clone(), "print(x)".into()),]
);
service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[1].id.clone(), source: "print('new source')".into() },
)
.unwrap();
assert_eq!(admission.plan[1].1, "print(x)");
assert_eq!(service.undo(&initial.id, false).unwrap_err().kind, ErrorKind::Unavailable);
admission.guard.batch.cancel.cancel();
assert!(matches!(admit(document.clone(), cells.clone(), CancellationToken::new()), Err(error)
if error.kind == ErrorKind::Unavailable));
drop(admission);
let next = admit(document, cells, CancellationToken::new()).unwrap();
assert_eq!(next.plan[1].1, "print('new source')");
}
#[test]
fn output_commits_revision_and_cross_cell_display_updates_before_delivery() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cells: Vec<_> = initial.cells[..2].iter().map(|cell| cell.id.clone()).collect();
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
let event = commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cells[0],
output(
"display_data",
json!({"data":{"text/plain":"before"},"metadata":{},"transient":{"display_id":"shared"}}),
),
)
.unwrap()
.unwrap();
let committed = service.get(&initial.id).unwrap();
assert_eq!(event.version, committed.version);
assert!(event.version > initial.version);
assert!(event.dirty);
assert_eq!(event.msg_id, "actual-parent-request");
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "before");
let updated = commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cells[1],
output(
"update_display_data",
json!({"data":{"text/plain":"after"},"metadata":{},"transient":{"display_id":"shared"}}),
),
)
.unwrap()
.unwrap();
assert!(updated.changed_cells.contains(&cells[0]));
let committed = service.get(&initial.id).unwrap();
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "after");
assert!(committed.cells[0].outputs[0].get("transient").is_none());
assert_eq!(updated.version, committed.version);
}
#[test]
fn invalidated_or_completed_batches_cannot_commit_and_deleted_cells_stay_deleted() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cell = initial.cells[0].id.clone();
let admission = admit(document.clone(), vec![cell.clone()], CancellationToken::new()).unwrap();
let deleted =
service.edit(&initial.id, initial.version, NotebookEdit::DeleteCell { cell: cell.clone() }).unwrap();
assert!(
commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cell,
output("stream", json!({"name":"stdout","text":"late"}))
)
.unwrap()
.is_none()
);
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
let other = initial.cells[1].id.clone();
invalidate(&document).unwrap();
assert!(admission.guard.batch.cancel.is_cancelled());
assert!(
commit_output(
&document,
&admission.guard.batch,
admission.generation,
&other,
output("stream", json!({"name":"stdout","text":"old generation"}))
)
.unwrap()
.is_none()
);
let batch = admission.guard.batch.clone();
let generation = document.state.lock().generation;
drop(admission);
assert!(
commit_output(
&document,
&batch,
generation,
&other,
output("stream", json!({"name":"stdout","text":"post idle"}))
)
.unwrap()
.is_none()
);
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
}
#[tokio::test]
async fn reload_preserves_incarnation_and_project_shutdown_revokes_all_admission() {
let (_dir, service, initial) = fixture();
let changed = service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed".into() },
)
.unwrap();
let reloaded = service.reload(&initial.id, false).await.unwrap();
assert_eq!(reloaded.id, initial.id);
assert!(reloaded.version > changed.version);
assert_eq!(reloaded.cells[0].source, "x = 1");
assert!(!reloaded.dirty);
let document = service.resolve(&initial.id).unwrap();
let admission = admit(document.clone(), vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
service.stop_admission();
assert!(admission.guard.batch.cancel.is_cancelled());
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
assert!(matches!(admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()), Err(error)
if error.kind == ErrorKind::NotFound));
drop(admission);
service.shutdown().await;
}
#[test]
fn invalid_batch_selection_does_not_claim_admission() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
for cells in [
vec![],
vec![initial.cells[0].id.clone(), initial.cells[0].id.clone()],
vec![initial.cells[2].id.clone()],
vec!["deleted".into()],
] {
assert!(admit(document.clone(), cells, CancellationToken::new()).is_err());
}
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(matches!(admit(document.clone(), vec![initial.cells[0].id.clone()], cancelled), Err(error)
if error.kind == ErrorKind::Cancelled));
let valid = admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
assert_eq!(valid.plan[0].1, "x = 1");
}
#[tokio::test]
async fn interrupt_cancels_before_drain_and_does_not_release_a_live_batch() {
let (_dir, service, initial) = fixture();
service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed source".into() },
)
.unwrap();
let cancel = CancellationToken::new();
let admission = service.admit(&initial.id, vec![initial.cells[0].id.clone()], cancel.clone()).unwrap();
let interrupt_service = service.clone();
let id = initial.id.clone();
let interrupt = tokio::spawn(async move { interrupt_service.interrupt(&id).await });
tokio::time::timeout(Duration::from_secs(1), cancel.cancelled()).await.unwrap();
assert!(!interrupt.is_finished(), "interrupt must wait for owned execution to drain");
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
Err(error) if error.kind == ErrorKind::Unavailable));
let mut done = admission.guard.batch.done.subscribe();
drop(admission);
assert!(*done.borrow_and_update());
tokio::time::timeout(Duration::from_secs(1), interrupt).await.unwrap().unwrap().unwrap();
let next = service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
assert_eq!(next.plan[0].1, "changed source");
drop(next);
let undone = service.undo(&initial.id, false).unwrap();
assert_eq!(undone.cells[0].source, "x = 1");
service.close(&initial.id).await.unwrap();
}
}
+5 -76
View File
@@ -17,7 +17,6 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
}
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
host.require_trust(window)?;
let mut providers = host.project(window)?.agents.providers();
ide_agent::registry::mark_updates(&registry_paths(host), &mut providers);
Ok(providers)
@@ -25,7 +24,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
RegistryPaths::new(&host.config_dir, &host.data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
@@ -63,21 +62,13 @@ pub async fn registry_install(
.await
.map_err(join_err);
host.end_request(request);
result??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
result?
}
/// Removes a registry agent: its agent file and installed files.
pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> {
let paths = registry_paths(host);
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)?
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
@@ -96,20 +87,12 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let requested = request.project_root.take();
request.project_root = Some(IpcPath(
host.blocking(move || validate_project_root(&root, requested.as_ref().map(|p| p.0.as_path()))).await?,
));
project.agents.create(request).await
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
host.project(window)?.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.prompt(&task, prompt, now).await
}
@@ -120,31 +103,26 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
/// Sends a queued message now, interrupting the current turn.
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_config(&task, &option, value).await
}
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.fork(&task, turns).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
host.project(window)?.agents.rollback(&task, turns).await
}
/// What the agent reports about itself when it starts, without opening a session.
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
host.require_trust(window)?;
host.project(window)?.agents.probe(&provider, refresh).await
}
@@ -155,7 +133,6 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.respond(&task, permission, option)
}
@@ -163,23 +140,16 @@ pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.archive(&task)
}
pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.unarchive(&task)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
}
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
host.require_trust(window)?;
host.project(window)?.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
@@ -213,7 +183,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
@@ -222,43 +191,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
let Some(requested) = requested else {
return Ok(root.to_path_buf());
};
if !requested.is_absolute() {
return Err(IdeError::invalid("Choose an absolute project root"));
}
let requested = dunce::canonicalize(requested)?;
let window_root = dunce::canonicalize(root)?;
if requested == window_root
|| ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.projects.contains(&requested))
{
return Ok(requested);
}
Err(IdeError::invalid("Agent project must be the window project or a workspace member"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
let member = tmp.path().join("member");
let other = tmp.path().join("other");
for dir in [&root, &member, &other, &member.join("child")] {
std::fs::create_dir_all(dir).unwrap();
}
assert_eq!(validate_project_root(&root, None).unwrap(), root);
assert_eq!(validate_project_root(&root, Some(&root)).unwrap(), dunce::canonicalize(&root).unwrap());
assert!(validate_project_root(&root, Some(&member)).is_err());
ide_workspace::workspace::Workspace::save(&root, "Workspace", vec![member.clone()], true).unwrap();
assert_eq!(validate_project_root(&root, Some(&member)).unwrap(), dunce::canonicalize(&member).unwrap());
for rejected in [other, member.join("child"), member.join("../other"), "relative".into()] {
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
}
}
}
+3 -4
View File
@@ -39,13 +39,12 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, snap.text));
}
let loaded = project.documents.load_file(path).ok()?;
let loaded = ide_vfs::load_text(path).ok()?;
if loaded.binary || loaded.large || loaded.too_large {
return None;
}
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
let language = languages.detect(path, text.lines().next());
Some((language, text))
let language = languages.detect(path, loaded.text.lines().next());
Some((language, loaded.text))
}
// ---- Inspect Code ----
+10 -97
View File
@@ -27,12 +27,7 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> B
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mut model = ide_build::load_linked(
root,
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
);
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
let mvn = ide_sdk::apply::maven_command(root, settings);
@@ -46,8 +41,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
if let Some(g) = &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = java_env(jdk.as_ref(), g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -68,7 +62,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
let hint = missing(g.java_version);
explain(&mut g.sync_error, hint);
}
model
@@ -76,7 +70,6 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
@@ -94,11 +87,9 @@ pub async fn build_sync(
profiles: Vec<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
let token = host.begin_project_request(window, request);
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -116,8 +107,6 @@ pub async fn build_sync(
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
@@ -126,37 +115,12 @@ pub async fn build_sync(
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
Ok(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
{
bridge.configure_maven(offline, bridge_profiles);
}
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
result?
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
@@ -210,10 +174,8 @@ pub async fn maven_exclude_dependency(
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit = WorkspaceEdit {
files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }],
..Default::default()
};
let edit =
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
apply_workspace_edit(&project.documents, &edit)?;
}
Some(Exclusion::Present) => {}
@@ -238,12 +200,10 @@ pub async fn build_run(
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let config_dir = host.config_dir.clone();
let token = host.begin_project_request(window, request);
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
@@ -252,23 +212,7 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
}
}
let mut plan = combined.ok_or_else(|| IdeError::invalid("No workspace project to build"))?;
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
};
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
@@ -317,34 +261,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
let mut settings = Settings::default();
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(default.command, wrapper.to_string_lossy());
settings.languages.java.gradle.jvm = "jdk21".into();
settings.languages.java.gradle.user_home = "user-home".into();
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
settings.languages.java.gradle.home = "gradle-install".into();
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(
model.command,
root.join("gradle-install")
.join("bin")
.join(if cfg!(windows) { "gradle.bat" } else { "gradle" })
.to_string_lossy()
);
assert_eq!(model.env["JAVA_HOME"], root.join("jdk21").to_string_lossy());
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
}
}
+1 -3
View File
@@ -16,7 +16,6 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
@@ -27,8 +26,7 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
}
/// Reads the report a coverage run left behind.
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
host.require_trust(window)?;
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
}
+3 -145
View File
@@ -1,5 +1,3 @@
mod output;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DebugSessionId, IdeResult};
@@ -8,105 +6,18 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
&& let Ok(project) = host.project(window)
{
super::python::prepare_debug(&mut config, &super::python::paths(&project))?;
}
let label = window;
let (sink, flush_output) = output::coalescing_sink(host.window_sink(label));
let owner = SessionOwner::new(host.owner_window(label), sink);
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
let settings = match host.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
if config.adapter == "delve" && config.request == "launch" {
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
Ok(config)
})
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
.blocking(move || {
if let Some(jdk) = ide_sdk::apply::jdtls_jdk(&runtime_root, &runtime_settings)
.map_err(ide_core::IdeError::unavailable)?
{
if jdk.major >= 11 {
return Ok(jdk);
}
return Err(ide_core::IdeError::unavailable(
"Kotlin debugger needs JDK 11 or newer. Choose a newer language server JDK in Settings.",
));
}
let jdks = ide_sdk::java::jdtls_jdks(11, &data_dir);
ide_sdk::java::jdtls_runtime(&jdks, 11).cloned().ok_or_else(|| {
ide_core::IdeError::unavailable(
"Kotlin debugger needs JDK 11 or newer. Download or choose a language server JDK in Settings.",
)
})
})
.await?;
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
.unwrap_or_else(|| serde_json::json!({}));
extra["__adapterJavaHome"] = serde_json::json!(runtime.home);
config.extra_json = Some(extra.to_string());
}
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
Some(path.to_string_lossy().into_owned())
};
host.require_trust(window)?;
let result = host.debugger.start_with(config, owner, custom).await;
flush_output();
result
}
/// A JVM launched by java-debug writes what java-debug decodes its output as
/// (see [`ide_core::output_encoding::java_debug_vm_args`]).
fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
if config.adapter != "java-debug" || config.request != "launch" {
return config;
}
let Some(serde_json::Value::Object(mut extra)) =
config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok())
else {
return config;
};
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
let encoding = extra.get("encoding").and_then(|v| v.as_str());
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
config
host.debugger.start_with(config, owner, custom).await
}
pub async fn debug_set_breakpoints(
@@ -315,12 +226,11 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
host: &Host,
window: &str,
_window: &str,
request: ide_core::RequestId,
jvm_host: String,
port: u16,
) -> IdeResult<u16> {
host.require_trust(window)?;
let token = host.begin_request(request);
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
host.end_request(request);
@@ -445,55 +355,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
);
}
#[cfg(test)]
mod tests {
use super::*;
fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "DromaraApplication".into(),
adapter: adapter.into(),
request: request.into(),
program: None,
args: vec![],
cwd: None,
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
extra_json: Some(extra.to_string()),
}
}
fn vm_args(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone()
}
#[test]
fn java_debug_launches_on_windows_write_utf8() {
let extra =
serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1});
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
assert_eq!(
vm_args(&fixed),
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
"org.dromara.DromaraApplication"
);
// Attaching, other adapters and other platforms are left alone.
for (adapter, request, windows) in
[("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)]
{
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
-182
View File
@@ -1,182 +0,0 @@
use ide_core::event::DebugEvent;
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: Duration = Duration::from_millis(50);
const MAX_BYTES: usize = 64 * 1024;
#[derive(Default)]
struct Pending {
sessions: HashMap<DebugSessionId, (String, String)>,
scheduled: bool,
}
fn flush(pending: &mut Pending, sink: &SharedSink, session: DebugSessionId) {
if let Some((category, output)) = pending.sessions.remove(&session) {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
/// All debug output sources (including build output and child sessions) share this
/// bounded batch before reaching desktop or remote IPC. Control events flush
/// their session first, so a stop or exit cannot overtake preceding output.
pub(super) fn coalescing_sink(sink: SharedSink) -> (SharedSink, impl Fn()) {
let pending = Arc::new(Mutex::new(Pending::default()));
let flush_pending = {
let pending = pending.clone();
let sink = sink.clone();
move || {
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
};
let runtime = tokio::runtime::Handle::current();
let output: SharedSink = Arc::new(move |event| {
let mut state = pending.lock();
match event {
IdeEvent::Debug { session, event: DebugEvent::Output { category, output } } => {
if output.is_empty() {
return;
}
if state.sessions.get(&session).is_some_and(|(c, _)| *c != category) {
flush(&mut state, &sink, session);
}
let mut rest = output.as_str();
while !rest.is_empty() {
let (_, text) = state.sessions.entry(session).or_insert_with(|| (category.clone(), String::new()));
let mut end = rest.len().min(MAX_BYTES - text.len());
while !rest.is_char_boundary(end) {
end -= 1;
}
text.push_str(&rest[..end]);
rest = &rest[end..];
if text.len() == MAX_BYTES || !rest.is_empty() {
flush(&mut state, &sink, session);
}
}
if !state.scheduled && !state.sessions.is_empty() {
state.scheduled = true;
let pending = pending.clone();
let sink = sink.clone();
runtime.spawn(async move {
tokio::time::sleep(WINDOW).await;
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
state.scheduled = false;
});
}
}
IdeEvent::Debug { session, event } => {
flush(&mut state, &sink, session);
sink.emit(IdeEvent::Debug { session, event });
}
event => sink.emit(event),
}
});
(output, flush_pending)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
(coalescing_sink(Arc::new(move |event| saved.lock().push(event))).0, events)
}
fn output(sink: &SharedSink, session: u32, category: &str, output: &str) {
sink.emit(IdeEvent::Debug {
session: DebugSessionId(session),
event: DebugEvent::Output { category: category.into(), output: output.into() },
});
}
#[tokio::test(start_paused = true)]
async fn explicit_start_completion_flush_does_not_duplicate_output() {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
let (sink, flush) = coalescing_sink(Arc::new(move |event| saved.lock().push(event)));
output(&sink, 1, "console", "build failed\n");
flush();
assert_eq!(events.lock().len(), 1);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
assert_eq!(events.lock().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn batches_per_session_and_flushes_idle_output() {
let (sink, events) = setup();
for _ in 0..500 {
output(&sink, 1, "stdout", "line\n");
}
output(&sink, 2, "stdout", "other\n");
assert!(events.lock().is_empty());
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 2);
assert!(events.iter().any(|event| matches!(event,
IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Output { output, .. } }
if output == &"line\n".repeat(500))));
}
#[tokio::test]
async fn categories_and_control_events_keep_order() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "out");
output(&sink, 1, "stderr", "err");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[0], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stdout" && output == "out"));
assert!(matches!(&events[1], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stderr" && output == "err"));
assert!(matches!(&events[2], IdeEvent::Debug { event: DebugEvent::Terminated, .. }));
}
#[tokio::test(start_paused = true)]
async fn terminating_one_session_preserves_other_pending_output() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "first");
output(&sink, 2, "stdout", "second");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
assert_eq!(events.lock().len(), 2);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[2],
IdeEvent::Debug { session: DebugSessionId(2), event: DebugEvent::Output { output, .. } }
if output == "second"));
}
#[tokio::test]
async fn large_unicode_events_are_bounded_without_loss() {
let (sink, events) = setup();
let text = "日志\n".repeat(100_000);
output(&sink, 1, "stdout", &text);
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let mut combined = String::new();
for event in events.lock().iter() {
if let IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } = event {
assert!(output.len() <= MAX_BYTES);
combined.push_str(output);
}
}
assert_eq!(combined, text);
}
}
-124
View File
@@ -1,124 +0,0 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
&format!("https://index.crates.io/{path}"),
Some(16 * 1024 * 1024),
cancel,
)?;
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
}
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
window: &str,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dependency in cargo::dependencies(&snap.text) {
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
continue;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
let update = cargo::update(&dependency.requirement, &versions);
out.push(DependencyVersions { dependency, versions, update });
}
Ok(out)
})
.await;
host.end_request(request);
result
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("dependency_versions", |c| async move {
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use std::sync::Arc;
struct Source;
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["1.10.0".into(), "0.2.1".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
let path = root.join("Cargo.toml");
std::fs::write(&path, text).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
for package in ["tokio", "serde"] {
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
}
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
assert!(all[1].update.is_none());
let offset = all[1].dependency.from;
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
assert_eq!(at_cursor.len(), 1);
assert_eq!(at_cursor[0].dependency.package, "serde");
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
host.cancel_request(RequestId(413));
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
let other = root.join("other.toml");
std::fs::write(&other, text).unwrap();
let info = project.documents.open(&other).unwrap();
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
}
Loaded 100 of 1501 files, more files were not shown because too many files have changed in this diff. Show more