mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9944d13314 | ||
|
|
7aad01b035 | ||
|
|
2613e61ebf | ||
|
|
971760f858 | ||
|
|
ad5f827673 | ||
|
|
dd8c8b8993 | ||
|
|
35ba957893 | ||
|
|
e272131823 | ||
|
|
d5fe771eed | ||
|
|
14c7821cf4 | ||
|
|
73dd9fdf65 | ||
|
|
091c25351b | ||
|
|
81fe384f4f | ||
|
|
01defdefe4 | ||
|
|
5bab140968 | ||
|
|
33d8d87ca2 | ||
|
|
669595e045 | ||
|
|
9c31a27e30 | ||
|
|
f265e636e9 | ||
|
|
47bd21e315 | ||
|
|
a5b7c154e1 | ||
|
|
b2c183dbd4 | ||
|
|
bc70ec7028 | ||
|
|
195356c4b1 | ||
|
|
67e3c60db8 | ||
|
|
f87d153caf | ||
|
|
552a43a0a1 | ||
|
|
2b1add64db | ||
|
|
d277fc1522 | ||
|
|
b983e3d0b2 | ||
|
|
ce981e4fd6 | ||
|
|
19b96b60a4 | ||
|
|
9295a8f78a | ||
|
|
f507f55d16 | ||
|
|
86cf348310 | ||
|
|
a1294598a5 | ||
|
|
6669df8313 | ||
|
|
83eb1ce2ba | ||
|
|
6356e85671 | ||
|
|
ed6092a4e9 | ||
|
|
12f676e0c0 | ||
|
|
1ee22dbf35 | ||
|
|
7ba75c814c | ||
|
|
ae110f73be | ||
|
|
c3252d43c3 | ||
|
|
924446279b | ||
|
|
f88b5d6f8c | ||
|
|
ea53e4f6fa | ||
|
|
379a52d6e7 | ||
|
|
92b3ae508c | ||
|
|
d837cb1acb | ||
|
|
932ac379bc | ||
|
|
fd5908e224 | ||
|
|
e017154c75 | ||
|
|
3655961642 | ||
|
|
545fb93744 | ||
|
|
d9d1caa476 | ||
|
|
9d1fe17894 | ||
|
|
9575500dd6 | ||
|
|
0329678124 | ||
|
|
2924c0f8e5 | ||
|
|
082420b5bb | ||
|
|
9a5b44bebb | ||
|
|
2203938491 | ||
|
|
503bbb2f02 | ||
|
|
ab6fca01c4 | ||
|
|
0a27d02f4d | ||
|
|
97b8928e4f | ||
|
|
53fd3683e2 | ||
|
|
27bef6956e | ||
|
|
3563f70810 | ||
|
|
7a46774fda | ||
|
|
2ad979af66 | ||
|
|
f5a95dcc21 | ||
|
|
1ed6f7241b | ||
|
|
0d880b9d35 | ||
|
|
95068f5885 | ||
|
|
15d926a5d8 | ||
|
|
7da58637c2 | ||
|
|
b1be3c6a09 | ||
|
|
5792e7d9b6 | ||
|
|
ab7cc02561 | ||
|
|
a7b5758f5a | ||
|
|
e85ffa2ae8 | ||
|
|
2c10b63f76 | ||
|
|
44d0f40b13 | ||
|
|
f28d8da3a1 | ||
|
|
822a93abac | ||
|
|
08956b615e | ||
|
|
6c1eba9d82 | ||
|
|
cdb6cc7c0d | ||
|
|
181a2ea03e | ||
|
|
d44e2414ca | ||
|
|
3bb994062d | ||
|
|
2ea47873cf | ||
|
|
c55fd9b86e | ||
|
|
922bbf493a | ||
|
|
c0626b2728 | ||
|
|
0d368ba947 | ||
|
|
3a1e47d872 | ||
|
|
d1f84ef364 | ||
|
|
00693334f7 | ||
|
|
285c7187fb | ||
|
|
1602e4b530 | ||
|
|
5217f8570b | ||
|
|
c3b3534f08 | ||
|
|
aeec0e6759 | ||
|
|
d1a597898d | ||
|
|
94d209c99c | ||
|
|
44f01b5ce6 | ||
|
|
14d7fd0061 | ||
|
|
b9cfd54742 | ||
|
|
f6a0152488 | ||
|
|
234703c7e9 |
No files matched your search
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -69,6 +71,15 @@ 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
|
||||
@@ -82,6 +93,7 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm knip
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"src-tauri",
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
Generated
+415
-144
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.24"
|
||||
version = "0.1.28"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -40,6 +40,7 @@ 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"] }
|
||||
@@ -75,6 +76,7 @@ trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
dirs = "6"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
@@ -85,6 +87,9 @@ 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"
|
||||
@@ -92,6 +97,8 @@ 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
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -54,6 +56,15 @@ pnpm tauri dev
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -111,6 +111,18 @@ 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`
|
||||
|
||||
@@ -6,6 +6,8 @@ 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
|
||||
|
||||
@@ -147,6 +147,7 @@ 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() }],
|
||||
|
||||
@@ -37,6 +37,16 @@ pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) ->
|
||||
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> {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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};
|
||||
@@ -116,9 +117,13 @@ 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);
|
||||
// Approval bypass belongs to the conversation, never to another new task.
|
||||
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("ask".into());
|
||||
o.current = Some("bypass".into());
|
||||
}
|
||||
config
|
||||
}
|
||||
@@ -314,8 +319,8 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
|
||||
let (provider, offered, cwd) = match task {
|
||||
Some(id) => {
|
||||
@@ -325,6 +330,14 @@ 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);
|
||||
}
|
||||
@@ -353,9 +366,16 @@ 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 = self.shared.paths.project_root.clone();
|
||||
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 (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?;
|
||||
@@ -379,6 +399,7 @@ impl AgentManager {
|
||||
}
|
||||
let now = now_ms();
|
||||
let task = AgentTask {
|
||||
project_root,
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
@@ -539,6 +560,10 @@ 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();
|
||||
@@ -748,18 +773,41 @@ 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) = {
|
||||
let (isolation, cwd, root) = {
|
||||
let s = task.state.lock();
|
||||
(s.task.isolation, s.task.cwd.0.clone())
|
||||
(
|
||||
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()),
|
||||
)
|
||||
};
|
||||
// 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 root = self.shared.paths.project_root.clone();
|
||||
blocking(move || worktree::remove(&root, &cwd)).await?;
|
||||
}
|
||||
self.tasks.write().remove(id);
|
||||
@@ -1017,6 +1065,7 @@ impl Task {
|
||||
self.push_entry(AgentEntry::User {
|
||||
text: prompt.text,
|
||||
attachments,
|
||||
images: prompt.images,
|
||||
sent_ms: Some(now_ms()),
|
||||
done_ms: None,
|
||||
steered: true,
|
||||
@@ -1037,6 +1086,7 @@ 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,
|
||||
@@ -1183,7 +1233,6 @@ impl Task {
|
||||
(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 };
|
||||
@@ -1212,6 +1261,7 @@ 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());
|
||||
@@ -1629,6 +1679,16 @@ 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())
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ 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`].
|
||||
@@ -240,6 +243,9 @@ 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>,
|
||||
@@ -361,6 +367,9 @@ 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…).
|
||||
|
||||
@@ -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 = String::from_utf8_lossy(&out.stdout);
|
||||
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
|
||||
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,12 @@ fn invalid(message: impl Into<String>) -> ResponseError {
|
||||
fn from_codex(e: RpcError) -> ResponseError {
|
||||
match e {
|
||||
RpcError::Server { code, message, data } => {
|
||||
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
// 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 };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -426,10 +431,48 @@ 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"))?;
|
||||
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
|
||||
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),
|
||||
}
|
||||
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,6 +593,104 @@ 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;
|
||||
|
||||
@@ -21,18 +21,26 @@ 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 found once (or not at all), is used as it is.
|
||||
/// 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.
|
||||
pub(crate) fn newest_install(command: &str) -> String {
|
||||
if Path::new(command).components().count() > 1 {
|
||||
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();
|
||||
newest_install_in(command, &path)
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
|
||||
return command.to_owned();
|
||||
}
|
||||
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 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 mut seen = Vec::new();
|
||||
let candidates: Vec<PathBuf> = path
|
||||
.iter()
|
||||
.flat_map(std::env::split_paths)
|
||||
.map(|dir| dir.join(command))
|
||||
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)))
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| {
|
||||
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
|
||||
@@ -42,7 +50,7 @@ pub(crate) fn newest_install(command: &str) -> String {
|
||||
})
|
||||
.collect();
|
||||
if candidates.len() < 2 {
|
||||
return command.to_owned();
|
||||
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
|
||||
}
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -56,7 +64,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(&String::from_utf8_lossy(&out.stdout))
|
||||
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
|
||||
}
|
||||
|
||||
fn parse_version(text: &str) -> Option<Vec<u64>> {
|
||||
@@ -66,16 +74,6 @@ 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
|
||||
@@ -94,3 +92,56 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,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_tools::default_search_path())?;
|
||||
let http = Http::new(&ide_core::tool_path::search_path())?;
|
||||
Ok(if cfg!(test) { http } else { http.https_only() })
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,23 @@ 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 mut cmd = tokio::process::Command::new(&spec.command);
|
||||
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);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
let mut child = cmd
|
||||
.args(&spec.args)
|
||||
|
||||
@@ -56,6 +56,7 @@ 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),
|
||||
|
||||
@@ -210,10 +210,11 @@ 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: the skills on disk.
|
||||
// Before any session: only 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(),
|
||||
@@ -227,6 +228,8 @@ 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.
|
||||
@@ -239,6 +242,46 @@ 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();
|
||||
@@ -268,6 +311,7 @@ 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() }],
|
||||
@@ -374,6 +418,7 @@ 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![],
|
||||
@@ -428,6 +473,7 @@ 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![],
|
||||
@@ -521,6 +567,7 @@ 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![],
|
||||
@@ -542,6 +589,7 @@ 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![],
|
||||
@@ -587,6 +635,7 @@ 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() }],
|
||||
@@ -635,6 +684,7 @@ 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(),
|
||||
@@ -685,6 +735,7 @@ 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.
|
||||
@@ -727,6 +778,7 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
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(),
|
||||
@@ -752,15 +804,17 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
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")));
|
||||
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;
|
||||
// An unrelated new conversation still asks, even after the cache was updated.
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -770,6 +824,7 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
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() }],
|
||||
@@ -781,3 +836,109 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
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);
|
||||
}
|
||||
@@ -446,6 +446,7 @@ mod tests {
|
||||
let user = |sent: f64| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
|
||||
@@ -53,8 +53,9 @@ pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
/// 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 repo = repo_at(cwd)?;
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
|
||||
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));
|
||||
@@ -96,7 +97,12 @@ 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>> {
|
||||
repo_at(cwd)?.file_at(path, Some(rev))
|
||||
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)
|
||||
}
|
||||
|
||||
/// A short ASCII branch-safe name from the task title.
|
||||
@@ -136,6 +142,20 @@ 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, &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"])
|
||||
|
||||
@@ -234,6 +234,9 @@ 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 = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
|
||||
@@ -399,6 +399,12 @@ 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]
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
mod sync;
|
||||
pub mod web;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
@@ -39,8 +40,19 @@ 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
|
||||
});
|
||||
|
||||
@@ -401,6 +401,9 @@ 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) {
|
||||
@@ -523,6 +526,11 @@ 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();
|
||||
@@ -935,9 +943,10 @@ 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));
|
||||
|
||||
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 });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
@@ -954,7 +963,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
aggregated,
|
||||
plugins,
|
||||
dependencies,
|
||||
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
|
||||
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(root, &dir),
|
||||
options: Vec::new(),
|
||||
@@ -967,6 +976,67 @@ 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()
|
||||
@@ -1219,9 +1289,41 @@ 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();
|
||||
@@ -1459,3 +1561,45 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,22 @@ fn kill_tree(child: &mut Child) {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `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.
|
||||
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();
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills
|
||||
/// the process group and returns `cancelled` when `cancel` fires.
|
||||
pub(crate) fn run(
|
||||
@@ -48,7 +64,8 @@ pub(crate) fn run(
|
||||
// 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 mut command = Command::new(program);
|
||||
let program = program_path(program, env);
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
@@ -57,7 +74,8 @@ pub(crate) fn run(
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
|
||||
let mut child =
|
||||
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
|
||||
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 || {
|
||||
@@ -68,7 +86,7 @@ pub(crate) fn run(
|
||||
match reader.read_until(b'\n', &mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
let line = String::from_utf8_lossy(&buf);
|
||||
let line = ide_core::output_encoding::decode_tool_output(&buf);
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_owned();
|
||||
if tx.send((kind, line)).is_err() {
|
||||
break;
|
||||
|
||||
@@ -23,6 +23,7 @@ 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.
|
||||
@@ -31,6 +32,8 @@ 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>,
|
||||
}
|
||||
@@ -118,6 +121,16 @@ 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 };
|
||||
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() {
|
||||
@@ -126,9 +139,6 @@ 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`.
|
||||
@@ -198,7 +208,8 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let model = maven::load(root);
|
||||
let mut model = maven::load(root);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache =
|
||||
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
|
||||
@@ -266,7 +277,14 @@ pub fn sync_gradle(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
|
||||
let out = run(
|
||||
options.gradle_command.as_deref().unwrap_or(&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(),
|
||||
@@ -280,6 +298,29 @@ 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();
|
||||
@@ -315,12 +356,16 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
|
||||
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
|
||||
</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]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{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>,
|
||||
}
|
||||
|
||||
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()]);
|
||||
}
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.artifact.file".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() });
|
||||
}
|
||||
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 literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
|
||||
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({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(path),
|
||||
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(),
|
||||
});
|
||||
}
|
||||
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();
|
||||
assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into()));
|
||||
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"]);
|
||||
}
|
||||
#[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 }"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
@@ -16,3 +17,6 @@ tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
|
||||
@@ -13,6 +13,8 @@ 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,
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
@@ -32,10 +34,13 @@ pub enum IdeEvent {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File Type).
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
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 {
|
||||
@@ -112,11 +117,22 @@ 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.
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
|
||||
|
||||
@@ -18,6 +18,60 @@
|
||||
|
||||
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";
|
||||
@@ -237,6 +291,29 @@ impl NoticeFilter {
|
||||
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",
|
||||
|
||||
@@ -218,6 +218,25 @@ 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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -363,4 +382,23 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -178,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`.
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
pub default_project_directory: String,
|
||||
@@ -563,12 +563,37 @@ 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`.
|
||||
@@ -691,3 +716,41 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! 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();
|
||||
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_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>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_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",
|
||||
".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"];
|
||||
|
||||
/// 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")),
|
||||
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"));
|
||||
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, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
}
|
||||
@@ -213,8 +213,12 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
.output()
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
|
||||
if !out.status.success() {
|
||||
let text = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text };
|
||||
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
|
||||
};
|
||||
return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}")));
|
||||
}
|
||||
let text = read_report(lcov)?;
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
|
||||
@@ -63,6 +63,12 @@ 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",
|
||||
|
||||
@@ -70,8 +70,10 @@ 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).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
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');
|
||||
err_out(format!("{line}\n"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -563,6 +565,14 @@ 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;
|
||||
}
|
||||
@@ -722,7 +732,19 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
|
||||
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);
|
||||
}
|
||||
let caps = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -749,8 +771,32 @@ 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(&s.request, arguments)?;
|
||||
let launch = s.conn.send_request(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;
|
||||
@@ -780,6 +826,7 @@ 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()),
|
||||
}
|
||||
}
|
||||
@@ -802,7 +849,11 @@ impl DebugManager {
|
||||
let _ = self.send_exception_breakpoints(s).await;
|
||||
}
|
||||
if s.supports("supportsConfigurationDoneRequest") {
|
||||
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
drop(s.conn.send_request("configurationDone", json!({}))?);
|
||||
} else {
|
||||
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
@@ -813,6 +864,9 @@ impl DebugManager {
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -869,6 +923,7 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -886,6 +941,23 @@ 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()
|
||||
|
||||
@@ -54,6 +54,8 @@ 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
|
||||
@@ -95,6 +97,8 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +108,14 @@ 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();
|
||||
}
|
||||
@@ -118,7 +130,14 @@ 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> {
|
||||
self.resolved_paths.lock().get(Path::new(path)).cloned()
|
||||
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('\\', "/")))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn supports(&self, cap: &str) -> bool {
|
||||
@@ -236,6 +255,7 @@ 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();
|
||||
}
|
||||
@@ -245,6 +265,19 @@ 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();
|
||||
@@ -297,7 +330,10 @@ pub(crate) async fn event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body: Value) {
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
@@ -486,3 +522,74 @@ 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(())
|
||||
}
|
||||
+127
-3
@@ -15,6 +15,8 @@ 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 {
|
||||
@@ -130,7 +132,9 @@ 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" => {
|
||||
ok!(msg, Value::Null);
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
}
|
||||
@@ -139,14 +143,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}}})
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
|
||||
);
|
||||
}
|
||||
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
|
||||
"stackTrace" => ok!(
|
||||
msg,
|
||||
json!({"stackFrames": [
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}},
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
|
||||
]})
|
||||
),
|
||||
@@ -893,6 +897,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
@@ -1015,3 +1020,122 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ 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
|
||||
|
||||
|
||||
+409
-21
@@ -5,6 +5,9 @@
|
||||
//! 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;
|
||||
@@ -44,10 +47,65 @@ 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.
|
||||
@@ -57,6 +115,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -79,6 +138,129 @@ 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");
|
||||
@@ -197,6 +379,37 @@ 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) => {{
|
||||
@@ -313,6 +526,7 @@ impl Pool {
|
||||
Pool::Sqlite(_) => DbDriver::Sqlite,
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,20 +667,60 @@ 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) {
|
||||
with_pool!(self, p => p.close().await)
|
||||
match self {
|
||||
Pool::SqlServer(_) => {}
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
|
||||
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)),
|
||||
};
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -485,21 +739,47 @@ 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> {
|
||||
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")));
|
||||
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)
|
||||
}
|
||||
total += n;
|
||||
.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
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
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")));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +789,7 @@ pub enum Held {
|
||||
Sqlite(PoolConnection<Sqlite>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -517,18 +798,25 @@ 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::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(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)
|
||||
}
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
/// Closes the connection instead of returning it to the pool (its statement was
|
||||
@@ -542,6 +830,9 @@ impl Held {
|
||||
Held::Mysql(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
}
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +880,13 @@ 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;
|
||||
@@ -608,6 +906,7 @@ 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",
|
||||
}
|
||||
}
|
||||
@@ -624,6 +923,95 @@ 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() }
|
||||
}
|
||||
|
||||
@@ -549,6 +549,7 @@ 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;
|
||||
@@ -821,6 +822,13 @@ 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() {
|
||||
|
||||
@@ -36,6 +36,7 @@ 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +199,7 @@ 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]
|
||||
|
||||
@@ -16,6 +16,8 @@ pub enum DbDriver {
|
||||
Postgres,
|
||||
/// MySQL and MariaDB.
|
||||
Mysql,
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -24,6 +26,7 @@ impl DbDriver {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some(5432),
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +251,7 @@ 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,
|
||||
}
|
||||
@@ -472,6 +476,14 @@ 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();
|
||||
|
||||
+105
-3
@@ -8,6 +8,7 @@ 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('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +53,7 @@ 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') \
|
||||
@@ -61,6 +63,10 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +89,11 @@ 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)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +121,16 @@ 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +155,13 @@ 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +199,20 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +244,13 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +267,9 @@ 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +279,7 @@ 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()"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,8 +303,14 @@ 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 f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
|
||||
sql.push_str(&format!(" WHERE {f}"));
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
if !sort.is_empty() {
|
||||
let keys: Vec<String> = sort
|
||||
@@ -260,7 +319,14 @@ pub fn table_page(
|
||||
.collect();
|
||||
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
|
||||
}
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
|
||||
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
|
||||
}
|
||||
|
||||
@@ -400,6 +466,10 @@ 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +508,10 @@ 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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +633,10 @@ 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]
|
||||
@@ -586,6 +664,30 @@ 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;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 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"));
|
||||
}
|
||||
@@ -54,7 +54,7 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,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 = String::from_utf8_lossy(pending).trim_end().to_string();
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending).trim_end().to_string();
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stream, line));
|
||||
}
|
||||
|
||||
+57
-15
@@ -4,13 +4,14 @@
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read once per run and cached; the first read can take a few
|
||||
//! hundred milliseconds, so callers run it off the UI and command threads.
|
||||
//! 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::OnceLock;
|
||||
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).
|
||||
@@ -21,10 +22,23 @@ pub struct FontFamily {
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use.
|
||||
pub fn families() -> &'static [FontFamily] {
|
||||
static CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| normalize(imp::list()))
|
||||
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
|
||||
@@ -79,7 +93,7 @@ mod imp {
|
||||
};
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
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 {
|
||||
@@ -116,15 +130,15 @@ mod imp {
|
||||
|
||||
/// 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() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read() }.unwrap_or_default()
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
}
|
||||
|
||||
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
|
||||
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, false) }?;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -171,7 +185,7 @@ mod imp {
|
||||
|
||||
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
|
||||
/// `fc-list` isn't installed.
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Command::new("fc-list")
|
||||
.args(["-f", "%{family[0]}\t%{spacing}\n"])
|
||||
.stdin(Stdio::null())
|
||||
@@ -186,7 +200,7 @@ mod imp {
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
mod imp {
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +208,7 @@ mod imp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
@@ -243,12 +258,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
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));
|
||||
@@ -260,5 +301,6 @@ mod tests {
|
||||
assert!(menlo.monospaced);
|
||||
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
|
||||
}
|
||||
assert_eq!(&*families(true), &*list);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -34,17 +35,20 @@ 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
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -281,7 +281,8 @@ 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).lines().map_while(Result::ok) {
|
||||
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();
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod java_runtime;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
mod proxy;
|
||||
@@ -132,26 +134,75 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: language servers, agents, runs and terminals.
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for service in notebooks {
|
||||
service.shutdown().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) {
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, 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 {
|
||||
for project in &projects {
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
// 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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! 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(®istry.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 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();
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,14 @@ 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, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.project(window)?.agents.create(request).await
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
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
|
||||
}
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
@@ -140,6 +146,10 @@ 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.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
@@ -183,6 +193,7 @@ 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",
|
||||
@@ -191,3 +202,43 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ 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(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let mut model = ide_build::load_linked(
|
||||
root,
|
||||
cache,
|
||||
ide_sdk::apply::maven_local_repository(root, settings),
|
||||
&settings.languages.java.maven,
|
||||
);
|
||||
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);
|
||||
@@ -41,7 +46,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
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();
|
||||
}
|
||||
// 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 {
|
||||
@@ -62,7 +68,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 = missing(g.java_version);
|
||||
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
@@ -107,6 +113,8 @@ 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);
|
||||
@@ -115,12 +123,27 @@ 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(load(&root, &cache, &settings))
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
let model = result??;
|
||||
if tool == BuildTool::Maven
|
||||
&& let Some(maven) = &model.maven
|
||||
{
|
||||
// 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).
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).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)
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
@@ -203,6 +226,7 @@ pub async fn build_run(
|
||||
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_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
@@ -212,7 +236,23 @@ pub async fn build_run(
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
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 result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
@@ -261,3 +301,34 @@ 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 +1,5 @@
|
||||
mod output;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
@@ -14,17 +16,54 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
super::python::prepare_debug(&mut config, &super::python::paths(&project))?;
|
||||
}
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let (sink, flush_output) = output::coalescing_sink(host.window_sink(label));
|
||||
let owner = SessionOwner::new(host.owner_window(label), sink);
|
||||
let settings = match host.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.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.debugger.start_with(config, owner, custom).await
|
||||
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
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,14 @@ pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: C
|
||||
|
||||
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save(doc)).await
|
||||
host.blocking(move || project.documents.save(doc)).await?;
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save_all()).await
|
||||
host.blocking(move || project.documents.save_all()).await?;
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
|
||||
@@ -22,6 +22,13 @@ pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResu
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
/// without opening it: previews of closed files.
|
||||
pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.documents.ui_mode_of(&path))).await
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
@@ -108,6 +115,7 @@ pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<Strin
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@ use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
/// Builds the workspace (when `build`), then resolves the runtime (or test)
|
||||
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
|
||||
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
|
||||
@@ -17,22 +18,39 @@ pub async fn java_resolve_launch(
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
async fn resolve_launch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
|
||||
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
@@ -64,6 +82,9 @@ pub async fn java_resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
@@ -82,13 +103,14 @@ pub async fn java_build_workspace(
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
|
||||
@@ -186,8 +186,19 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
pub async fn lsp_rename(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<Renamed> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -220,11 +231,16 @@ pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeRes
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.call_hierarchy(doc, pos, direction, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
@@ -335,7 +351,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
@@ -348,7 +364,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_call_hierarchy", |c| async move {
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod history;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
@@ -60,6 +61,7 @@ impl<T> Clone for Channel<T> {
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
agent::register(d);
|
||||
lsp::register(d);
|
||||
notebook::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Native notebook operations shared by desktop IPC and the remote dispatcher.
|
||||
use crate::Host;
|
||||
pub use crate::notebook::NotebookOutputEvent;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_notebook::{NotebookEdit, NotebookSnapshot};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: String) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let path = PathBuf::from(path);
|
||||
let path = if path.is_absolute() { path } else { project.root.join(path) };
|
||||
let notebooks = project.notebooks.clone();
|
||||
host.blocking(move || notebooks.open(&path)).await
|
||||
}
|
||||
|
||||
pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.project(window)?.notebooks.get(&id)
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
version: u32,
|
||||
edit: NotebookEdit,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.edit(&id, version, edit)).await
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.save(&id)).await
|
||||
}
|
||||
|
||||
pub async fn undo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, false)).await
|
||||
}
|
||||
|
||||
pub async fn redo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, true)).await
|
||||
}
|
||||
|
||||
pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
// An abandoned IPC future must not leave a lifecycle transition half applied.
|
||||
tokio::spawn(async move { notebooks.reload(&id, false).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn close(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.close(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn interrupt(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.interrupt(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
cells: Vec<String>,
|
||||
python: Option<String>,
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
let selected = match python {
|
||||
Some(path) if path.trim().is_empty() => return Err(IdeError::invalid("select a Python interpreter")),
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => {
|
||||
let root = project.root.clone();
|
||||
let settings = project.settings.effective();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = host.blocking(move || {
|
||||
ide_sdk::apply::python_interpreter(&root, &settings)
|
||||
.or_else(|| ide_sdk::detect(ide_sdk::SdkKind::Python, Some(&root))
|
||||
.into_iter().next().map(|sdk| sdk.path.into_path_buf()))
|
||||
.ok_or_else(|| IdeError::unavailable("select a Python environment with ipykernel installed"))
|
||||
}) => result?,
|
||||
}
|
||||
}
|
||||
};
|
||||
// Do not canonicalize this path: resolving a venv symlink to base Python
|
||||
// changes sys.prefix and executes in the wrong environment.
|
||||
let python = if selected.is_absolute() { selected } else { project.root.join(selected) };
|
||||
project.notebooks.execute(admission, python, on_output).await
|
||||
}
|
||||
|
||||
struct RequestGuard<'a> {
|
||||
host: &'a Host,
|
||||
request: RequestId,
|
||||
cancel: ide_core::CancellationToken,
|
||||
}
|
||||
|
||||
impl Drop for RequestGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
// If the invocation is dropped, the owned batch still receives cancellation.
|
||||
self.cancel.cancel();
|
||||
self.host.end_request(self.request);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("notebook_edit", |c| async move {
|
||||
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
|
||||
});
|
||||
d.add("notebook_save", |c| async move { save(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_reload", |c| async move { reload(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_undo", |c| async move { undo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_redo", |c| async move { redo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_close", |c| async move { close(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_interrupt", |c| async move { interrupt(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_restart", |c| async move { restart(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_execute", |c| async move {
|
||||
execute(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("id")?,
|
||||
c.arg("cells")?,
|
||||
c.arg("python")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<ide_workspace::workspace::Workspace>,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
@@ -28,6 +30,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +56,42 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let project = host.project(window)?;
|
||||
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
project.settings.reload();
|
||||
project.refresh_index_scope();
|
||||
project.refresh_repositories();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
|
||||
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
|
||||
let project = host.project(window)?;
|
||||
let path = dunce::canonicalize(path)?;
|
||||
if project.light {
|
||||
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
|
||||
}
|
||||
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
if !workspace.projects.contains(&path) {
|
||||
workspace.projects.push(path);
|
||||
save_workspace(host, window, &workspace.name, workspace.projects)?;
|
||||
}
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
Ok(ide_workspace::workspace::Workspace::create_for_project(
|
||||
&host.data_dir,
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
&path,
|
||||
)?
|
||||
.into())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
pub struct SettingsApplied {
|
||||
pub settings: Settings,
|
||||
@@ -129,7 +168,9 @@ pub fn set_settings(
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
|
||||
@@ -9,16 +9,59 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
|
||||
let settings = project.settings.effective();
|
||||
let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed);
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
|
||||
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
|
||||
let saved = ide_run::load_user_configs(&member)?;
|
||||
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
|
||||
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
|
||||
let key = crate::project::project_key(&member);
|
||||
let name = member.file_name().unwrap_or_default().to_string_lossy();
|
||||
for mut config in configs {
|
||||
config.id = format!("workspace:{}:{}", key, config.id);
|
||||
config.name = format!("{name} / {}", config.name);
|
||||
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
|
||||
config.detected = true;
|
||||
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
|
||||
all.push(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
ide_run::save_user_configs(&host.project(window)?.root, configs)
|
||||
/// A project's detected configurations (minus removed ones and unlinked Maven projects), then
|
||||
/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved:
|
||||
/// derived defaults must not be written back as explicit environment.
|
||||
fn detected_and_saved(
|
||||
root: &std::path::Path,
|
||||
settings: &ide_core::settings::Settings,
|
||||
saved: &[RunConfig],
|
||||
suppressed: &[String],
|
||||
) -> Vec<RunConfig> {
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
|
||||
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
all.extend(saved.iter().cloned());
|
||||
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub fn save_configs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
configs: &[RunConfig],
|
||||
removed_detected_ids: &[String],
|
||||
) -> IdeResult<()> {
|
||||
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
@@ -27,6 +70,9 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
|
||||
}
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let out = on_output.clone();
|
||||
@@ -35,17 +81,41 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let settings = project.settings.effective();
|
||||
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
|
||||
let cwd = config.cwd.as_deref()?;
|
||||
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
|
||||
});
|
||||
let root = member.as_ref().unwrap_or(&project.root);
|
||||
let settings = match &member {
|
||||
Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(),
|
||||
None => project.settings.effective(),
|
||||
};
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
let mut config = config;
|
||||
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
|
||||
if matches!(config.kind.as_str(), "gradle" | "tomcat") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
if config.kind == "tomcat" {
|
||||
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("This Tomcat configuration is already running"));
|
||||
}
|
||||
let (root, cache) = (root.clone(), project.cache_dir.clone());
|
||||
config = tokio::task::spawn_blocking(move || {
|
||||
ide_run::tomcat::prepare(&root, &cache, &mut config)?;
|
||||
Ok::<_, IdeError>(config)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: super::python::paths(&project),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
@@ -49,8 +49,87 @@ pub async fn run_test_reports(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
ide_build::web::artifacts(&project.root, &model)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(None);
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
let artifacts = ide_build::web::artifacts(&project.root, &model);
|
||||
let id = config
|
||||
.extras
|
||||
.params
|
||||
.get("artifact")
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
)?;
|
||||
let mut env = plan.env;
|
||||
env.extend(config.env.clone());
|
||||
Ok(Some(RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
name: format!("Build {}", config.name),
|
||||
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
cwd: Some(std::path::PathBuf::from(plan.cwd).into()),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras {
|
||||
include_system_env: config.extras.include_system_env,
|
||||
env_files: config.extras.env_files,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::update(&project.root, &project.cache_dir, &config, &mode))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
|
||||
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("run_tomcat_update", |c| async move {
|
||||
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
|
||||
});
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
|
||||
@@ -42,13 +42,16 @@ pub async fn start(
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
@@ -66,7 +69,7 @@ pub async fn start(
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
@@ -74,7 +77,13 @@ pub async fn start(
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| events(SearchEvent::File { file }),
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
|
||||
@@ -45,13 +45,16 @@ pub enum RemoteOp {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
/// remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
|
||||
/// remote picker).
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
@@ -187,18 +190,33 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
pub async fn vcs_blame(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: PathBuf,
|
||||
text: Option<String>,
|
||||
revision: Option<String>,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.documents.text_of(&path);
|
||||
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
@@ -243,12 +261,13 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
/// Returns the ignore files that were updated, so the UI can show them.
|
||||
pub async fn vcs_add_to_ignore(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<()> {
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
@@ -264,7 +283,7 @@ pub async fn vcs_add_to_ignore(
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|_| ())
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -276,7 +295,7 @@ pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeR
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
@@ -293,7 +312,12 @@ pub async fn vcs_commit(
|
||||
) -> IdeResult<String> {
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_checkout(
|
||||
@@ -443,12 +467,17 @@ pub async fn vcs_remote(
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
|
||||
Some(target) => {
|
||||
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
|
||||
}
|
||||
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
|
||||
},
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
|
||||
&branch,
|
||||
PushBranchOptions {
|
||||
revision: revision.as_deref(),
|
||||
target: target.as_deref(),
|
||||
remote: remote.as_deref(),
|
||||
force_with_lease,
|
||||
},
|
||||
&cancel,
|
||||
&out,
|
||||
),
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
@@ -464,12 +493,14 @@ pub async fn vcs_remote(
|
||||
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
|
||||
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path, project.vcs_encoding(&path, Some(":2"))?)))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
|
||||
@@ -481,13 +512,11 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let current = ide_vfs::load_text(&path)?.text;
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
|
||||
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected);
|
||||
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -816,7 +845,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
});
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
|
||||
+244
-39
@@ -2,7 +2,7 @@
|
||||
//! watchers and the per-project services, opened by [`crate::Host::open_project`].
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink};
|
||||
use ide_index::ProjectIndex;
|
||||
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
@@ -46,6 +46,8 @@ pub struct Project {
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
/// Coding agent tasks (Agents tool window).
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -65,6 +67,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
@@ -79,20 +83,14 @@ impl Host {
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
|
||||
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
|
||||
let extra_roots = content_roots(&settings.effective(), &root);
|
||||
mark("roots");
|
||||
|
||||
// The project's own registry: File Types associations may be project-scoped.
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
let documents = documents(&sink, &languages, Some(&root));
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
|
||||
{
|
||||
@@ -108,7 +106,7 @@ impl Host {
|
||||
let index = ProjectIndex::open(
|
||||
&root,
|
||||
&self.data_dir.join("index").join(&project_key),
|
||||
extra_roots.clone(),
|
||||
index_roots(&root, extra_roots.clone()),
|
||||
excludes.clone(),
|
||||
languages.clone(),
|
||||
self.syntax(),
|
||||
@@ -116,31 +114,40 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let effective = settings.effective();
|
||||
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
});
|
||||
let effective = member
|
||||
.as_ref()
|
||||
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
|
||||
.unwrap_or_else(|| settings.effective());
|
||||
let root = member.as_ref().unwrap_or(&root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let bundles: Vec<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
server_root,
|
||||
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
|
||||
ide_tools::managed_bundle(&data_dir, "lombok"),
|
||||
ide_sdk::apply::maven_local_repository(&root, &effective),
|
||||
ide_sdk::apply::maven_local_repository(root, &effective),
|
||||
);
|
||||
if let Some(problem) = lombok.problem() {
|
||||
tracing::warn!("{problem}");
|
||||
}
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(root, &effective).unwrap_or_else(|e| {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
@@ -149,7 +156,7 @@ impl Host {
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
|
||||
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
@@ -158,6 +165,27 @@ impl Host {
|
||||
ide_sdk::jdtls::launch_directly(cfg);
|
||||
}
|
||||
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
|
||||
let excluded_poms = unlinked_maven_poms(root, &effective);
|
||||
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms);
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let sink = sink.clone();
|
||||
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
|
||||
let effective = settings.effective();
|
||||
if cfg.name == "jdtls" {
|
||||
crate::java_runtime::prepare(
|
||||
cfg,
|
||||
&data_dir,
|
||||
!effective.languages.java.jdtls_jdk.trim().is_empty()
|
||||
|| ide_sdk::apply::jdtls_jdk(&root, &effective).is_err(),
|
||||
&sink,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -188,6 +216,7 @@ impl Host {
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
let documents = documents.clone();
|
||||
let languages = languages.clone();
|
||||
let sink = sink.clone();
|
||||
let root = root.clone();
|
||||
let detected_roots = detected_roots.clone();
|
||||
@@ -203,6 +232,12 @@ impl Host {
|
||||
index.update(&changed, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
// templates gain or lose their mode. Markers in `node_modules` only
|
||||
// count here when the folder isn't excluded (it is by default).
|
||||
if batch.created.iter().chain(&batch.removed).any(|p| languages.is_framework_marker(p)) {
|
||||
documents.redetect_languages();
|
||||
}
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
@@ -221,7 +256,10 @@ impl Host {
|
||||
if *detected_roots.read() != fresh {
|
||||
// Detected excluded folders (`target`, `build`) leave the index too.
|
||||
let effective = settings.effective();
|
||||
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
|
||||
index.set_scope(
|
||||
index_roots(&root, content_roots(&effective, &root)),
|
||||
exclude_globs(&root, &effective, &fresh),
|
||||
);
|
||||
*detected_roots.write() = fresh;
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
@@ -229,7 +267,7 @@ impl Host {
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
|
||||
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
@@ -239,7 +277,10 @@ impl Host {
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
}
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p));
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
@@ -282,6 +323,10 @@ impl Host {
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
root,
|
||||
@@ -301,6 +346,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -375,13 +421,7 @@ impl Host {
|
||||
let effective = settings.effective();
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&effective.files, None));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
let documents = documents(&sink, &languages, None);
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?);
|
||||
let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?;
|
||||
@@ -393,6 +433,10 @@ impl Host {
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
sink,
|
||||
languages,
|
||||
@@ -410,6 +454,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -440,6 +485,18 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// The documents of a project whose languages and UI modes `languages` detects,
|
||||
/// looking for frameworks' root markers up to `root` (anywhere for loose files).
|
||||
fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<&Path>) -> Arc<Documents> {
|
||||
let (detect, modes, root) = (languages.clone(), languages.clone(), root.map(Path::to_path_buf));
|
||||
Arc::new(
|
||||
Documents::new(sink.clone(), Box::new(move |p: &Path, first: Option<&str>| detect.detect(p, first)))
|
||||
.with_ui_mode_detection(Box::new(move |p: &Path, lang: &LanguageId| {
|
||||
modes.framework_ui_mode(lang, p, root.as_deref())
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn light_watcher(
|
||||
dir: &Path,
|
||||
sink: &SharedSink,
|
||||
@@ -465,7 +522,9 @@ fn light_watcher(
|
||||
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
match Repos::discover(root, &skip) {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
@@ -477,6 +536,10 @@ fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStor
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn refresh_repositories(&self) {
|
||||
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink);
|
||||
}
|
||||
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
@@ -567,7 +630,7 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
|
||||
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -602,17 +665,23 @@ impl Project {
|
||||
q: &ide_lsp::CandidateQuery,
|
||||
cancel: &CancellationToken,
|
||||
) -> ide_lsp::CandidateFiles {
|
||||
if !q.root.starts_with(&self.root) {
|
||||
let root = self
|
||||
.content_roots()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(self.root.clone()))
|
||||
.filter(|root| q.root.starts_with(root))
|
||||
.max_by_key(|root| root.components().count());
|
||||
let Some(root) = root else {
|
||||
return ide_lsp::CandidateFiles::default();
|
||||
}
|
||||
};
|
||||
let query = ide_search::WordQuery {
|
||||
word: q.word.clone(),
|
||||
dir: q.root.clone(),
|
||||
exclude: self.search_excludes(),
|
||||
limit: q.limit,
|
||||
};
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None));
|
||||
match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
|
||||
Err(e) => {
|
||||
tracing::debug!("reference candidates: {}", e.message);
|
||||
@@ -623,7 +692,7 @@ impl Project {
|
||||
|
||||
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
exclude_globs(&self.root, &self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Project Structure content roots besides the project folder (existing directories).
|
||||
@@ -649,7 +718,8 @@ impl Project {
|
||||
return;
|
||||
}
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.index.set_scope(roots.clone(), excludes.clone());
|
||||
self.lsp.set_content_roots(roots.clone());
|
||||
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
watchers.retain(|(p, _)| roots.contains(p));
|
||||
@@ -668,11 +738,29 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the open document's selected encoding, or the same detection/settings as opening it.
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
if let Some(id) = self.documents.by_path(path) {
|
||||
let info = self.documents.snapshot(id)?;
|
||||
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown document encoding"));
|
||||
}
|
||||
let defaults = self.documents.file_defaults();
|
||||
let fallback = defaults.fallback_for(path);
|
||||
if path.exists() {
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
|
||||
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
|
||||
}
|
||||
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
Ok(ide_vfs::detect_encoding(&bytes, fallback))
|
||||
}
|
||||
|
||||
/// Current text of `path`: the open buffer when available, else the file on disk.
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text(path)?.text),
|
||||
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,8 +823,16 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
|
||||
}
|
||||
}
|
||||
|
||||
/// Project Structure content roots outside `root` (existing absolute directories).
|
||||
/// A workspace uses its root only for state, never as indexed content.
|
||||
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
|
||||
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
|
||||
}
|
||||
|
||||
/// Workspace members in manifest order, or extra Project Structure directories.
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
return workspace.projects;
|
||||
}
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for r in &settings.project_structure.content_roots {
|
||||
let p = PathBuf::from(r.trim());
|
||||
@@ -773,13 +869,27 @@ fn content_root_watcher(
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec<PathBuf> {
|
||||
ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect()
|
||||
}
|
||||
|
||||
fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots {
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven);
|
||||
let detected = detected
|
||||
.iter()
|
||||
.filter(|folder| !folder.detected_from.as_ref().is_some_and(|pom| unlinked.contains(pom)))
|
||||
.cloned()
|
||||
.collect();
|
||||
ProjectRoots::compute(detected, &settings.project_structure)
|
||||
}
|
||||
|
||||
fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
let mut globs = settings.file_excludes();
|
||||
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||
globs.extend(linked_roots(root, settings, detected).excluded_globs());
|
||||
globs
|
||||
}
|
||||
|
||||
fn project_key(root: &Path) -> String {
|
||||
pub(crate) fn project_key(root: &Path) -> String {
|
||||
// Stable FNV-1a directory name per project for caches.
|
||||
let s = root.to_string_lossy();
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
@@ -906,3 +1016,98 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
let exe = settings.vcs.git.executable.trim();
|
||||
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
#[test]
|
||||
fn unlinked_roots_remain_plain_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir_all(tmp.path().join(name).join("src/main/java")).unwrap();
|
||||
std::fs::write(tmp.path().join(name).join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap();
|
||||
std::fs::write(tmp.path().join("a/pom.xml"), "<project><modules><module>child</module></modules></project>")
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").unwrap();
|
||||
let detected = ide_workspace::roots::detect(tmp.path());
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.maven.unlinked_projects.push("a/pom.xml".into());
|
||||
let excluded_poms = super::unlinked_maven_poms(tmp.path(), &settings);
|
||||
assert_eq!(excluded_poms, vec![std::path::PathBuf::from("a/child/pom.xml"), "a/pom.xml".into()]);
|
||||
let configs = ide_run::detect_excluding(tmp.path(), &excluded_poms);
|
||||
assert!(configs.iter().any(|c| c.kind == "maven"));
|
||||
assert!(
|
||||
configs
|
||||
.iter()
|
||||
.filter(|c| c.kind == "maven")
|
||||
.all(|c| { c.cwd.as_ref().is_some_and(|dir| dir.as_ref() == std::path::Path::new("b")) })
|
||||
);
|
||||
let mut server = serde_json::from_value::<ide_language::LanguageServerConfig>(
|
||||
serde_json::json!({"name": "jdtls", "command": "jdtls"}),
|
||||
)
|
||||
.unwrap();
|
||||
ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms);
|
||||
let options = serde_json::to_value(server.initialization_options).unwrap();
|
||||
let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap();
|
||||
assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/"))));
|
||||
assert!(
|
||||
exclusions.contains(&serde_json::json!(tmp.path().join("a/child").to_string_lossy().replace('\\', "/")))
|
||||
);
|
||||
let roots = super::linked_roots(tmp.path(), &settings, &detected);
|
||||
assert!(roots.roots.iter().any(|r| r.path == "b/src/main/java"));
|
||||
assert!(!roots.roots.iter().any(|r| r.path.starts_with("a/")));
|
||||
assert!(tmp.path().join("a/pom.xml").is_file());
|
||||
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 1);
|
||||
settings.languages.java.maven.unlinked_projects.push("b/pom.xml".into());
|
||||
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
|
||||
assert!(model.maven.unwrap().projects.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn vcs_path_changed(path: &Path) -> bool {
|
||||
!path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod index_event_tests {
|
||||
use super::vcs_path_changed;
|
||||
|
||||
#[test]
|
||||
fn status_write_is_suppressed_but_external_index_and_refs_refresh() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
let file = dir.join("file.txt");
|
||||
std::fs::write(&file, "original\n").unwrap();
|
||||
git(&["add", "file.txt"]);
|
||||
// Same contents, different stat data: status must refresh the index.
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&file)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
|
||||
.unwrap();
|
||||
let index = dir.join(".git/index");
|
||||
let before = std::fs::read(&index).unwrap();
|
||||
let repo = ide_vcs::Repo::discover(dir).unwrap().unwrap();
|
||||
repo.status().unwrap();
|
||||
assert_ne!(before, std::fs::read(&index).unwrap(), "status must actually write the index");
|
||||
assert!(!vcs_path_changed(&index));
|
||||
assert!(!vcs_path_changed(&index), "duplicate notifications for the same identity");
|
||||
for path in [".git/HEAD", ".git/refs/heads/main", ".git/packed-refs"] {
|
||||
assert!(vcs_path_changed(&dir.join(path)));
|
||||
}
|
||||
std::fs::write(&file, "external staged change\n").unwrap();
|
||||
git(&["add", "file.txt"]);
|
||||
assert!(vcs_path_changed(&index));
|
||||
assert!(vcs_path_changed(&index));
|
||||
std::fs::remove_file(&index).unwrap();
|
||||
assert!(vcs_path_changed(&index));
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,12 @@ impl Dispatcher {
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
|
||||
ops::run::save_configs(
|
||||
&c.host,
|
||||
&c.window,
|
||||
&c.arg::<Vec<ide_run::RunConfig>>("configs")?,
|
||||
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_detections_stay_removed_and_overrides_restore() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(detected.len() >= 2);
|
||||
assert!(detected.iter().all(|c| c.detected));
|
||||
let id = detected[0].id.clone();
|
||||
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
|
||||
let remaining = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(!remaining.iter().any(|c| c.id == id));
|
||||
assert_eq!(remaining.len(), detected.len() - 1);
|
||||
// Reopening a project reads suppression from disk.
|
||||
host.open_project("again", &root).unwrap();
|
||||
assert!(!ops::run::configs(&host, "again").unwrap().iter().any(|c| c.id == id));
|
||||
let mut saved = detected[0].clone();
|
||||
saved.detected = false;
|
||||
ops::run::save_configs(&host, "main", &[saved], &[]).unwrap();
|
||||
assert!(ide_run::load_suppressed_config_ids(&root).unwrap().is_empty());
|
||||
assert!(!ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
|
||||
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
|
||||
assert!(ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
|
||||
// An override may coexist with suppression (e.g. a shared file added elsewhere).
|
||||
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
|
||||
std::fs::write(
|
||||
ide_run::user_configs_path(&root),
|
||||
format!("[[configurations]]\nid = {id:?}\nname = \"Override\"\ncommand = \"node\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id && !c.detected));
|
||||
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
|
||||
assert!(!ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.java.gradle.jvm",
|
||||
"\"/configured-jdk\"",
|
||||
)
|
||||
.unwrap();
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
let mut gradle = configs.into_iter().find(|c| c.kind == "gradle").unwrap();
|
||||
assert!(!gradle.env.contains_key("JAVA_HOME"));
|
||||
gradle.detected = false;
|
||||
gradle.env.insert("JAVA_HOME".into(), "/explicit-jdk".into());
|
||||
ops::run::save_configs(&host, "main", &[gradle.clone()], &[]).unwrap();
|
||||
let saved = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == gradle.id).unwrap();
|
||||
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
|
||||
assert_eq!(saved.command, gradle.command);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use ide_core::{ChangeSet, NullSink, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
let path = root.join("Example.java");
|
||||
let old = "// 中文注释\nclass Example {\n // 保持不变\n int value = 1;\n}\n";
|
||||
let original = ide_vfs::encode(old, "gbk", false).unwrap();
|
||||
std::fs::write(&path, &original).unwrap();
|
||||
std::fs::write(root.join("Utf8.java"), old).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "GBK Java"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "gbk".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert_eq!(project.text_of(&path).unwrap(), old);
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(old));
|
||||
let doc = project.documents.open(&path).unwrap();
|
||||
assert_eq!(doc.encoding, "gbk");
|
||||
// A live document's explicit selection wins over later project defaults.
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "windows-1252".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
let from = old.find("value = 1").unwrap() + "value = ".len();
|
||||
let offset = old[..from].encode_utf16().count() as u32;
|
||||
project
|
||||
.documents
|
||||
.edit(
|
||||
doc.id,
|
||||
doc.version,
|
||||
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
|
||||
assert_eq!(hunks[0].old_text, " int value = 1;\n");
|
||||
project.documents.save(doc.id).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
|
||||
ide_vfs::encode(&old.replace("value = 1", "value = 2"), "gbk", false).unwrap()
|
||||
);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
let snapshot = project.documents.snapshot(doc.id).unwrap();
|
||||
let line_start =
|
||||
snapshot.text.split_inclusive('\n').take(3).map(|s| s.encode_utf16().count()).sum::<usize>() as u32;
|
||||
project
|
||||
.documents
|
||||
.edit(
|
||||
doc.id,
|
||||
snapshot.version,
|
||||
ChangeSet {
|
||||
changes: vec![TextChange {
|
||||
from: line_start,
|
||||
to: line_start + " int value = 2;\n".encode_utf16().count() as u32,
|
||||
insert: hunks[0].old_text.clone(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.documents.save(doc.id).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
// Missing working files still use configured encoding for historical content.
|
||||
project.documents.close(doc.id);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "gbk".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path, None).await.unwrap().as_deref(), Some(old));
|
||||
let utf8 = root.join("Utf8.java");
|
||||
std::fs::remove_file(&utf8).unwrap();
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::workspace::Workspace;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_directories() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let roots = vec![base.join("frontend"), base.join("backend")];
|
||||
for (index, root) in roots.iter().enumerate() {
|
||||
std::fs::create_dir(root).unwrap();
|
||||
assert!(std::process::Command::new("git").args(["init", "-q"]).current_dir(root).status().unwrap().success());
|
||||
std::fs::write(root.join("same.txt"), format!("shared needle {index}\n")).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "Full stack", vec![roots[0].clone()], true).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(project.content_roots(), roots);
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.find_files("internal-needle", 100).is_empty());
|
||||
assert!(project.index.find_files("workspace.json", 100).is_empty());
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.repos().all().len(), 2);
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
for member in &roots {
|
||||
assert!(configs.iter().any(|c| c.cwd.as_ref().is_some_and(|cwd| &**cwd == member)));
|
||||
}
|
||||
let mut ids = configs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), configs.len());
|
||||
let a = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
|
||||
let b = ops::doc::open(&host, "main", roots[1].join("same.txt")).await.unwrap();
|
||||
assert_ne!(a.id, b.id);
|
||||
ops::doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
a.id,
|
||||
a.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hits = Arc::new(Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false, "caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.lock().unwrap().len(), 2);
|
||||
let found = hits.lock().unwrap().iter().map(|f| f.rel_path.clone()).collect::<Vec<_>>();
|
||||
assert!(found.contains(&"frontend/same.txt".to_owned()));
|
||||
assert!(found.contains(&"backend/same.txt".to_owned()));
|
||||
let updated = ops::project::save_workspace(&host, "main", "Frontend", vec![roots[0].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[0].clone()]);
|
||||
assert_eq!(project.content_roots(), vec![roots[0].clone()]);
|
||||
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
|
||||
assert!(roots[1].join("same.txt").exists());
|
||||
assert!(!roots[1].join(".openidea").exists());
|
||||
let invalid = base.join("invalid");
|
||||
std::fs::create_dir_all(invalid.join(".openidea")).unwrap();
|
||||
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
|
||||
assert!(host.open_project("main", &invalid).is_err());
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
host.open_project("main", &roots[0]).unwrap();
|
||||
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(host.project("main").unwrap().root, roots[0]);
|
||||
let automatic = host.open_project("main", &automatic).unwrap();
|
||||
assert_eq!(automatic.content_roots(), roots);
|
||||
assert_eq!(ops::project::info(&automatic).name, "frontend");
|
||||
assert!(automatic.root.starts_with(base.join("data/workspaces")));
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
|
||||
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
|
||||
assert_eq!(automatic.root, updated.root.as_ref());
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"];
|
||||
|
||||
pub(crate) struct PathFilter {
|
||||
root: PathBuf,
|
||||
include_root: bool,
|
||||
/// Content roots outside `root`, indexed as `<folder name>/<path>`.
|
||||
extra: Vec<ExtraRoot>,
|
||||
excludes: GlobSet,
|
||||
@@ -22,7 +23,7 @@ pub(crate) struct PathFilter {
|
||||
/// applies nested ignore files itself).
|
||||
gitignore: Gitignore,
|
||||
/// What the filter was built from: `(extra roots, exclude globs)`.
|
||||
config: (Vec<PathBuf>, Vec<String>),
|
||||
config: (crate::IndexRoots, Vec<String>),
|
||||
}
|
||||
|
||||
struct ExtraRoot {
|
||||
@@ -68,7 +69,7 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
|
||||
}
|
||||
|
||||
impl PathFilter {
|
||||
pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self {
|
||||
pub(crate) fn new(root: &Path, extra_roots: &crate::IndexRoots, exclude_globs: &[String]) -> Self {
|
||||
let mut set = GlobSetBuilder::new();
|
||||
for raw in exclude_globs {
|
||||
let p = raw.trim().trim_end_matches('/');
|
||||
@@ -92,10 +93,9 @@ impl PathFilter {
|
||||
}
|
||||
let excludes = set.build().unwrap_or_else(|_| GlobSet::empty());
|
||||
let mut extra: Vec<ExtraRoot> = Vec::new();
|
||||
for r in extra_roots {
|
||||
for r in &extra_roots.folders {
|
||||
let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone());
|
||||
let nested = path.starts_with(root)
|
||||
|| root.starts_with(&path)
|
||||
let nested = (extra_roots.include_root && (path.starts_with(root) || root.starts_with(&path)))
|
||||
|| extra.iter().any(|e| path.starts_with(&e.path) || e.path.starts_with(&path));
|
||||
if nested || !path.is_dir() {
|
||||
continue;
|
||||
@@ -105,16 +105,17 @@ impl PathFilter {
|
||||
}
|
||||
Self {
|
||||
root: root.to_owned(),
|
||||
include_root: extra_roots.include_root,
|
||||
extra,
|
||||
excludes,
|
||||
gitignore: root_ignores(root),
|
||||
config: (extra_roots.to_vec(), exclude_globs.to_vec()),
|
||||
config: (extra_roots.clone(), exclude_globs.to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the filter was built from these roots and globs.
|
||||
pub(crate) fn same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool {
|
||||
self.config.0 == extra_roots && self.config.1 == excludes
|
||||
pub(crate) fn same_config(&self, extra_roots: &crate::IndexRoots, excludes: &[String]) -> bool {
|
||||
&self.config.0 == extra_roots && self.config.1 == excludes
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
@@ -123,12 +124,14 @@ impl PathFilter {
|
||||
|
||||
/// The project root, then the extra content roots.
|
||||
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
|
||||
std::iter::once(self.root.as_path()).chain(self.extra.iter().map(|e| e.path.as_path()))
|
||||
std::iter::once(self.root.as_path())
|
||||
.filter(|_| self.include_root)
|
||||
.chain(self.extra.iter().map(|e| e.path.as_path()))
|
||||
}
|
||||
|
||||
/// The root containing `path`, its display prefix (`None` for the project root) and ignore rules.
|
||||
fn base_of(&self, path: &Path) -> Option<(&Path, Option<&str>, &Gitignore)> {
|
||||
if path.starts_with(&self.root) {
|
||||
if self.include_root && path.starts_with(&self.root) {
|
||||
return Some((&self.root, None, &self.gitignore));
|
||||
}
|
||||
self.extra
|
||||
@@ -159,6 +162,9 @@ impl PathFilter {
|
||||
/// Key of `path` in the persistent store: only files below the project root
|
||||
/// are persisted (extra content roots are rescanned when the project opens).
|
||||
pub(crate) fn store_key(&self, path: &Path) -> Option<String> {
|
||||
if !self.include_root {
|
||||
return None;
|
||||
}
|
||||
let rel = rel_string(path.strip_prefix(&self.root).ok()?);
|
||||
(!rel.is_empty()).then_some(rel)
|
||||
}
|
||||
@@ -290,7 +296,8 @@ mod tests {
|
||||
touch(&root, f);
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let filter =
|
||||
PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let mut found: Vec<String> =
|
||||
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
|
||||
found.sort();
|
||||
@@ -313,7 +320,7 @@ mod tests {
|
||||
touch(&base, f);
|
||||
}
|
||||
std::fs::write(shared.join(".gitignore"), "out/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]);
|
||||
let filter = PathFilter::new(&root, &vec![shared.clone(), root.join("src")].into(), &["target".into()]);
|
||||
assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots");
|
||||
let mut found: Vec<String> = filter
|
||||
.roots()
|
||||
@@ -327,7 +334,7 @@ mod tests {
|
||||
assert!(!filter.is_excluded(&shared.join("src/new.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("target/y.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("out/y.rs"), false));
|
||||
assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()]));
|
||||
assert!(!filter.same_config(&[shared], &["target".into()]));
|
||||
assert!(filter.same_config(&vec![shared.clone(), root.join("src")].into(), &["target".into()]));
|
||||
assert!(!filter.same_config(&vec![shared].into(), &["target".into()]));
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,19 @@ pub struct ProjectIndex {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
/// Folders indexed alongside the internal state root.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct IndexRoots {
|
||||
pub include_root: bool,
|
||||
pub folders: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl From<Vec<PathBuf>> for IndexRoots {
|
||||
fn from(folders: Vec<PathBuf>) -> Self {
|
||||
Self { include_root: true, folders }
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectIndex {
|
||||
/// Opens (or creates) the persistent index at `cache_dir` and starts a
|
||||
/// background full scan that skips files whose content hash is unchanged.
|
||||
@@ -77,7 +90,7 @@ impl ProjectIndex {
|
||||
pub fn open(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
content_roots: Vec<PathBuf>,
|
||||
content_roots: impl Into<IndexRoots>,
|
||||
excludes: Vec<String>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<Syntax>,
|
||||
@@ -88,7 +101,7 @@ impl ProjectIndex {
|
||||
return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display())));
|
||||
}
|
||||
let store = store::Store::open(cache_dir, &root, &syntax.fingerprint());
|
||||
let filter = filter::PathFilter::new(&root, &content_roots, &excludes);
|
||||
let filter = filter::PathFilter::new(&root, &content_roots.into(), &excludes);
|
||||
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
|
||||
Self::start(shared, root, true)
|
||||
}
|
||||
@@ -102,7 +115,7 @@ impl ProjectIndex {
|
||||
sink: SharedSink,
|
||||
) -> IdeResult<Arc<Self>> {
|
||||
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned());
|
||||
let filter = filter::PathFilter::new(&root, &[], &[]);
|
||||
let filter = filter::PathFilter::new(&root, &Vec::new().into(), &[]);
|
||||
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None));
|
||||
Self::start(shared, root, false)
|
||||
}
|
||||
@@ -121,7 +134,8 @@ impl ProjectIndex {
|
||||
/// Changes what is indexed besides the root (content roots) and the exclude
|
||||
/// globs, rescanning in the background (unchanged files are not re-read).
|
||||
/// Returns whether anything changed.
|
||||
pub fn set_scope(&self, content_roots: Vec<PathBuf>, excludes: Vec<String>) -> bool {
|
||||
pub fn set_scope(&self, content_roots: impl Into<IndexRoots>, excludes: Vec<String>) -> bool {
|
||||
let content_roots = content_roots.into();
|
||||
if self.shared.filter().same_config(&content_roots, &excludes) {
|
||||
return false;
|
||||
}
|
||||
@@ -222,7 +236,14 @@ impl ProjectIndex {
|
||||
/// the IDE was closed) are left out. Virtual documents are always listed.
|
||||
pub fn recent_files(&self) -> Vec<PathBuf> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
recent.into_iter().filter(|p| p.to_str().is_some_and(ide_core::text::is_virtual_uri) || p.exists()).collect()
|
||||
let filter = self.shared.filter();
|
||||
recent
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.to_str().is_some_and(ide_core::text::is_virtual_uri)
|
||||
|| (p.exists() && (!p.starts_with(&self.root) || filter.contains(p)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocks until the initial scan and every queued update has been applied
|
||||
|
||||
@@ -508,3 +508,38 @@ fn unindexed_index_never_scans_or_persists() {
|
||||
assert!(!fx.cache.exists());
|
||||
assert!(!events.lock().iter().any(|e| matches!(e, IdeEvent::Task { .. })), "no indexing task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
|
||||
let fx = Fixture::new(&[(".openidea/workspace.json", "{}"), ("internal.rs", "fn hidden() {}")]);
|
||||
// Simulate a cache produced before the settings root was excluded.
|
||||
let old = fx.open();
|
||||
old.mark_recent(&fx.root.join("internal.rs"));
|
||||
assert!(old.wait_idle(WAIT));
|
||||
drop(old);
|
||||
let member = fx.root.join("member");
|
||||
std::fs::create_dir_all(&member).unwrap();
|
||||
std::fs::write(member.join("visible.rs"), "fn visible() {}").unwrap();
|
||||
let idx = ProjectIndex::open(
|
||||
&fx.root,
|
||||
&fx.cache,
|
||||
crate::IndexRoots { include_root: false, folders: vec![member.clone()] },
|
||||
Vec::new(),
|
||||
Arc::new(LanguageRegistry::load(None).unwrap()),
|
||||
Arc::new(Syntax::new()),
|
||||
Arc::new(NullSink),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("internal", 100).is_empty());
|
||||
assert!(idx.find_files("workspace.json", 100).is_empty());
|
||||
assert!(idx.recent_files().is_empty());
|
||||
assert_eq!(idx.find_files("visible", 100).len(), 1);
|
||||
idx.update(&[fx.root.join("internal.rs"), fx.root.join(".openidea/workspace.json")], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "hidden", SymbolFilter::All).is_empty());
|
||||
assert_eq!(symbols(&idx, "visible", SymbolFilter::All), ["visible"]);
|
||||
idx.set_scope(crate::IndexRoots { include_root: false, folders: Vec::new() }, Vec::new());
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("", 100).is_empty());
|
||||
}
|
||||
@@ -168,11 +168,15 @@ impl Shared {
|
||||
|
||||
fn load_persisted(&self) {
|
||||
let Some(store) = &self.store else { return };
|
||||
let root = self.filter().root().to_owned();
|
||||
let filter = self.filter();
|
||||
let root = filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> =
|
||||
records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect();
|
||||
let entries: Vec<FileEntry> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
|
||||
@@ -332,6 +332,25 @@ impl Drop for KillOnDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// The program [`Peer::spawn_framed`] runs: a bare name resolved against the
|
||||
/// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so
|
||||
/// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else
|
||||
/// is kept as given.
|
||||
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(command);
|
||||
if path.components().count() > 1 || path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
let search = 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(command, &search).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
/// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines, and
|
||||
/// stdout lines that are not part of a message (a launcher's own output), are
|
||||
@@ -360,7 +379,10 @@ impl Peer {
|
||||
framing: Framing,
|
||||
) -> std::io::Result<Self> {
|
||||
use std::process::Stdio;
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
// Windows resolves a bare name only as `.exe`; find the file a child
|
||||
// would (`PATH` × `PATHEXT`, e.g. `codex` → `codex.cmd`) first.
|
||||
let resolved = resolve_spawn_program(command, env);
|
||||
let mut cmd = tokio::process::Command::new(&resolved);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
|
||||
@@ -58,6 +58,7 @@ tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
tempfile.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "syntax"
|
||||
|
||||
@@ -9,8 +9,10 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
mod markers;
|
||||
mod registry;
|
||||
mod syntax;
|
||||
pub use markers::marked_root;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
|
||||
@@ -41,6 +43,10 @@ pub struct LanguageConfig {
|
||||
/// Name of the CodeMirror/Lezer language the UI should load for instant highlighting.
|
||||
#[serde(default)]
|
||||
pub ui_mode: Option<String>,
|
||||
/// Modes replacing `ui_mode` in a framework's projects (Angular templates);
|
||||
/// the first one whose root marker is found applies.
|
||||
#[serde(default)]
|
||||
pub framework_ui_modes: Vec<FrameworkUiMode>,
|
||||
#[serde(default)]
|
||||
pub tree_sitter: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -49,6 +55,17 @@ pub struct LanguageConfig {
|
||||
pub debugger: Option<DebuggerConfig>,
|
||||
}
|
||||
|
||||
/// A CodeMirror mode for the files of a framework's projects.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FrameworkUiMode {
|
||||
pub ui_mode: String,
|
||||
/// Files whose presence in a directory above a file, up to the project
|
||||
/// folder, marks the framework's project, e.g. `angular.json`; matched like
|
||||
/// language servers' `root-markers`.
|
||||
pub root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||
pub struct IndentConfig {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Root markers: files whose presence in a directory marks a tool's or a
|
||||
//! framework's project there (`Cargo.toml`, `angular.json`). Language server
|
||||
//! roots (`root-markers`) and framework modes (`framework-ui-modes`) share them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The outermost ancestor of `file` containing a root marker, within
|
||||
/// `project_root` (any ancestor without one: loose files).
|
||||
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
|
||||
let mut best = None;
|
||||
for dir in file.ancestors().skip(1) {
|
||||
if project_root.is_some_and(|root| !dir.starts_with(root)) {
|
||||
break;
|
||||
}
|
||||
if markers.iter().any(|m| has_marker(dir, m)) {
|
||||
best = Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// `dir` contains `marker`: a relative path, or a file name pattern with one
|
||||
/// `*` (`*.csproj`).
|
||||
fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
let Some((prefix, suffix)) = marker.split_once('*') else {
|
||||
return dir.join(marker).exists();
|
||||
};
|
||||
std::fs::read_dir(dir).is_ok_and(|entries| {
|
||||
entries.flatten().any(|e| matches_pattern(&e.file_name().to_string_lossy(), prefix, suffix))
|
||||
})
|
||||
}
|
||||
|
||||
/// `path` is (or, after its creation or removal, was) a root marker of the
|
||||
/// directory above it.
|
||||
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
|
||||
match marker.split_once('*') {
|
||||
None => path.ends_with(marker),
|
||||
Some((prefix, suffix)) => {
|
||||
path.file_name().is_some_and(|name| matches_pattern(&name.to_string_lossy(), prefix, suffix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_pattern(name: &str, prefix: &str, suffix: &str) -> bool {
|
||||
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marked_root_needs_a_marker() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let file = root.join("apps/web/src/app.ts");
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()];
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), None);
|
||||
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("apps/web")));
|
||||
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_above_the_project_root_only_count_without_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (home, project) = (tmp.path(), tmp.path().join("work/site"));
|
||||
let file = project.join("src/index.html");
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(home.join("node_modules/@angular/core")).unwrap();
|
||||
let markers = vec!["node_modules/@angular/core".to_string()];
|
||||
assert_eq!(marked_root(Some(&project), &file, &markers), None);
|
||||
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_match_file_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::write(dir.join("App.csproj"), "").unwrap();
|
||||
assert!(has_marker(dir, "*.csproj") && has_marker(dir, "App.*") && !has_marker(dir, "*.fsproj"));
|
||||
assert!(is_marker_path(&dir.join("App.csproj"), "*.csproj"));
|
||||
assert!(is_marker_path(Path::new("/p/web/node_modules/@angular/core"), "node_modules/@angular/core"));
|
||||
assert!(is_marker_path(Path::new("/p/web/angular.json"), "angular.json"));
|
||||
assert!(!is_marker_path(Path::new("/p/web/angular.json.bak"), "angular.json"));
|
||||
assert!(!is_marker_path(Path::new("/p/core"), "node_modules/@angular/core"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
("c", include_str!("../../../languages/c.toml")),
|
||||
("clojure", include_str!("../../../languages/clojure.toml")),
|
||||
("cmake", include_str!("../../../languages/cmake.toml")),
|
||||
@@ -276,6 +277,27 @@ impl LanguageRegistry {
|
||||
self.languages.iter().find(|l| &l.id == id).cloned()
|
||||
}
|
||||
|
||||
/// The `framework-ui-modes` mode of a `language` file at `path`: the first
|
||||
/// whose root marker is in a directory above it, up to `project_root` (any
|
||||
/// directory without one: loose files). `None` keeps the language's own
|
||||
/// `ui-mode`.
|
||||
pub fn framework_ui_mode(&self, language: &LanguageId, path: &Path, project_root: Option<&Path>) -> Option<String> {
|
||||
let cfg = self.languages.iter().find(|l| &l.id == language)?;
|
||||
cfg.framework_ui_modes
|
||||
.iter()
|
||||
.find(|f| crate::marked_root(project_root, path, &f.root_markers).is_some())
|
||||
.map(|f| f.ui_mode.clone())
|
||||
}
|
||||
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode`:
|
||||
/// it is a framework's root marker.
|
||||
pub fn is_framework_marker(&self, path: &Path) -> bool {
|
||||
self.languages
|
||||
.iter()
|
||||
.flat_map(|l| &l.framework_ui_modes)
|
||||
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
|
||||
}
|
||||
|
||||
/// Detects by Override File Type, exact file name, then file-name pattern
|
||||
/// (`Dockerfile.*`), then extension (longest compound first, e.g. `d.ts`),
|
||||
/// then hashbang / first line; falls back to plaintext. The user's patterns
|
||||
@@ -348,8 +370,10 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// `name`s exported by `@codemirror/language-data` (the UI resolves `ui-mode` against these).
|
||||
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
|
||||
/// (the UI resolves `ui-mode` against these).
|
||||
const UI_MODES: &[&str] = &[
|
||||
"Batch",
|
||||
"C",
|
||||
"C++",
|
||||
"CQL",
|
||||
@@ -511,6 +535,7 @@ mod tests {
|
||||
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
|
||||
assert_eq!(detect(®, "README"), "plaintext");
|
||||
assert_eq!(detect(®, "unknown.zzz"), "plaintext");
|
||||
assert_eq!(reg.get(&LanguageId::new("bat")).unwrap().line_comment.as_deref(), Some("@REM"));
|
||||
let rust = reg.get(&LanguageId::new("rust")).unwrap();
|
||||
assert_eq!(rust.language_servers[0].name, "rust-analyzer");
|
||||
}
|
||||
@@ -552,6 +577,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mode = |lang: &str, file: &str| reg.framework_ui_mode(&LanguageId::new(lang), &root.join(file), Some(root));
|
||||
std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap();
|
||||
std::fs::create_dir_all(root.join("apps/admin")).unwrap();
|
||||
std::fs::write(root.join("apps/admin/angular.json"), "{}").unwrap();
|
||||
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "docs/index.html"), None);
|
||||
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
|
||||
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_outside_the_project_do_not_make_angular_templates() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A stray `~/node_modules/@angular/core` above an unrelated project.
|
||||
std::fs::create_dir_all(tmp.path().join("node_modules/@angular/core")).unwrap();
|
||||
let project = tmp.path().join("work/site");
|
||||
let page = project.join("index.html");
|
||||
assert_eq!(reg.framework_ui_mode(&LanguageId::new("html"), &page, Some(&project)), None);
|
||||
// Loose files (LightEdit) have no project to stop at.
|
||||
let loose = reg.framework_ui_mode(&LanguageId::new("html"), &page, None);
|
||||
assert_eq!(loose.as_deref(), Some("Angular Template"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_file_is_registered_and_parses() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../languages");
|
||||
@@ -621,7 +677,8 @@ mod tests {
|
||||
fn ui_modes_exist_in_codemirror_language_data() {
|
||||
for (name, src) in BUILTIN {
|
||||
let cfg = parse(src).unwrap();
|
||||
if let Some(mode) = &cfg.ui_mode {
|
||||
let framework_modes = cfg.framework_ui_modes.iter().map(|f| &f.ui_mode);
|
||||
for mode in cfg.ui_mode.iter().chain(framework_modes) {
|
||||
assert!(UI_MODES.contains(&mode.as_str()), "{name}: ui-mode {mode:?} is not a language-data name");
|
||||
}
|
||||
}
|
||||
@@ -679,6 +736,8 @@ mod tests {
|
||||
("deploy.sh", "shellscript"),
|
||||
(".zshrc", "shellscript"),
|
||||
("Build.ps1", "powershell"),
|
||||
("build.bat", "bat"),
|
||||
("run.cmd", "bat"),
|
||||
("schema.sql", "sql"),
|
||||
("api.http", "http"),
|
||||
("api.rest", "http"),
|
||||
|
||||
@@ -21,7 +21,6 @@ futures.workspace = true
|
||||
globset.workspace = true
|
||||
toml.workspace = true
|
||||
percent-encoding = "2"
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1176,6 +1176,40 @@ mod tests {
|
||||
assert!(matches!(back.text_edit, Some(lsp::CompletionTextEdit::InsertAndReplace(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_completion_defaults_preserve_prefix_range_and_edit_text() {
|
||||
// JDT.LS 1.63: request after `System.out.` or `System.out.Print`.
|
||||
for end in [19, 24] {
|
||||
let (_, items) = parse_completion_response(json!({
|
||||
"isIncomplete": false,
|
||||
"itemDefaults": {
|
||||
"editRange": {
|
||||
"insert": {"start": {"line":2,"character":19}, "end": {"line":2,"character":end}},
|
||||
"replace": {"start": {"line":2,"character":19}, "end": {"line":2,"character":end}}
|
||||
},
|
||||
"insertTextFormat": 2,
|
||||
"data": {"completionKinds": [2, 6]}
|
||||
},
|
||||
"items": [{
|
||||
"label": "print",
|
||||
"labelDetails": {"detail": "(Object obj)", "description": "void"},
|
||||
"kind": 2,
|
||||
"detail": "PrintStream.print(Object obj) : void",
|
||||
"filterText": "print(Object obj) : void",
|
||||
"insertText": "print",
|
||||
"textEditText": "print(${0});",
|
||||
"data": {"pid": "0", "rid": "0"}
|
||||
}]
|
||||
}));
|
||||
let item = completion_item(&Conv::utf16(), Path::new("/Main.java"), items[0].clone(), ServerId(1));
|
||||
assert_eq!(item.replace_range, Some(Range::new(p(2, 19), p(2, end))));
|
||||
assert_eq!(item.replace_range_tab, None);
|
||||
assert_eq!(item.filter_text.as_deref(), Some("print(Object obj) : void"));
|
||||
assert_eq!(item.insert_text, "print(${0});");
|
||||
assert!(item.is_snippet);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_normalisation() {
|
||||
let conv = Conv::utf16();
|
||||
|
||||
+118
-24
@@ -8,8 +8,7 @@
|
||||
use crate::LspManager;
|
||||
use crate::server::{Running, Server};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeResult, LanguageId};
|
||||
use ide_jsonrpc::RpcError;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
@@ -20,7 +19,9 @@ use std::time::{Duration, Instant};
|
||||
const SERVER: &str = "jdtls";
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
const BUILD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// A workspace build of a large multi-module project can take far longer than
|
||||
/// an editor request; the user can always cancel the launch waiting on it.
|
||||
const BUILD_TIMEOUT: Duration = Duration::from_secs(20 * 60);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// How long to retry while the project is still being imported.
|
||||
const IMPORT_WAIT: Duration = Duration::from_secs(90);
|
||||
@@ -70,6 +71,8 @@ pub struct JavaLaunchInfo {
|
||||
pub project_root: Option<ide_core::IpcPath>,
|
||||
pub class_paths: Vec<String>,
|
||||
pub module_paths: Vec<String>,
|
||||
/// Path list separator of the host that runs the JVM.
|
||||
pub path_list_separator: String,
|
||||
/// JDK home jdtls compiles the project against.
|
||||
pub java_home: Option<ide_core::IpcPath>,
|
||||
/// The `java` launcher to use (filled in by the caller from the installed JDKs).
|
||||
@@ -163,10 +166,19 @@ fn missing_bundle(e: &IdeError) -> bool {
|
||||
m.contains("no delegatecommandhandler") || m.contains("unsupported command") || m.contains("not supported")
|
||||
}
|
||||
|
||||
/// Cancel local waits without interrupting a shared server's startup or import.
|
||||
async fn cancellable_wait<T>(cancel: &CancellationToken, wait: impl std::future::Future<Output = T>) -> IdeResult<T> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
result = wait => Ok(result),
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// The jdtls instance serving `path` (a module directory or source file),
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path) -> IdeResult<(Arc<Server>, Running)> {
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
let java = LanguageId::new("java");
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
@@ -174,7 +186,7 @@ impl LspManager {
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
match server.wait_ready(READY_TIMEOUT).await {
|
||||
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
|
||||
Some(run) => Ok((server, run)),
|
||||
None => {
|
||||
let status = server.status();
|
||||
@@ -191,79 +203,146 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn jdtls_request(&self, path: &Path, method: &str, params: Value, timeout: Duration) -> IdeResult<Value> {
|
||||
let (server, run) = self.jdtls(path).await?;
|
||||
let fut = run.peer.request::<Value, Value>(method, params, None);
|
||||
async fn jdtls_request(
|
||||
&self,
|
||||
path: &Path,
|
||||
method: &str,
|
||||
params: Value,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let fut = run.peer.request::<Value, Value>(method, params, Some(cancel.clone()));
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
|
||||
Err(_) => Err(crate::rpc_err(&server.name, method, RpcError::Timeout)),
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
format!(
|
||||
"{SERVER}: {method} timed out after {} s; retry, or check that jdtls is not stuck",
|
||||
timeout.as_secs()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `workspace/executeCommand` on the jdtls serving `path`.
|
||||
pub async fn jdtls_command(&self, path: &Path, command: &str, arguments: Vec<Value>) -> IdeResult<Value> {
|
||||
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
async fn jdtls_command_cancellable(
|
||||
&self,
|
||||
path: &Path,
|
||||
command: &str,
|
||||
arguments: Vec<Value>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
self.jdtls_request(
|
||||
path,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": command, "arguments": arguments }),
|
||||
COMMAND_TIMEOUT,
|
||||
cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`jdtls_command`](Self::jdtls_command), retried while the project is still importing.
|
||||
async fn jdtls_project_command(&self, path: &Path, command: &str, arguments: Vec<Value>) -> IdeResult<Value> {
|
||||
async fn jdtls_project_command(
|
||||
&self,
|
||||
path: &Path,
|
||||
command: &str,
|
||||
arguments: Vec<Value>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
let deadline = Instant::now() + IMPORT_WAIT;
|
||||
loop {
|
||||
match self.jdtls_command(path, command, arguments.clone()).await {
|
||||
match self.jdtls_command_cancellable(path, command, arguments.clone(), cancel).await {
|
||||
Err(e) if not_imported(&e) && Instant::now() < deadline => {
|
||||
tracing::debug!("{command}: {e}; waiting for the project import");
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
cancellable_wait(cancel, tokio::time::sleep(Duration::from_millis(1500))).await?;
|
||||
}
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells jdtls the build files of `uris` changed (`java/projectConfigurationsUpdate`),
|
||||
/// so it re-imports their projects (e.g. after `~/.m2` gained an artifact it could
|
||||
/// not resolve). Best-effort: skipped while jdtls is not already running.
|
||||
pub async fn java_update_project_configs(&self, path: &Path, uris: Vec<String>) -> IdeResult<()> {
|
||||
if uris.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let java = LanguageId::new("java");
|
||||
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(run) = server.running() else { return Ok(()) };
|
||||
let identifiers: Vec<Value> = uris.iter().map(|u| json!({ "uri": u })).collect();
|
||||
run.peer
|
||||
.notify("java/projectConfigurationsUpdate", json!({ "identifiers": identifiers }))
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
|
||||
/// class files are up to date before a launch.
|
||||
pub async fn java_build(&self, path: &Path) -> IdeResult<JavaBuildStatus> {
|
||||
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT).await?;
|
||||
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Runtime (`test == false`) or test classpath of the project containing `path`.
|
||||
pub async fn java_classpath(&self, path: &Path, test: bool) -> IdeResult<JavaClasspath> {
|
||||
pub async fn java_classpath(
|
||||
&self,
|
||||
path: &Path,
|
||||
test: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaClasspath> {
|
||||
let uri = crate::uri::path_to_uri_string(path);
|
||||
let options = json!({ "scope": if test { "test" } else { "runtime" } }).to_string();
|
||||
let v =
|
||||
self.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)]).await?;
|
||||
let v = self
|
||||
.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)], cancel)
|
||||
.await?;
|
||||
Ok(parse_classpaths(&v))
|
||||
}
|
||||
|
||||
/// The JDK home jdtls uses for the project containing `path`.
|
||||
pub async fn java_vm_location(&self, path: &Path) -> IdeResult<Option<PathBuf>> {
|
||||
pub async fn java_vm_location(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<Option<PathBuf>> {
|
||||
let uri = crate::uri::path_to_uri_string(path);
|
||||
let v = self
|
||||
.jdtls_project_command(
|
||||
path,
|
||||
"java.project.getSettings",
|
||||
vec![json!(uri), json!(["org.eclipse.jdt.ls.core.vm.location"])],
|
||||
cancel,
|
||||
)
|
||||
.await?;
|
||||
Ok(parse_vm_location(&v))
|
||||
}
|
||||
|
||||
/// Builds (when asked), then resolves the classpath and JDK of the module at `module`.
|
||||
pub async fn java_launch(&self, module: &Path, test: bool, build: bool) -> IdeResult<JavaLaunchInfo> {
|
||||
let build = if build { Some(self.java_build(module).await?) } else { None };
|
||||
pub async fn java_launch(
|
||||
&self,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let build = if build { Some(self.java_build(module, cancel).await?) } else { None };
|
||||
if build == Some(JavaBuildStatus::Cancelled) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (errors, error_count) = match build {
|
||||
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => self.java_compile_errors(module).await,
|
||||
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
|
||||
cancellable_wait(cancel, self.java_compile_errors(module)).await?
|
||||
}
|
||||
_ => (Vec::new(), 0),
|
||||
};
|
||||
let cp = self.java_classpath(module, test).await?;
|
||||
let java_home = match self.java_vm_location(module).await {
|
||||
let cp = self.java_classpath(module, test, cancel).await?;
|
||||
let java_home = match self.java_vm_location(module, cancel).await {
|
||||
Ok(home) => home,
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("java.project.getSettings: {e}");
|
||||
None
|
||||
@@ -274,6 +353,7 @@ impl LspManager {
|
||||
project_root: cp.project_root,
|
||||
class_paths: cp.class_paths,
|
||||
module_paths: cp.module_paths,
|
||||
path_list_separator: if cfg!(windows) { ";" } else { ":" }.into(),
|
||||
java_home: java_home.map(Into::into),
|
||||
java_exec: None,
|
||||
shorten_command_line: None,
|
||||
@@ -285,7 +365,7 @@ impl LspManager {
|
||||
|
||||
/// Compile errors jdtls reports for its workspace (all modules it imported).
|
||||
async fn java_compile_errors(&self, module: &Path) -> (Vec<JavaCompileError>, u32) {
|
||||
let Ok((server, _)) = self.jdtls(module).await else { return (Vec::new(), 0) };
|
||||
let Ok((server, _)) = self.jdtls(module, &CancellationToken::new()).await else { return (Vec::new(), 0) };
|
||||
let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS);
|
||||
let found = collect();
|
||||
if found.1 > 0 {
|
||||
@@ -323,6 +403,20 @@ impl LspManager {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_ends_a_pending_wait() {
|
||||
let cancel = CancellationToken::new();
|
||||
let waiting = cancellable_wait(&cancel, std::future::pending::<()>());
|
||||
let cancelling = async {
|
||||
tokio::task::yield_now().await;
|
||||
cancel.cancel();
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(1), async { tokio::join!(waiting, cancelling) })
|
||||
.await
|
||||
.expect("cancelled wait must finish");
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_errors_are_sorted_and_capped() {
|
||||
let diag = |line: u32, severity, message: &str| lsp_types::Diagnostic {
|
||||
|
||||
+10
-101
@@ -1,11 +1,12 @@
|
||||
//! Starting server processes: binary resolution and the [`Launcher`] seam.
|
||||
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::tool_path::search_path;
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LaunchError {
|
||||
@@ -72,8 +73,7 @@ impl Launcher for ProcessLauncher {
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
let search = server_search_path(config);
|
||||
let binary = which::which_in(&config.command, Some(&search), root)
|
||||
.ok()
|
||||
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b));
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
|
||||
@@ -95,7 +95,7 @@ impl Launcher for ProcessLauncher {
|
||||
}
|
||||
|
||||
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
|
||||
which::which_in(&config.command, Some(server_search_path(config)), root).is_ok()
|
||||
ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,6 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
|
||||
}
|
||||
|
||||
/// `PATH` plus the login shell's `PATH` and common tool directories, so
|
||||
/// servers resolve even when the app is launched from a GUI shell with a
|
||||
/// minimal environment.
|
||||
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
@@ -153,74 +150,6 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_path() -> OsString {
|
||||
static PATH: OnceLock<OsString> = OnceLock::new();
|
||||
PATH.get_or_init(|| {
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default();
|
||||
if let Some(shell_path) = login_shell_path() {
|
||||
dirs.extend(std::env::split_paths(&shell_path));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) {
|
||||
for rel in [".cargo/bin", ".local/bin", "go/bin", ".bun/bin", ".deno/bin", ".npm-global/bin", ".volta/bin"]
|
||||
{
|
||||
dirs.push(home.join(rel));
|
||||
}
|
||||
// nvm: newest installed node's bin.
|
||||
if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) {
|
||||
let mut versions: Vec<PathBuf> = entries.flatten().map(|e| e.path().join("bin")).collect();
|
||||
versions.sort();
|
||||
dirs.extend(versions.into_iter().rev());
|
||||
}
|
||||
}
|
||||
for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone()));
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_shell_path() -> Option<OsString> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
let shell = std::env::var_os("SHELL")?;
|
||||
const MARK: &str = "__OPENIDEA_PATH__";
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(shell))
|
||||
.args(["-l", "-c", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
child.stdout.take()?.read_to_string(&mut out).ok()?;
|
||||
let start = out.find(MARK)? + MARK.len();
|
||||
let end = start + out[start..].find(MARK)?;
|
||||
Some(OsString::from(&out[start..end]))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_shell_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Workspace root for `file`: the outermost ancestor within `project_root`
|
||||
/// containing a root marker (so a Cargo/npm workspace gets one server, not one
|
||||
/// per member), else `project_root`. `None` when `file` is outside the project.
|
||||
@@ -231,32 +160,10 @@ pub fn find_root(project_root: &Path, file: &Path, markers: &[String]) -> Option
|
||||
Some(marked_root(project_root, file, markers).unwrap_or_else(|| project_root.to_path_buf()))
|
||||
}
|
||||
|
||||
/// The outermost ancestor of `file` within `project_root` containing a root marker.
|
||||
/// The outermost ancestor of `file` within `project_root` containing a root
|
||||
/// marker (the same lookup as `framework-ui-modes`).
|
||||
pub fn marked_root(project_root: &Path, file: &Path, markers: &[String]) -> Option<PathBuf> {
|
||||
let mut best = None;
|
||||
for dir in file.ancestors().skip(1) {
|
||||
if !dir.starts_with(project_root) {
|
||||
break;
|
||||
}
|
||||
if markers.iter().any(|m| has_marker(dir, m)) {
|
||||
best = Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
if !marker.contains('*') {
|
||||
return dir.join(marker).exists();
|
||||
}
|
||||
let (prefix, suffix) = marker.split_once('*').unwrap_or((marker, ""));
|
||||
std::fs::read_dir(dir).is_ok_and(|entries| {
|
||||
entries.flatten().any(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
|
||||
})
|
||||
})
|
||||
ide_language::marked_root(Some(project_root), file, markers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -297,7 +204,9 @@ mod tests {
|
||||
fn search_path_contains_env_path() {
|
||||
let p = search_path();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&p).collect();
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")));
|
||||
// The process's own `PATH` starts the search, then per-user and system dirs.
|
||||
let first_env = std::env::split_paths(&std::env::var_os("PATH").unwrap()).find(|d| d.is_absolute()).unwrap();
|
||||
assert_eq!(dirs.first(), Some(&first_env), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-28
@@ -24,7 +24,7 @@ mod uri;
|
||||
mod virtual_doc;
|
||||
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
|
||||
|
||||
@@ -75,6 +75,8 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
|
||||
/// settings before each launch; receives the server's workspace root.
|
||||
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
|
||||
pub type ConfigHook = Arc<ConfigFn>;
|
||||
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
|
||||
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
|
||||
/// The servers a language uses, from its definition's list (user settings
|
||||
/// disable some and add others).
|
||||
pub type ServersFn = dyn Fn(&LanguageId, &[LanguageServerConfig]) -> Vec<LanguageServerConfig> + Send + Sync;
|
||||
@@ -155,6 +157,7 @@ impl DocSnap {
|
||||
pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
content_roots: RwLock<Vec<PathBuf>>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
@@ -163,6 +166,7 @@ pub struct LspManager {
|
||||
docs: Mutex<HashMap<DocId, DocState>>,
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
@@ -283,6 +287,7 @@ impl LspManager {
|
||||
Arc::new_cyclic(|me| Self {
|
||||
me: me.clone(),
|
||||
project_root,
|
||||
content_roots: RwLock::new(Vec::new()),
|
||||
languages,
|
||||
sink,
|
||||
launcher,
|
||||
@@ -291,6 +296,7 @@ impl LspManager {
|
||||
docs: Mutex::new(HashMap::new()),
|
||||
apply_edit_hook: RwLock::new(None),
|
||||
config_hook: RwLock::new(None),
|
||||
prepare_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
@@ -302,9 +308,24 @@ impl LspManager {
|
||||
|
||||
// ---- server lookup & lifecycle ----
|
||||
|
||||
pub fn set_content_roots(&self, roots: Vec<PathBuf>) {
|
||||
*self.content_roots.write() = roots;
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
self.content_roots
|
||||
.read()
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.project_root))
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
}
|
||||
|
||||
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
|
||||
let mut servers = self.servers.lock();
|
||||
let root = launch::find_root(&self.project_root, path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
@@ -332,7 +353,9 @@ impl LspManager {
|
||||
if sc.fallback && available_before {
|
||||
continue;
|
||||
}
|
||||
if sc.require_root_marker && launch::marked_root(&self.project_root, path, &sc.root_markers).is_none() {
|
||||
if sc.require_root_marker
|
||||
&& launch::marked_root(&self.project_root_for(path), path, &sc.root_markers).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let server = self.server_for(sc, language, path);
|
||||
@@ -408,7 +431,16 @@ impl LspManager {
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
let cfg = me.configured(&tail);
|
||||
let mut cfg = me.configured(&tail);
|
||||
tail.set_effective_config(cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
prepare(&cfg, &tail.root, &cancel);
|
||||
cfg = me.configured(&tail);
|
||||
}
|
||||
tail.set_effective_config(cfg.clone());
|
||||
let root = tail.root.clone();
|
||||
if let Some(message) = &cfg.launch_error {
|
||||
@@ -1380,21 +1412,34 @@ impl LspManager {
|
||||
/// server's analysis scope that mention the name are opened for the request.
|
||||
/// Every server that can rename there does: the Angular server adds the
|
||||
/// templates to vtsls's edit, whose own edits win where both change the same text.
|
||||
pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
pub async fn rename(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Renamed> {
|
||||
let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?;
|
||||
let renaming = self.rename_targets(&snap, &targets, pos).await;
|
||||
let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name))).await;
|
||||
let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name, &cancel))).await;
|
||||
let mut out = Outcome::new();
|
||||
let mut incomplete = None;
|
||||
for edit in edits {
|
||||
match edit {
|
||||
Ok(edit) => match &mut out.value {
|
||||
Some(merged) => convert::merge_workspace_edit(merged, edit),
|
||||
None => out.value = Some(edit),
|
||||
},
|
||||
Ok(renamed) => {
|
||||
incomplete = incomplete.or(renamed.incomplete);
|
||||
match &mut out.value {
|
||||
Some(merged) => convert::merge_workspace_edit(merged, renamed.edit),
|
||||
None => out.value = Some(renamed.edit),
|
||||
}
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
}
|
||||
out.finish(WorkspaceEdit::default())
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
}
|
||||
|
||||
/// The servers that can rename at `pos`, the first one when none can (a
|
||||
@@ -1411,16 +1456,27 @@ impl LspManager {
|
||||
vec![&targets[0]]
|
||||
}
|
||||
|
||||
async fn rename_on(&self, snap: &DocSnap, t: &Target, pos: Pos, new_name: &str) -> IdeResult<WorkspaceEdit> {
|
||||
async fn rename_on(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
new_name: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Renamed> {
|
||||
let query = || async {
|
||||
let mut params = snap.pos_params(t, pos);
|
||||
params["newName"] = json!(new_name);
|
||||
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
|
||||
let r: Option<lsp::WorkspaceEdit> =
|
||||
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
|
||||
};
|
||||
// The Angular server renames in the file's own project only, whatever is open.
|
||||
if t.server.name == angular::SERVER {
|
||||
return query().await;
|
||||
let edit = query().await?;
|
||||
let empty = edit.files.is_empty();
|
||||
let w = scope::Widened::only((), empty, &t.server);
|
||||
return Ok(Renamed { edit, incomplete: w.incomplete });
|
||||
}
|
||||
let files = |e: &WorkspaceEdit| {
|
||||
e.files
|
||||
@@ -1431,9 +1487,9 @@ impl LspManager {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let w = self.widened(snap, t, pos, &CancellationToken::new(), query, files).await?;
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
Ok(w.second.unwrap_or(w.first))
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -1728,11 +1784,14 @@ impl LspManager {
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
let (snap, targets) = self.doc_targets(doc, "call hierarchy", |c| c.has("callHierarchyProvider")).await?;
|
||||
let t = &targets[0];
|
||||
let items: Option<Vec<lsp::CallHierarchyItem>> =
|
||||
self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
|
||||
let cancel = &cancel;
|
||||
let items: Option<Vec<lsp::CallHierarchyItem>> = self
|
||||
.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), Some(cancel), DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?;
|
||||
let query = || async {
|
||||
let mut out = Vec::new();
|
||||
@@ -1741,7 +1800,7 @@ impl LspManager {
|
||||
match direction {
|
||||
HierarchyDirection::Incoming => {
|
||||
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
|
||||
self.call(t, "callHierarchy/incomingCalls", params, None, LONG_TIMEOUT).await?;
|
||||
self.call(t, "callHierarchy/incomingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
for c in calls.unwrap_or_default() {
|
||||
let (path, _) = uri_target(c.from.uri.as_str());
|
||||
@@ -1751,7 +1810,7 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => {
|
||||
let calls: Option<Vec<lsp::CallHierarchyOutgoingCall>> =
|
||||
self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?;
|
||||
self.call(t, "callHierarchy/outgoingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
let (src, _) = uri_target(item.uri.as_str());
|
||||
for c in calls.unwrap_or_default() {
|
||||
@@ -1763,19 +1822,22 @@ impl LspManager {
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let out = match direction {
|
||||
let (out, incomplete) = match direction {
|
||||
// Callers are references: they may sit in files outside the server's scope.
|
||||
HierarchyDirection::Incoming => {
|
||||
let files = |v: &Vec<HierarchyItem>| {
|
||||
v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect()
|
||||
};
|
||||
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
|
||||
w.second.unwrap_or(w.first)
|
||||
let w = self.widened(&snap, t, pos, cancel, query, files).await?;
|
||||
(w.second.unwrap_or(w.first), w.incomplete)
|
||||
}
|
||||
HierarchyDirection::Outgoing => query().await?,
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref()));
|
||||
Ok(out)
|
||||
Ok(CallHierarchy { items: out, incomplete })
|
||||
}
|
||||
|
||||
pub async fn type_hierarchy(
|
||||
@@ -1901,6 +1963,10 @@ impl LspManager {
|
||||
*self.config_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
pub fn set_prepare_hook(&self, hook: Box<PrepareFn>) {
|
||||
*self.prepare_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// Installed by `src-tauri` to apply the servers enabled / defined in settings.
|
||||
pub fn set_servers_hook(&self, hook: Box<ServersFn>) {
|
||||
*self.servers_hook.write() = Some(Arc::from(hook));
|
||||
@@ -1921,9 +1987,22 @@ impl LspManager {
|
||||
/// Servers no longer enabled stop; newly enabled ones attach to open documents.
|
||||
pub async fn reconfigure(&self) {
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for s in servers {
|
||||
let next = self.configured(&s);
|
||||
// The settings hook may run tools (`npm root -g`), as when the server starts.
|
||||
let next = match tokio::task::spawn_blocking({
|
||||
let (me, s) = (me.clone(), s.clone());
|
||||
move || me.configured(&s)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: settings hook failed: {e}", s.name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let current = s.effective_config();
|
||||
if next == current {
|
||||
continue;
|
||||
@@ -2216,7 +2295,7 @@ mod send_check {
|
||||
assert_send(m.hover(doc, Pos::default(), CancellationToken::new()));
|
||||
assert_send(m.code_lens(doc));
|
||||
assert_send(m.workspace_symbols("", CancellationToken::new()));
|
||||
assert_send(m.call_hierarchy(doc, Pos::default(), HierarchyDirection::Incoming));
|
||||
assert_send(m.call_hierarchy(doc, Pos::default(), HierarchyDirection::Incoming, CancellationToken::new()));
|
||||
assert_send(m.restart(ide_core::ServerId(1)));
|
||||
assert_send(m.shutdown_all());
|
||||
assert_send(m.semantic_tokens(doc));
|
||||
|
||||
+30
-10
@@ -138,6 +138,9 @@ impl LspManager {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
let opened = self.open_transient(t, files, cancel).await;
|
||||
if opened.skipped {
|
||||
incomplete = incomplete.or(Some(ReferencesIncomplete::FilesSkipped));
|
||||
}
|
||||
if opened.files.is_empty() {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
@@ -160,22 +163,37 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops.
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
|
||||
let read = {
|
||||
// The server already sees the user's files, however large.
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.path.clone()).collect();
|
||||
let (read, skipped) = {
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
files
|
||||
.into_iter()
|
||||
.take_while(|_| !cancel.is_cancelled())
|
||||
.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_CANDIDATE_BYTES))
|
||||
.filter_map(|f| std::fs::read_to_string(&f).ok().map(|text| (f, text)))
|
||||
.collect::<Vec<_>>()
|
||||
let mut read = Vec::new();
|
||||
let mut skipped = false;
|
||||
for f in files.into_iter().filter(|f| !open.contains(f)) {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
// A file gone since the search no longer mentions anything.
|
||||
let Ok(meta) = std::fs::metadata(&f) else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) {
|
||||
Some(Ok(text)) => read.push((f, text)),
|
||||
_ => skipped = true,
|
||||
}
|
||||
}
|
||||
(read, skipped)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() };
|
||||
let mut guard = TransientFiles::empty(self, t);
|
||||
guard.skipped = skipped;
|
||||
if cancel.is_cancelled() {
|
||||
return guard;
|
||||
}
|
||||
@@ -206,6 +224,8 @@ pub(crate) struct TransientFiles<'a> {
|
||||
server: Arc<Server>,
|
||||
run: Running,
|
||||
pub files: Vec<(PathBuf, lsp::Uri)>,
|
||||
/// Candidates that could not be opened.
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
impl TransientFiles<'_> {
|
||||
@@ -226,7 +246,7 @@ impl TransientFiles<'_> {
|
||||
|
||||
impl<'a> TransientFiles<'a> {
|
||||
pub(crate) fn empty(mgr: &'a LspManager, t: &Target) -> Self {
|
||||
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }
|
||||
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new(), skipped: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +331,8 @@ pub enum ReferencesIncomplete {
|
||||
Indexing,
|
||||
/// More project files mention the name than were checked.
|
||||
TooManyFiles,
|
||||
/// Files that mention the name were too large or unreadable to check.
|
||||
FilesSkipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -339,3 +341,17 @@ pub struct References {
|
||||
pub locations: Vec<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Renamed {
|
||||
pub edit: WorkspaceEdit,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallHierarchy {
|
||||
pub items: Vec<HierarchyItem>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
@@ -245,6 +245,7 @@ fn harness_configured(
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -358,6 +359,29 @@ fn change(range: Range, text: &str) -> AppliedChange {
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn linked_projects_initialize_separate_language_server_roots() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let roots = vec![first.path().to_path_buf(), second.path().to_path_buf()];
|
||||
h.mgr.set_content_roots(roots.clone());
|
||||
for root in &roots {
|
||||
let file = root.join("same.mk");
|
||||
std::fs::write(&file, "hello").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "hello");
|
||||
}
|
||||
let server = h.launcher.state("mock");
|
||||
eventually(|| server.requested("initialize").len() == 2).await;
|
||||
let mut actual: Vec<_> =
|
||||
server.requested("initialize").iter().map(|r| r["rootUri"].as_str().unwrap().to_owned()).collect();
|
||||
let mut expected: Vec<_> = roots.iter().map(|p| uri_of(p)).collect();
|
||||
actual.sort();
|
||||
expected.sort();
|
||||
assert_eq!(actual, expected);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn initialize_and_incremental_sync() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -904,6 +928,38 @@ async fn launch_error_fails_without_starting_the_server() {
|
||||
assert!(!h.statuses("mock").contains(&ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn preparation_refreshes_config_and_survives_unchanged_settings() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let prepared = Arc::new(AtomicBool::new(false));
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let wait = Mutex::new(wait);
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let prepared = prepared.clone();
|
||||
move |cfg, _| {
|
||||
cfg.launch_error = (!prepared.load(Ordering::SeqCst)).then(|| "needs runtime".into());
|
||||
}
|
||||
}));
|
||||
h.mgr.set_prepare_hook(Box::new({
|
||||
let started = started.clone();
|
||||
move |_, _, cancel| {
|
||||
started.fetch_add(1, Ordering::SeqCst);
|
||||
wait.lock().recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!cancel.is_cancelled(), "unchanged settings cancelled preparation");
|
||||
prepared.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
h.open("a.mk", "x");
|
||||
eventually(|| started.load(Ordering::SeqCst) > 0).await;
|
||||
h.mgr.reconfigure().await;
|
||||
release.send(()).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
assert_eq!(started.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn settings_changes_reach_servers_without_restarts() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]);
|
||||
@@ -1112,7 +1168,7 @@ async fn navigation_rename_format_and_decorations() {
|
||||
s.respond("textDocument/prepareRename", json!({ "range": r(1,0,1,3), "placeholder": "foo" }));
|
||||
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(1, 1)).await.unwrap(), Some(rng(1, 0, 1, 3)));
|
||||
s.respond("textDocument/rename", json!({ "changes": { uri.clone(): [{ "range": r(0,3,0,6), "newText": "bar" }, { "range": r(1,0,1,3), "newText": "bar" }] } }));
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into(), Default::default()).await.unwrap().edit;
|
||||
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
|
||||
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
|
||||
|
||||
@@ -1149,13 +1205,19 @@ async fn navigation_rename_format_and_decorations() {
|
||||
|
||||
// Nothing to prepare at the position: NotFound, so the UI can retry at the enclosing declaration.
|
||||
s.respond("textDocument/prepareCallHierarchy", json!(null));
|
||||
let err = h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming).await.unwrap_err();
|
||||
let err =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming, Default::default()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
|
||||
let item = json!({ "name": "foo", "kind": 12, "uri": uri, "range": r(0,0,0,11), "selectionRange": r(0,3,0,6), "data": "id" });
|
||||
s.respond("textDocument/prepareCallHierarchy", json!([item]));
|
||||
s.respond("callHierarchy/incomingCalls", json!([{ "from": { "name": "main", "kind": 12, "uri": uri, "range": r(1,0,1,6), "selectionRange": r(1,0,1,3) }, "fromRanges": [r(1,0,1,3)] }]));
|
||||
let calls = h.mgr.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap();
|
||||
let calls = h
|
||||
.mgr
|
||||
.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming, Default::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.items;
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "main");
|
||||
assert_eq!(calls[0].from_ranges, vec![rng(1, 0, 1, 3)]);
|
||||
@@ -1200,7 +1262,7 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
);
|
||||
|
||||
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(0, 15)).await.unwrap(), Some(rng(0, 14, 0, 19)));
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path).len(), 2);
|
||||
assert!(h.launcher.state("ts").requested("textDocument/rename").is_empty());
|
||||
|
||||
@@ -1212,7 +1274,7 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
"textDocument/rename",
|
||||
json!({ "changes": { uri: [{ "range": r(1, 0, 5), "newText": "heading: x" }] } }),
|
||||
);
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path), [(rng(0, 14, 0, 19), "heading".into()), (rng(1, 0, 1, 5), "heading: x".into())]);
|
||||
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
|
||||
}
|
||||
@@ -1748,7 +1810,7 @@ fn occurrences(uri: &str, text: &str) -> Vec<Value> {
|
||||
/// A server that only analyses its open documents (minus `blind` URIs), like
|
||||
/// Pyright for files outside its `include`.
|
||||
fn open_files_only(blind: Vec<String>) -> Responder {
|
||||
Arc::new(move |s, method, _| {
|
||||
Arc::new(move |s, method, params| {
|
||||
let docs: Vec<(String, String)> =
|
||||
s.docs.lock().iter().filter(|(u, _)| !blind.contains(u)).map(|(u, t)| (u.clone(), t.clone())).collect();
|
||||
let locs = || docs.iter().flat_map(|(u, t)| occurrences(u, t));
|
||||
@@ -1762,6 +1824,19 @@ fn open_files_only(blind: Vec<String>) -> Responder {
|
||||
}
|
||||
Some(json!({ "changes": changes }))
|
||||
}
|
||||
"textDocument/prepareCallHierarchy" => {
|
||||
let uri = ¶ms["textDocument"]["uri"];
|
||||
let at = json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
|
||||
Some(json!([{ "name": WORD, "kind": 12, "uri": uri, "range": at, "selectionRange": at }]))
|
||||
}
|
||||
"callHierarchy/incomingCalls" => Some(Value::Array(
|
||||
locs()
|
||||
.map(|l| {
|
||||
let from = json!({ "name": "caller", "kind": 12, "uri": l["uri"], "range": l["range"], "selectionRange": l["range"] });
|
||||
json!({ "from": from, "fromRanges": [l["range"]] })
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
@@ -1883,6 +1958,113 @@ async fn references_report_a_capped_candidate_search() {
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
|
||||
}
|
||||
|
||||
/// What each search widened with candidate files reports for the symbol at the start of `doc`.
|
||||
async fn incomplete_reports(h: &Harness, doc: DocId) -> Vec<(&'static str, Option<ReferencesIncomplete>)> {
|
||||
let at = Pos::new(0, 5);
|
||||
let cancel = ide_core::CancellationToken::new;
|
||||
vec![
|
||||
("references", h.mgr.references(doc, at, true, true, cancel()).await.unwrap().incomplete),
|
||||
("rename", h.mgr.rename(doc, at, "renamed".into(), cancel()).await.unwrap().incomplete),
|
||||
("callers", h.mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, cancel()).await.unwrap().incomplete),
|
||||
]
|
||||
}
|
||||
|
||||
fn expect_all(reports: Vec<(&'static str, Option<ReferencesIncomplete>)>, want: Option<ReferencesIncomplete>) {
|
||||
for (search, got) in reports {
|
||||
assert_eq!(got, want, "{search}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_widened_search_reports_a_capped_candidate_search() {
|
||||
let (h, s) = scope_harness();
|
||||
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
|
||||
files: vec![q.root.join("b.mk")],
|
||||
limit_hit: true,
|
||||
}));
|
||||
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_widened_search_reports_candidates_it_could_not_open() {
|
||||
let oversized = "target\n".repeat(200_000);
|
||||
// Name, content (`None`: not on disk), whether the search misses it.
|
||||
let cases: [(&str, Option<&[u8]>, bool); 4] = [
|
||||
("large.mk", Some(oversized.as_bytes()), true),
|
||||
("binary.mk", Some(b"target \xff\xfe\n"), true),
|
||||
("deleted.mk", None, false),
|
||||
("small.mk", Some(b"target\n"), false),
|
||||
];
|
||||
for (name, content, missed) in cases {
|
||||
let (h, s) = scope_harness();
|
||||
let file = h.root.join(name);
|
||||
if let Some(content) = content {
|
||||
std::fs::write(&file, content).unwrap();
|
||||
}
|
||||
let listed = file.clone();
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![listed.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let want = missed.then_some(ReferencesIncomplete::FilesSkipped);
|
||||
for (search, got) in incomplete_reports(&h, doc).await {
|
||||
assert_eq!(got, want, "{name}: {search}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_large_file_the_user_has_open_is_not_reported_as_skipped() {
|
||||
let (h, s) = scope_harness();
|
||||
let large = "target\n".repeat(200_000);
|
||||
let (_, path) = h.open("large.mk", &large);
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 2).await;
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![path.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
// `covered` would hide the file: answer as if the server had not listed it.
|
||||
*s.responder.lock() = Some(open_files_only(vec![uri_of(&h.root.join("large.mk"))]));
|
||||
expect_all(incomplete_reports(&h, doc).await, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rename_and_callers_can_be_cancelled() {
|
||||
let (h, s) = scope_harness();
|
||||
h.mgr.set_candidates_hook(Box::new(|_, cancel| {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
ide_lsp::CandidateFiles::default()
|
||||
}));
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let cancel_later = || {
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let later = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
later.cancel();
|
||||
});
|
||||
cancel
|
||||
};
|
||||
let err = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), cancel_later()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
let err =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, cancel_later()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() {
|
||||
let (h, s) = scope_harness();
|
||||
@@ -2063,7 +2245,7 @@ async fn rename_edits_files_outside_the_analysis_scope() {
|
||||
let (doc, a) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), Default::default()).await.unwrap().edit;
|
||||
let mut paths: Vec<PathBuf> = edit
|
||||
.files
|
||||
.iter()
|
||||
|
||||
@@ -54,6 +54,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
tree_sitter: None,
|
||||
language_servers: vec![server],
|
||||
debugger: None,
|
||||
@@ -296,7 +297,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
|
||||
/// Vue Language Server 3 in hybrid mode: its `tsserver/request`s go through the
|
||||
/// client to vtsls with `@vue/typescript-plugin` (issue #25). Set
|
||||
/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @vtsls/language-server`.
|
||||
/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@6 @vtsls/language-server`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires @vue/language-server and @vtsls/language-server (OPENIDEA_TEST_NPM_TOOLS)"]
|
||||
async fn vue_hybrid_mode_end_to_end() {
|
||||
@@ -443,13 +444,13 @@ async fn angular_language_server_end_to_end() {
|
||||
let count = Pos::new(4, 20);
|
||||
let range = mgr.prepare_rename(inline, count).await.unwrap();
|
||||
assert_eq!(range, Some(Range::new(Pos::new(4, 19), Pos::new(4, 24))));
|
||||
let edit = mgr.rename(inline, count, "total".into()).await.unwrap();
|
||||
let edit = mgr.rename(inline, count, "total".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edited_lines(&edit, &inline_ts), [4, 7], "{edit:?}");
|
||||
|
||||
// `title` in the class, which both servers rename: vtsls's edit and the
|
||||
// template's from the Angular server.
|
||||
let title = Pos::new(7, 3);
|
||||
let edit = mgr.rename(ts, title, "heading".into()).await.unwrap();
|
||||
let edit = mgr.rename(ts, title, "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!((edited_lines(&edit, &app_ts), edited_lines(&edit, &app_html)), (vec![7], vec![0]), "{edit:?}");
|
||||
// Its usage in the template, the declaration left out, from either file.
|
||||
for (doc, pos) in [(ts, title), (html, Pos::new(0, 8))] {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "ide-notebook"
|
||||
description = "Lossless native notebook documents and owned Jupyter kernels"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
uuid.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
jupyter-zmq-client.workspace = true
|
||||
jupyter-protocol.workspace = true
|
||||
zeromq.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory"] }
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
//! Lossless nbformat 4 documents. Runtime identities and execution bookkeeping
|
||||
//! never leak into existing notebook JSON. Only explicit mutations alter bytes.
|
||||
mod raw;
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use uuid::Uuid;
|
||||
|
||||
use self::raw::{Layout, Member, Node};
|
||||
use crate::kernel::KernelOutput;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookSnapshot {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub version: u32,
|
||||
pub dirty: bool,
|
||||
pub can_undo: bool,
|
||||
pub can_redo: bool,
|
||||
pub nbformat: u32,
|
||||
pub nbformat_minor: u32,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub metadata: Value,
|
||||
pub cells: Vec<NotebookCell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookCell {
|
||||
pub id: String,
|
||||
pub cell_type: String,
|
||||
pub source: String,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub metadata: Value,
|
||||
#[specta(type = Vec<specta_typescript::Unknown>)]
|
||||
pub outputs: Vec<Value>,
|
||||
pub execution_count: Option<u32>,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub attachments: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum NotebookEdit {
|
||||
SetSource {
|
||||
cell: String,
|
||||
source: String,
|
||||
},
|
||||
InsertCell {
|
||||
after: Option<String>,
|
||||
cell_type: String,
|
||||
},
|
||||
DeleteCell {
|
||||
cell: String,
|
||||
},
|
||||
MoveCell {
|
||||
cell: String,
|
||||
before: Option<String>,
|
||||
},
|
||||
SetCellType {
|
||||
cell: String,
|
||||
cell_type: String,
|
||||
},
|
||||
SetOutputs {
|
||||
cell: String,
|
||||
#[specta(type = Vec<specta_typescript::Unknown>)]
|
||||
outputs: Vec<Value>,
|
||||
execution_count: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CellState {
|
||||
id: String,
|
||||
display_ids: Vec<Option<String>>,
|
||||
pending_clear: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Fields {
|
||||
values: Vec<(String, Option<Member>)>,
|
||||
order: Vec<String>,
|
||||
layout: Layout,
|
||||
}
|
||||
|
||||
impl Fields {
|
||||
fn capture(node: &Node, keys: &[&str]) -> Self {
|
||||
let members = node.members().expect("validated cell object");
|
||||
Self {
|
||||
values: keys.iter().map(|key| ((*key).into(), members.iter().find(|m| m.key == *key).cloned())).collect(),
|
||||
order: members.iter().map(|m| m.key.clone()).collect(),
|
||||
layout: node.layout(),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&self, node: &mut Node) {
|
||||
node.restore_fields(&self.values, &self.order, &self.layout);
|
||||
}
|
||||
}
|
||||
|
||||
enum Change {
|
||||
Fields { cell: String, before: Fields, after: Fields },
|
||||
Insert { index: usize, node: Node, state: CellState, before: Layout, after: Layout },
|
||||
Delete { index: usize, node: Node, state: CellState, before: Layout, after: Layout },
|
||||
Move { from: usize, to: usize, before: Layout, after: Layout },
|
||||
}
|
||||
|
||||
struct History {
|
||||
change: Change,
|
||||
typing_cell: Option<String>,
|
||||
time: Instant,
|
||||
}
|
||||
|
||||
/// The caller serializes short document operations, not kernel awaits. Batch
|
||||
/// admission belongs to the host; set_execution_active protects backend history.
|
||||
pub struct NotebookDocument {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
root: Node,
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
baseline: Node,
|
||||
baseline_text: Arc<str>,
|
||||
cells: Vec<CellState>,
|
||||
version: u32,
|
||||
last_user_revision: u32,
|
||||
undo: Vec<History>,
|
||||
redo: Vec<History>,
|
||||
execution_active: bool,
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn load(path: &Path) -> IdeResult<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Self::from_text(path.to_path_buf(), text)
|
||||
}
|
||||
|
||||
pub fn from_text(path: PathBuf, text: String) -> IdeResult<Self> {
|
||||
let text: Arc<str> = text.into();
|
||||
let (root, prefix, suffix) = Node::parse(text.clone())?;
|
||||
validate_notebook(&root)?;
|
||||
let nodes = root.get("cells").and_then(Node::array).expect("validated cells");
|
||||
let mut persisted = HashMap::<String, usize>::new();
|
||||
for node in nodes {
|
||||
if let Some(id) = string_field(node, "id").filter(|id| valid_id(id)) {
|
||||
*persisted.entry(id.to_owned()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut used: HashSet<String> = persisted.keys().cloned().collect();
|
||||
let cells = nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let id = string_field(node, "id")
|
||||
.filter(|id| valid_id(id) && persisted.get(*id) == Some(&1))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| unique_id(&mut used));
|
||||
let outputs = node.get("outputs").and_then(Node::array).map_or(0, <[Node]>::len);
|
||||
CellState { id, display_ids: vec![None; outputs], pending_clear: false }
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
path,
|
||||
baseline: root.clone(),
|
||||
root,
|
||||
prefix,
|
||||
suffix,
|
||||
baseline_text: text,
|
||||
cells,
|
||||
version: 1,
|
||||
last_user_revision: 1,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
execution_active: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> NotebookSnapshot {
|
||||
NotebookSnapshot {
|
||||
id: self.id.clone(),
|
||||
path: self.path.to_string_lossy().into_owned(),
|
||||
version: self.version,
|
||||
dirty: self.is_dirty(),
|
||||
can_undo: !self.execution_active && !self.undo.is_empty(),
|
||||
can_redo: !self.execution_active && !self.redo.is_empty(),
|
||||
nbformat: uint_field(&self.root, "nbformat").expect("validated format"),
|
||||
nbformat_minor: uint_field(&self.root, "nbformat_minor").expect("validated minor"),
|
||||
metadata: self.root.get("metadata").expect("validated metadata").value(),
|
||||
cells: self
|
||||
.nodes()
|
||||
.iter()
|
||||
.zip(&self.cells)
|
||||
.map(|(node, state)| NotebookCell {
|
||||
id: state.id.clone(),
|
||||
cell_type: string_field(node, "cell_type").expect("validated type").into(),
|
||||
source: source(node).expect("validated source"),
|
||||
metadata: node.get("metadata").map_or_else(|| json!({}), Node::value),
|
||||
outputs: node
|
||||
.get("outputs")
|
||||
.and_then(Node::array)
|
||||
.map_or_else(Vec::new, |items| items.iter().map(Node::value).collect()),
|
||||
execution_count: uint_field(node, "execution_count"),
|
||||
attachments: node.get("attachments").map_or_else(|| json!({}), Node::value),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte equality, not a sticky flag. Unchanged raw siblings short-circuit
|
||||
/// without serializing the notebook on each source edit or output chunk.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!self.root.same_bytes(&self.baseline)
|
||||
}
|
||||
|
||||
/// The host uses these cheap fields for high-volume output events.
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Intended for save/export and behavioral tests, not per-chunk IPC.
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut text = String::with_capacity(self.baseline_text.len());
|
||||
text.push_str(&self.prefix);
|
||||
self.root.write(&mut text);
|
||||
text.push_str(&self.suffix);
|
||||
text
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
let disk = std::fs::read(&self.path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
out_of_sync("Notebook file was removed; reload or save a separate copy before overwriting")
|
||||
} else {
|
||||
error.into()
|
||||
}
|
||||
})?;
|
||||
if disk != self.baseline_text.as_bytes() {
|
||||
return Err(out_of_sync("Notebook changed on disk; reload or save a separate copy before overwriting"));
|
||||
}
|
||||
if self.is_dirty() {
|
||||
let text = self.to_text();
|
||||
ide_vfs::write_atomic_strict(&self.path, text.as_bytes())?;
|
||||
self.baseline_text = text.into();
|
||||
self.baseline = self.root.clone();
|
||||
}
|
||||
// A save is a typing-group boundary; undo after it must become dirty.
|
||||
self.break_typing_group();
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn reload(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
let mut replacement = Self::load(&self.path)?;
|
||||
replacement.id.clone_from(&self.id);
|
||||
replacement.version = self.version + 1;
|
||||
replacement.last_user_revision = replacement.version;
|
||||
*self = replacement;
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn set_execution_active(&mut self, active: bool) -> IdeResult<()> {
|
||||
if active && self.execution_active {
|
||||
return Err(IdeError::invalid("Notebook already has an active execution batch"));
|
||||
}
|
||||
self.execution_active = active;
|
||||
self.break_typing_group();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nodes(&self) -> &[Node] {
|
||||
self.root.get("cells").and_then(Node::array).expect("validated cells")
|
||||
}
|
||||
fn array_mut(&mut self) -> &mut Node {
|
||||
self.root.get_mut("cells").expect("validated cells")
|
||||
}
|
||||
fn cell_mut(&mut self, index: usize) -> &mut Node {
|
||||
self.array_mut().item_mut(index)
|
||||
}
|
||||
fn index(&self, id: &str) -> IdeResult<usize> {
|
||||
self.cells
|
||||
.iter()
|
||||
.position(|cell| cell.id == id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("Notebook cell {id} no longer exists")))
|
||||
}
|
||||
fn ensure_revision(&self) -> IdeResult<()> {
|
||||
if self.version == u32::MAX {
|
||||
Err(IdeError::unavailable("Notebook revision limit reached; save and reopen the document"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn require_inactive(&self) -> IdeResult<()> {
|
||||
if self.execution_active {
|
||||
Err(IdeError::invalid("Stop and drain notebook execution before undo, redo, or reload"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn break_typing_group(&mut self) {
|
||||
if let Some(last) = self.undo.last_mut() {
|
||||
last.typing_cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn out_of_sync(message: &str) -> IdeError {
|
||||
IdeError::new(ErrorKind::OutOfSync, message)
|
||||
}
|
||||
fn string_field<'a>(node: &'a Node, key: &str) -> Option<&'a str> {
|
||||
node.get(key)?.string()
|
||||
}
|
||||
fn uint_field(node: &Node, key: &str) -> Option<u32> {
|
||||
node.get(key)?.uint()
|
||||
}
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
fn unique_id(used: &mut HashSet<String>) -> String {
|
||||
loop {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
if used.insert(id.clone()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_value(value: &Value) -> IdeResult<String> {
|
||||
match value {
|
||||
Value::String(text) => Ok(text.clone()),
|
||||
Value::Array(parts) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
text.push_str(
|
||||
part.as_str().ok_or_else(|| IdeError::invalid("Notebook text arrays must contain only strings"))?,
|
||||
);
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
_ => Err(IdeError::invalid("Notebook source/text must be a string or string array")),
|
||||
}
|
||||
}
|
||||
|
||||
fn source(node: &Node) -> IdeResult<String> {
|
||||
match node.get("source") {
|
||||
Some(value) => node_text(value),
|
||||
None if !matches!(string_field(node, "cell_type"), Some("code" | "markdown" | "raw")) => Ok(String::new()),
|
||||
None => Err(IdeError::invalid("Notebook cell source is missing")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_notebook(root: &Node) -> IdeResult<()> {
|
||||
if root.members().is_none()
|
||||
|| uint_field(root, "nbformat") != Some(4)
|
||||
|| uint_field(root, "nbformat_minor").is_none()
|
||||
{
|
||||
return Err(IdeError::invalid(
|
||||
"Only nbformat 4 notebooks with a nonnegative integer minor version are supported; the file has not been modified",
|
||||
));
|
||||
}
|
||||
if root.get("metadata").is_none_or(|n| n.members().is_none()) {
|
||||
return Err(IdeError::invalid("Notebook metadata must be an object"));
|
||||
}
|
||||
let cells =
|
||||
root.get("cells").and_then(Node::array).ok_or_else(|| IdeError::invalid("Notebook cells must be an array"))?;
|
||||
for node in cells {
|
||||
if node.members().is_none() || string_field(node, "cell_type").is_none() {
|
||||
return Err(IdeError::invalid("Notebook cells must be objects with a string cell_type"));
|
||||
}
|
||||
let known = matches!(string_field(node, "cell_type"), Some("code" | "markdown" | "raw"));
|
||||
match node.get("source") {
|
||||
Some(value) => validate_text_node(value)?,
|
||||
None if known => return Err(IdeError::invalid("Notebook cell source is missing")),
|
||||
None => {}
|
||||
}
|
||||
if known && node.get("metadata").is_none() {
|
||||
return Err(IdeError::invalid("Notebook cell metadata is missing"));
|
||||
}
|
||||
if let Some(metadata) = node.get("metadata")
|
||||
&& metadata.members().is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell metadata must be an object"));
|
||||
}
|
||||
if let Some(attachments) = node.get("attachments")
|
||||
&& attachments.members().is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell attachments must be an object"));
|
||||
}
|
||||
if let Some(outputs) = node.get("outputs") {
|
||||
let outputs = outputs.array().ok_or_else(|| IdeError::invalid("Cell outputs must be an array"))?;
|
||||
for output in outputs {
|
||||
validate_output_node(output)?;
|
||||
}
|
||||
}
|
||||
if let Some(count) = node.get("execution_count")
|
||||
&& !count.is_null()
|
||||
&& uint_field(node, "execution_count").is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell execution_count must be null or a nonnegative 32-bit integer"));
|
||||
}
|
||||
if string_field(node, "cell_type") == Some("code")
|
||||
&& (node.get("outputs").is_none() || node.get("execution_count").is_none())
|
||||
{
|
||||
return Err(IdeError::invalid("Code cells require outputs and execution_count"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn edit(&mut self, base_version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
|
||||
if base_version < self.last_user_revision || base_version > self.version {
|
||||
return Err(out_of_sync("Notebook was edited elsewhere; refresh and rebase pending edits"));
|
||||
}
|
||||
self.ensure_revision()?;
|
||||
if let NotebookEdit::SetOutputs { cell, outputs, execution_count } = edit {
|
||||
let index = self.code_index(&cell)?;
|
||||
let outputs = outputs.into_iter().map(persisted_output).collect::<IdeResult<Vec<_>>>()?;
|
||||
let node = Node::new(Value::Array(outputs));
|
||||
for output in node.array().expect("outputs array") {
|
||||
validate_output_node(output)?;
|
||||
}
|
||||
let count = Node::new(json!(execution_count));
|
||||
let changed = !self.nodes()[index].get("outputs").expect("code outputs").same_bytes(&node)
|
||||
|| !self.nodes()[index].get("execution_count").expect("code count").same_bytes(&count);
|
||||
if changed {
|
||||
let len = node.array().expect("outputs array").len();
|
||||
self.cell_mut(index).set("outputs", Some(node));
|
||||
self.cell_mut(index).set("execution_count", Some(count));
|
||||
self.cells[index].display_ids = vec![None; len];
|
||||
self.cells[index].pending_clear = false;
|
||||
self.version += 1;
|
||||
}
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let typing_cell = match &edit {
|
||||
NotebookEdit::SetSource { cell, .. } => Some(cell.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let change = match edit {
|
||||
NotebookEdit::SetSource { cell, source: text } => {
|
||||
let index = self.index(&cell)?;
|
||||
if self.nodes()[index].get("source").is_some_and(|old| text_matches(old, &text)) {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let before = Fields::capture(&self.nodes()[index], &["source"]);
|
||||
let value = rewritten_source(self.nodes()[index].get("source"), text);
|
||||
self.cell_mut(index).set("source", Some(value));
|
||||
let after = Fields::capture(&self.nodes()[index], &["source"]);
|
||||
Change::Fields { cell, before, after }
|
||||
}
|
||||
NotebookEdit::InsertCell { after, cell_type } => {
|
||||
validate_cell_type(&cell_type)?;
|
||||
let index = after.map_or(Ok(0), |id| self.index(&id).map(|index| index + 1))?;
|
||||
let id = unique_id(&mut self.cells.iter().map(|state| state.id.clone()).collect());
|
||||
let mut value = json!({ "cell_type": cell_type, "metadata": {}, "source": [] });
|
||||
if uint_field(&self.root, "nbformat_minor").expect("minor") >= 5 {
|
||||
value["id"] = json!(id);
|
||||
}
|
||||
if value["cell_type"] == "code" {
|
||||
value["outputs"] = json!([]);
|
||||
value["execution_count"] = Value::Null;
|
||||
}
|
||||
let node = Node::new(value);
|
||||
let state = CellState { id, display_ids: Vec::new(), pending_clear: false };
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
self.array_mut().insert(index, node.clone());
|
||||
self.cells.insert(index, state.clone());
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Insert { index, node, state, before, after }
|
||||
}
|
||||
NotebookEdit::DeleteCell { cell } => {
|
||||
let index = self.index(&cell)?;
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
let node = self.array_mut().remove(index);
|
||||
let state = self.cells.remove(index);
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Delete { index, node, state, before, after }
|
||||
}
|
||||
NotebookEdit::MoveCell { cell, before: target } => {
|
||||
let from = self.index(&cell)?;
|
||||
let target = target.map_or(Ok(self.cells.len()), |id| self.index(&id))?;
|
||||
let to = if target > from { target - 1 } else { target };
|
||||
if from == to {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
self.move_cell(from, to);
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Move { from, to, before, after }
|
||||
}
|
||||
NotebookEdit::SetCellType { cell, cell_type } => {
|
||||
validate_cell_type(&cell_type)?;
|
||||
let index = self.index(&cell)?;
|
||||
if string_field(&self.nodes()[index], "cell_type") == Some(cell_type.as_str()) {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let keys = ["cell_type", "outputs", "execution_count"];
|
||||
let before = Fields::capture(&self.nodes()[index], &keys);
|
||||
let node = self.cell_mut(index);
|
||||
node.set("cell_type", Some(Node::new(json!(cell_type))));
|
||||
if cell_type == "code" {
|
||||
node.set("outputs", Some(Node::new(json!([]))));
|
||||
node.set("execution_count", Some(Node::new(Value::Null)));
|
||||
} else {
|
||||
node.set("outputs", None);
|
||||
node.set("execution_count", None);
|
||||
}
|
||||
self.cells[index].display_ids.clear();
|
||||
self.cells[index].pending_clear = false;
|
||||
let after = Fields::capture(&self.nodes()[index], &keys);
|
||||
Change::Fields { cell, before, after }
|
||||
}
|
||||
NotebookEdit::SetOutputs { .. } => unreachable!("output edits handled above"),
|
||||
};
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
self.redo.clear();
|
||||
let now = Instant::now();
|
||||
let grouped = typing_cell.as_ref().is_some_and(|cell| {
|
||||
self.undo.last().is_some_and(|last| {
|
||||
last.typing_cell.as_ref() == Some(cell) && now.duration_since(last.time) < Duration::from_millis(750)
|
||||
})
|
||||
});
|
||||
if grouped {
|
||||
if let (
|
||||
Some(History { change: Change::Fields { after: previous, .. }, time, .. }),
|
||||
Change::Fields { after, .. },
|
||||
) = (self.undo.last_mut(), change)
|
||||
{
|
||||
*previous = after;
|
||||
*time = now;
|
||||
}
|
||||
} else {
|
||||
self.undo.push(History { change, typing_cell, time: now });
|
||||
// Bounded operation count; streaming outputs never enter history.
|
||||
if self.undo.len() > 200 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
if let Some(mut history) = self.undo.pop() {
|
||||
if let Err(error) = self.apply_history(&mut history.change, false) {
|
||||
self.undo.push(history);
|
||||
return Err(error);
|
||||
}
|
||||
history.typing_cell = None;
|
||||
self.redo.push(history);
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
}
|
||||
self.break_typing_group();
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
if let Some(mut history) = self.redo.pop() {
|
||||
if let Err(error) = self.apply_history(&mut history.change, true) {
|
||||
self.redo.push(history);
|
||||
return Err(error);
|
||||
}
|
||||
history.typing_cell = None;
|
||||
self.undo.push(history);
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
fn apply_history(&mut self, change: &mut Change, forward: bool) -> IdeResult<()> {
|
||||
let insertion_change = matches!(change, Change::Insert { .. });
|
||||
match change {
|
||||
Change::Fields { cell, before, after } => {
|
||||
let index = self.index(cell)?;
|
||||
let keys: Vec<&str> = before.values.iter().map(|(key, _)| key.as_str()).collect();
|
||||
let current = Fields::capture(&self.nodes()[index], &keys);
|
||||
if forward {
|
||||
*before = current;
|
||||
} else {
|
||||
*after = current;
|
||||
}
|
||||
let fields = if forward { after } else { before };
|
||||
fields.restore(self.cell_mut(index));
|
||||
if fields.values.iter().any(|(key, _)| key == "outputs") {
|
||||
let len = self.nodes()[index].get("outputs").and_then(Node::array).map_or(0, <[Node]>::len);
|
||||
self.cells[index].display_ids = vec![None; len];
|
||||
self.cells[index].pending_clear = false;
|
||||
}
|
||||
}
|
||||
Change::Insert { index, node, state, before, after }
|
||||
| Change::Delete { index, node, state, before, after } => {
|
||||
let insert = insertion_change == forward;
|
||||
if insert {
|
||||
self.array_mut().insert(*index, node.clone());
|
||||
self.cells.insert(*index, state.clone());
|
||||
} else {
|
||||
*node = self.array_mut().remove(*index);
|
||||
*state = self.cells.remove(*index);
|
||||
}
|
||||
self.array_mut().restore_layout(if forward { after } else { before }.clone());
|
||||
}
|
||||
Change::Move { from, to, before, after } => {
|
||||
if forward {
|
||||
self.move_cell(*from, *to);
|
||||
} else {
|
||||
self.move_cell(*to, *from);
|
||||
}
|
||||
self.array_mut().restore_layout(if forward { after } else { before }.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_cell(&mut self, from: usize, to: usize) {
|
||||
let node = self.array_mut().remove(from);
|
||||
self.array_mut().insert(to, node);
|
||||
let state = self.cells.remove(from);
|
||||
self.cells.insert(to, state);
|
||||
}
|
||||
|
||||
fn code_index(&self, cell: &str) -> IdeResult<usize> {
|
||||
let index = self.index(cell)?;
|
||||
if string_field(&self.nodes()[index], "cell_type") != Some("code") {
|
||||
return Err(IdeError::invalid("Only code cells accept execution output"));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cell_type(cell_type: &str) -> IdeResult<()> {
|
||||
if matches!(cell_type, "code" | "markdown" | "raw") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::invalid("New/converted cell type must be code, markdown, or raw"))
|
||||
}
|
||||
}
|
||||
|
||||
fn persisted_output(mut value: Value) -> IdeResult<Value> {
|
||||
let object = value.as_object_mut().ok_or_else(|| IdeError::invalid("Notebook outputs must be objects"))?;
|
||||
if !object.get("output_type").is_some_and(Value::is_string) {
|
||||
return Err(IdeError::invalid("Notebook outputs require a string output_type"));
|
||||
}
|
||||
object.remove("transient");
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn begin_execution(&mut self, cell: &str) -> IdeResult<()> {
|
||||
let index = self.code_index(cell)?;
|
||||
self.ensure_revision()?;
|
||||
let changed = self.clear_outputs(index);
|
||||
let count_changed = !self.nodes()[index].get("execution_count").expect("code count").is_null();
|
||||
if count_changed {
|
||||
self.cell_mut(index).set("execution_count", Some(Node::new(Value::Null)));
|
||||
}
|
||||
if changed || count_changed {
|
||||
self.version += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits one IOPub event and returns all cells whose persisted bytes
|
||||
/// changed. Display identifiers and deferred clears exist only in memory.
|
||||
pub fn record_output(&mut self, cell: &str, event: &KernelOutput) -> IdeResult<Vec<String>> {
|
||||
let index = self.code_index(cell)?;
|
||||
self.ensure_revision()?;
|
||||
let content = &event.content;
|
||||
let mut changed = Vec::new();
|
||||
match event.kind.as_str() {
|
||||
"execute_input" => {
|
||||
let count = content
|
||||
.get("execution_count")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| IdeError::invalid("Kernel execute_input lacks a valid execution_count"))?;
|
||||
if uint_field(&self.nodes()[index], "execution_count") != Some(count) {
|
||||
self.cell_mut(index).set("execution_count", Some(Node::new(json!(count))));
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"clear_output" => {
|
||||
if content.get("wait").and_then(Value::as_bool).unwrap_or(false) {
|
||||
self.cells[index].pending_clear = true;
|
||||
} else if self.clear_outputs(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"stream" => {
|
||||
let name = content
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::invalid("Kernel stream lacks a name"))?;
|
||||
let text =
|
||||
text_value(content.get("text").ok_or_else(|| IdeError::invalid("Kernel stream lacks text"))?)?;
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
let outputs = self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs");
|
||||
let last = outputs.len().checked_sub(1).filter(|last| {
|
||||
string_field(&outputs[*last], "output_type") == Some("stream")
|
||||
&& string_field(&outputs[*last], "name") == Some(name)
|
||||
});
|
||||
if let Some(last) = last {
|
||||
if !text.is_empty() {
|
||||
let old = outputs[last]
|
||||
.get("text")
|
||||
.ok_or_else(|| IdeError::invalid("Stored stream output lacks text"))?;
|
||||
let combined = if old.string().is_some() { None } else { Some(node_text(old)?) };
|
||||
let stream = self.cell_mut(index).get_mut("outputs").expect("outputs").item_mut(last);
|
||||
if let Some(mut combined) = combined {
|
||||
combined.push_str(&text);
|
||||
stream.set("text", Some(Node::new(Value::String(combined))));
|
||||
} else {
|
||||
stream.get_mut("text").expect("stream text").append_string(&text);
|
||||
}
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
} else {
|
||||
let mut value = content
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::invalid("Kernel stream content must be an object"))?;
|
||||
value.insert("output_type".into(), json!("stream"));
|
||||
value.insert("text".into(), Value::String(text));
|
||||
value.remove("transient");
|
||||
self.append_output(index, Node::new(Value::Object(value)), None);
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"display_data" | "execute_result" | "error" => {
|
||||
// Validate before applying a pending clear: malformed messages
|
||||
// must not erase existing output as a side effect of failure.
|
||||
let mut value = content
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::invalid("Kernel output content must be an object"))?;
|
||||
if event.kind == "error" {
|
||||
if !value.get("ename").is_some_and(Value::is_string)
|
||||
|| !value.get("evalue").is_some_and(Value::is_string)
|
||||
|| !value
|
||||
.get("traceback")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|lines| lines.iter().all(Value::is_string))
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel error output is malformed"));
|
||||
}
|
||||
} else if !value.get("data").is_some_and(Value::is_object)
|
||||
|| !value.get("metadata").is_some_and(Value::is_object)
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel display output lacks a MIME bundle or metadata"));
|
||||
}
|
||||
if event.kind == "execute_result"
|
||||
&& value.get("execution_count").and_then(Value::as_u64).is_none_or(|n| u32::try_from(n).is_err())
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel execute_result lacks a valid execution_count"));
|
||||
}
|
||||
let display_id = display_id(content);
|
||||
value.remove("transient");
|
||||
value.insert("output_type".into(), json!(event.kind));
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
self.append_output(index, Node::new(Value::Object(value)), display_id);
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
"update_display_data" => {
|
||||
let Some(id) = display_id(content) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let data = content
|
||||
.get("data")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| IdeError::invalid("Display update lacks a MIME bundle"))?;
|
||||
let metadata = content
|
||||
.get("metadata")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| IdeError::invalid("Display update lacks metadata"))?;
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
for current in 0..self.cells.len() {
|
||||
let matches: Vec<usize> = self.cells[current]
|
||||
.display_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(output, display)| (display.as_deref() == Some(id.as_str())).then_some(output))
|
||||
.collect();
|
||||
let mut cell_changed = false;
|
||||
for output in matches {
|
||||
let Some(old) = self.nodes()[current]
|
||||
.get("outputs")
|
||||
.and_then(Node::array)
|
||||
.and_then(|outputs| outputs.get(output))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let data_node = Node::new(data.clone());
|
||||
let metadata_node = Node::new(metadata.clone());
|
||||
let replace_data = old.get("data").is_none_or(|old| !old.same_bytes(&data_node));
|
||||
let replace_metadata = old.get("metadata").is_none_or(|old| !old.same_bytes(&metadata_node));
|
||||
if replace_data || replace_metadata {
|
||||
let output =
|
||||
self.cell_mut(current).get_mut("outputs").expect("mapped outputs").item_mut(output);
|
||||
if replace_data {
|
||||
output.set("data", Some(data_node));
|
||||
}
|
||||
if replace_metadata {
|
||||
output.set("metadata", Some(metadata_node));
|
||||
}
|
||||
cell_changed = true;
|
||||
}
|
||||
}
|
||||
if cell_changed {
|
||||
changed.push(self.cells[current].id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
changed.retain(|id| seen.insert(id.clone()));
|
||||
if !changed.is_empty() {
|
||||
self.version += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn append_output(&mut self, index: usize, node: Node, display_id: Option<String>) {
|
||||
let len = self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs").len();
|
||||
self.cell_mut(index).get_mut("outputs").expect("outputs").insert(len, node);
|
||||
self.cells[index].display_ids.push(display_id);
|
||||
}
|
||||
|
||||
fn clear_pending(&mut self, index: usize) -> bool {
|
||||
if self.cells[index].pending_clear { self.clear_outputs(index) } else { false }
|
||||
}
|
||||
|
||||
fn clear_outputs(&mut self, index: usize) -> bool {
|
||||
self.cells[index].pending_clear = false;
|
||||
self.cells[index].display_ids.clear();
|
||||
let changed = !self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs").is_empty();
|
||||
if changed {
|
||||
self.cell_mut(index).set("outputs", Some(Node::new(json!([]))));
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
fn display_id(content: &Value) -> Option<String> {
|
||||
content.get("transient")?.get("display_id")?.as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
fn node_text(node: &Node) -> IdeResult<String> {
|
||||
if let Some(text) = node.string() {
|
||||
return Ok(text.into());
|
||||
}
|
||||
if let Some(parts) = node.array() {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
text.push_str(
|
||||
part.string().ok_or_else(|| IdeError::invalid("Notebook text arrays must contain only strings"))?,
|
||||
);
|
||||
}
|
||||
return Ok(text);
|
||||
}
|
||||
Err(IdeError::invalid("Notebook source/text must be a string or string array"))
|
||||
}
|
||||
|
||||
fn rewritten_source(old: Option<&Node>, text: String) -> Node {
|
||||
let Some(old) = old.filter(|node| node.array().is_some()) else {
|
||||
return Node::new(Value::String(text));
|
||||
};
|
||||
let parts = old.array().expect("source list");
|
||||
let mut first = 0;
|
||||
let mut start = 0;
|
||||
while first < parts.len() {
|
||||
let part = parts[first].string().expect("validated source text");
|
||||
if !text[start..].starts_with(part) {
|
||||
break;
|
||||
}
|
||||
start += part.len();
|
||||
first += 1;
|
||||
}
|
||||
let mut last = parts.len();
|
||||
let mut end = text.len();
|
||||
while last > first {
|
||||
let part = parts[last - 1].string().expect("validated source text");
|
||||
if end - start < part.len() || !text[start..end].ends_with(part) {
|
||||
break;
|
||||
}
|
||||
end -= part.len();
|
||||
last -= 1;
|
||||
}
|
||||
let mut updated = old.clone();
|
||||
for index in (first..last).rev() {
|
||||
updated.remove(index);
|
||||
}
|
||||
for (offset, part) in text[start..end].split_inclusive('\n').enumerate() {
|
||||
updated.insert(first + offset, Node::new(Value::String(part.into())));
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
fn validate_text_node(node: &Node) -> IdeResult<()> {
|
||||
if node.string().is_some() || node.array().is_some_and(|items| items.iter().all(|part| part.string().is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::invalid("Notebook source/text must be a string or string array"))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_matches(node: &Node, text: &str) -> bool {
|
||||
if let Some(old) = node.string() {
|
||||
return old == text;
|
||||
}
|
||||
let Some(parts) = node.array() else {
|
||||
return false;
|
||||
};
|
||||
let mut remaining = text;
|
||||
for part in parts {
|
||||
let Some(part) = part.string() else {
|
||||
return false;
|
||||
};
|
||||
let Some(rest) = remaining.strip_prefix(part) else {
|
||||
return false;
|
||||
};
|
||||
remaining = rest;
|
||||
}
|
||||
remaining.is_empty()
|
||||
}
|
||||
|
||||
fn validate_output_node(node: &Node) -> IdeResult<()> {
|
||||
let kind = string_field(node, "output_type")
|
||||
.ok_or_else(|| IdeError::invalid("Notebook outputs must be objects with a string output_type"))?;
|
||||
match kind {
|
||||
"stream" => {
|
||||
if string_field(node, "name").is_none() {
|
||||
return Err(IdeError::invalid("Stored stream output lacks a name"));
|
||||
}
|
||||
validate_text_node(node.get("text").ok_or_else(|| IdeError::invalid("Stored stream output lacks text"))?)?;
|
||||
}
|
||||
"display_data" | "execute_result" => {
|
||||
if node.get("data").is_none_or(|n| n.members().is_none())
|
||||
|| node.get("metadata").is_none_or(|n| n.members().is_none())
|
||||
{
|
||||
return Err(IdeError::invalid("Stored display output requires data and metadata objects"));
|
||||
}
|
||||
if kind == "execute_result" && uint_field(node, "execution_count").is_none() {
|
||||
return Err(IdeError::invalid("Stored execute_result lacks a valid execution_count"));
|
||||
}
|
||||
}
|
||||
"error"
|
||||
if (string_field(node, "ename").is_none()
|
||||
|| string_field(node, "evalue").is_none()
|
||||
|| !node
|
||||
.get("traceback")
|
||||
.and_then(Node::array)
|
||||
.is_some_and(|lines| lines.iter().all(|line| line.string().is_some()))) =>
|
||||
{
|
||||
return Err(IdeError::invalid("Stored error output is malformed"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! JSON syntax retained as shared original spans. Mutating one value never
|
||||
//! normalizes its siblings, property spelling, whitespace, or container order.
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Raw {
|
||||
text: Arc<str>,
|
||||
span: Range<usize>,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
fn text(&self) -> &str {
|
||||
&self.text[self.span.clone()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Node {
|
||||
raw: Option<Raw>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Kind {
|
||||
Scalar(Arc<Value>),
|
||||
Object(Vec<Member>, Layout),
|
||||
Array(Vec<Node>, Layout),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Member {
|
||||
pub key: String,
|
||||
head: String,
|
||||
pub value: Node,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Layout {
|
||||
prefix: String,
|
||||
separators: Vec<String>,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn compact() -> Self {
|
||||
Self { prefix: String::new(), separators: Vec::new(), suffix: String::new() }
|
||||
}
|
||||
|
||||
fn separator(&self, index: usize) -> &str {
|
||||
self.separators.get(index).map(String::as_str).unwrap_or(",")
|
||||
}
|
||||
|
||||
fn remove(&mut self, index: usize) {
|
||||
if !self.separators.is_empty() {
|
||||
self.separators.remove(index.saturating_sub(1).min(self.separators.len() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, index: usize, previous_len: usize) {
|
||||
if previous_len > 0 {
|
||||
let separator = self.separators.first().cloned().unwrap_or_else(|| ",".into());
|
||||
self.separators.insert(index.saturating_sub(1).min(self.separators.len()), separator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn parse(text: Arc<str>) -> IdeResult<(Self, String, String)> {
|
||||
// serde supplies strict JSON/UTF-8 string and numeric validation. The
|
||||
// scanner below only locates the already validated syntax spans.
|
||||
let start = usize::from(text.starts_with('\u{feff}')) * 3;
|
||||
serde_json::from_str::<serde::de::IgnoredAny>(&text[start..])
|
||||
.map_err(|e| IdeError::invalid(format!("Invalid notebook JSON: {e}")))?;
|
||||
let mut parser = Parser { text: text.clone(), pos: start };
|
||||
parser.space();
|
||||
let begin = parser.pos;
|
||||
let root = parser.node()?;
|
||||
Ok((root, text[..begin].into(), text[parser.pos..].into()))
|
||||
}
|
||||
|
||||
pub fn new(value: Value) -> Self {
|
||||
match value {
|
||||
Value::Object(map) => Self {
|
||||
raw: None,
|
||||
kind: Kind::Object(
|
||||
map.into_iter()
|
||||
.map(|(key, value)| Member {
|
||||
head: format!("{}:", serde_json::to_string(&key).expect("JSON key")),
|
||||
key,
|
||||
value: Self::new(value),
|
||||
})
|
||||
.collect(),
|
||||
Layout::compact(),
|
||||
),
|
||||
},
|
||||
Value::Array(values) => {
|
||||
Self { raw: None, kind: Kind::Array(values.into_iter().map(Self::new).collect(), Layout::compact()) }
|
||||
}
|
||||
value => Self { raw: None, kind: Kind::Scalar(Arc::new(value)) },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Value {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_ref().clone(),
|
||||
Kind::Array(items, _) => Value::Array(items.iter().map(Self::value).collect()),
|
||||
Kind::Object(items, _) => Value::Object(items.iter().map(|m| (m.key.clone(), m.value.value())).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Self> {
|
||||
match &self.kind {
|
||||
Kind::Object(items, _) => items.iter().find(|m| m.key == key).map(|m| &m.value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn string(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_str(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uint(&self) -> Option<u32> {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_u64().and_then(|n| u32::try_from(n).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_null(&self) -> bool {
|
||||
matches!(&self.kind, Kind::Scalar(value) if value.is_null())
|
||||
}
|
||||
|
||||
pub fn append_string(&mut self, text: &str) {
|
||||
self.raw = None;
|
||||
let Kind::Scalar(value) = &mut self.kind else { unreachable!("validated string") };
|
||||
let Value::String(current) = Arc::make_mut(value) else { unreachable!("validated string") };
|
||||
current.push_str(text);
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut Self> {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Object(items, _) => items.iter_mut().find(|m| m.key == key).map(|m| &mut m.value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn members(&self) -> Option<&[Member]> {
|
||||
match &self.kind {
|
||||
Kind::Object(items, _) => Some(items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn array(&self) -> Option<&[Self]> {
|
||||
match &self.kind {
|
||||
Kind::Array(items, _) => Some(items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_mut(&mut self, index: usize) -> &mut Self {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Array(items, _) => &mut items[index],
|
||||
_ => unreachable!("validated array"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> Layout {
|
||||
match &self.kind {
|
||||
Kind::Array(_, layout) | Kind::Object(_, layout) => layout.clone(),
|
||||
_ => unreachable!("container layout"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_layout(&mut self, layout: Layout) {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Array(_, old) | Kind::Object(_, old) => *old = layout,
|
||||
_ => unreachable!("container layout"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: &str, value: Option<Self>) {
|
||||
self.raw = None;
|
||||
let Kind::Object(items, layout) = &mut self.kind else { unreachable!("validated object") };
|
||||
match (items.iter().position(|m| m.key == key), value) {
|
||||
(Some(index), Some(value)) => items[index].value = value,
|
||||
(Some(index), None) => {
|
||||
items.remove(index);
|
||||
layout.remove(index);
|
||||
}
|
||||
(None, Some(value)) => {
|
||||
layout.insert(items.len(), items.len());
|
||||
items.push(Member {
|
||||
key: key.into(),
|
||||
head: format!("{}:", serde_json::to_string(key).expect("JSON key")),
|
||||
value,
|
||||
});
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_fields(&mut self, fields: &[(String, Option<Member>)], order: &[String], layout: &Layout) {
|
||||
for (key, field) in fields {
|
||||
self.set(key, field.as_ref().map(|m| m.value.clone()));
|
||||
}
|
||||
let Kind::Object(items, old_layout) = &mut self.kind else { unreachable!("validated object") };
|
||||
for (key, field) in fields {
|
||||
if let (Some(saved), Some(current)) = (field, items.iter_mut().find(|m| &m.key == key)) {
|
||||
current.head.clone_from(&saved.head);
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|m| order.iter().position(|key| key == &m.key).unwrap_or(usize::MAX));
|
||||
if items.len() == order.len() {
|
||||
*old_layout = layout.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, index: usize, node: Self) {
|
||||
self.raw = None;
|
||||
let Kind::Array(items, layout) = &mut self.kind else { unreachable!("validated array") };
|
||||
layout.insert(index, items.len());
|
||||
items.insert(index, node);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, index: usize) -> Self {
|
||||
self.raw = None;
|
||||
let Kind::Array(items, layout) = &mut self.kind else { unreachable!("validated array") };
|
||||
layout.remove(index);
|
||||
items.remove(index)
|
||||
}
|
||||
|
||||
pub fn same_bytes(&self, other: &Self) -> bool {
|
||||
if let (Some(a), Some(b)) = (&self.raw, &other.raw) {
|
||||
return (Arc::ptr_eq(&a.text, &b.text) && a.span == b.span) || a.text() == b.text();
|
||||
}
|
||||
match (&self.kind, &other.kind) {
|
||||
(Kind::Scalar(a), Kind::Scalar(b)) => {
|
||||
if a != b {
|
||||
return false;
|
||||
}
|
||||
match (&self.raw, &other.raw) {
|
||||
(Some(raw), None) | (None, Some(raw)) => {
|
||||
raw.text() == serde_json::to_string(a.as_ref()).expect("JSON value")
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
(Kind::Object(a, al), Kind::Object(b, bl)) => {
|
||||
a.len() == b.len()
|
||||
&& same_layout(al, bl, a.len())
|
||||
&& a.iter().zip(b).all(|(a, b)| a.key == b.key && a.head == b.head && a.value.same_bytes(&b.value))
|
||||
}
|
||||
(Kind::Array(a, al), Kind::Array(b, bl)) => {
|
||||
a.len() == b.len() && same_layout(al, bl, a.len()) && a.iter().zip(b).all(|(a, b)| a.same_bytes(b))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, output: &mut String) {
|
||||
if let Some(raw) = &self.raw {
|
||||
output.push_str(raw.text());
|
||||
return;
|
||||
}
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => output.push_str(&serde_json::to_string(value.as_ref()).expect("JSON value")),
|
||||
Kind::Object(items, layout) => {
|
||||
output.push('{');
|
||||
output.push_str(&layout.prefix);
|
||||
for (index, member) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(layout.separator(index - 1));
|
||||
}
|
||||
output.push_str(&member.head);
|
||||
member.value.write(output);
|
||||
}
|
||||
output.push_str(&layout.suffix);
|
||||
output.push('}');
|
||||
}
|
||||
Kind::Array(items, layout) => {
|
||||
output.push('[');
|
||||
output.push_str(&layout.prefix);
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(layout.separator(index - 1));
|
||||
}
|
||||
item.write(output);
|
||||
}
|
||||
output.push_str(&layout.suffix);
|
||||
output.push(']');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_layout(a: &Layout, b: &Layout, count: usize) -> bool {
|
||||
a.prefix == b.prefix
|
||||
&& a.suffix == b.suffix
|
||||
&& (0..count.saturating_sub(1)).all(|index| a.separator(index) == b.separator(index))
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
text: Arc<str>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn space(&mut self) {
|
||||
while self.text.as_bytes().get(self.pos).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn string_end(&mut self) {
|
||||
self.pos += 1;
|
||||
loop {
|
||||
match self.text.as_bytes()[self.pos] {
|
||||
b'\\' => self.pos += 2,
|
||||
b'"' => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
_ => self.pos += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node(&mut self) -> IdeResult<Node> {
|
||||
let start = self.pos;
|
||||
let kind = match self.text.as_bytes()[self.pos] {
|
||||
b'{' | b'[' => {
|
||||
let object = self.text.as_bytes()[self.pos] == b'{';
|
||||
let close = if object { b'}' } else { b']' };
|
||||
self.pos += 1;
|
||||
let gap = self.pos;
|
||||
self.space();
|
||||
let mut layout =
|
||||
Layout { prefix: self.text[gap..self.pos].into(), separators: Vec::new(), suffix: String::new() };
|
||||
let mut members = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
if self.text.as_bytes()[self.pos] != close {
|
||||
loop {
|
||||
if object {
|
||||
let key_start = self.pos;
|
||||
self.string_end();
|
||||
let key: String = serde_json::from_str(&self.text[key_start..self.pos])
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if members.iter().any(|m: &Member| m.key == key) {
|
||||
return Err(IdeError::invalid(
|
||||
"Duplicate JSON object keys are ambiguous; notebook was not opened",
|
||||
));
|
||||
}
|
||||
self.space();
|
||||
self.pos += 1;
|
||||
self.space();
|
||||
let head = self.text[key_start..self.pos].into();
|
||||
let value = self.node()?;
|
||||
members.push(Member { key, head, value });
|
||||
} else {
|
||||
values.push(self.node()?);
|
||||
}
|
||||
let end = self.pos;
|
||||
self.space();
|
||||
if self.text.as_bytes()[self.pos] == close {
|
||||
layout.suffix = self.text[end..self.pos].into();
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
self.space();
|
||||
layout.separators.push(self.text[end..self.pos].into());
|
||||
}
|
||||
}
|
||||
self.pos += 1;
|
||||
if object { Kind::Object(members, layout) } else { Kind::Array(values, layout) }
|
||||
}
|
||||
b'"' => {
|
||||
self.string_end();
|
||||
Kind::Scalar(Arc::new(
|
||||
serde_json::from_str(&self.text[start..self.pos]).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
while self
|
||||
.text
|
||||
.as_bytes()
|
||||
.get(self.pos)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && !b",]}".contains(b))
|
||||
{
|
||||
self.pos += 1;
|
||||
}
|
||||
Kind::Scalar(Arc::new(
|
||||
serde_json::from_str(&self.text[start..self.pos]).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(Node { raw: Some(Raw { text: self.text.clone(), span: start..self.pos }), kind })
|
||||
}
|
||||
}
|
||||
Loaded 100 of 584 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user