Compare commits

..
582 changed files with 2706 additions and 33563 deletions

No files matched your search

-12
View File
@@ -3,8 +3,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
workflow_dispatch:
concurrency:
@@ -71,15 +69,6 @@ jobs:
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
deny:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
frontend:
name: Frontend
runs-on: ubuntu-22.04
@@ -93,7 +82,6 @@ jobs:
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm knip
- run: pnpm test
- run: pnpm build
-1
View File
@@ -9,7 +9,6 @@
"dist",
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
-1
View File
@@ -13,7 +13,6 @@
"src-tauri",
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"fixtures",
"plugins",
"test-results",
Generated
+144 -415
View File
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.28"
version = "0.1.24"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -40,7 +40,6 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -76,7 +75,6 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -87,9 +85,6 @@ redb = "4"
tree-sitter = "0.25"
# protocols & integrations
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
jupyter-protocol = "=2.0.3"
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
lsp-types = "0.97"
gix = { version = "0.89", default-features = false }
portable-pty = "0.9"
@@ -97,8 +92,6 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# testing
-11
View File
@@ -15,8 +15,6 @@
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -56,15 +54,6 @@ 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).
-12
View File
@@ -111,18 +111,6 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## CodeMirror Angular template language
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
`parser.ts` generated from them) is derived from
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
token for `{` and `}`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
-2
View File
@@ -6,8 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
-1
View File
@@ -147,7 +147,6 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Use the TodoWrite tool"),
provider: "claude-acp".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
-10
View File
@@ -37,16 +37,6 @@ 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> {
+9 -69
View File
@@ -23,7 +23,6 @@ pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use ide_vcs::Repo;
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -117,13 +116,9 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
o.current = Some(v);
}
// Approval bypass belongs to the conversation, never to another new task.
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
o.current = Some("bypass".into());
o.current = Some("ask".into());
}
config
}
@@ -319,8 +314,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, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
/// in the project: what the agent offered last time there, plus, for Claude Code, the
/// skills and custom commands on disk it has not listed.
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
let (provider, offered, cwd) = match task {
Some(id) => {
@@ -330,14 +325,6 @@ impl AgentManager {
}
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
};
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
}
}
if provider != "claude" && provider != "claude-acp" {
return Ok(offered);
}
@@ -366,16 +353,9 @@ impl AgentManager {
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?;
let title = title_of(&prompt);
let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone());
let project_root = Some(IpcPath(root.clone()));
let root = self.shared.paths.project_root.clone();
let (cwd, branch, base, base_commit, before) = match req.isolation {
Isolation::Worktree => {
// The UI offers worktrees for git projects only; state it when it isn't one.
if Repo::discover(&root)?.is_none() {
return Err(IdeError::invalid(
"worktrees need a git repository: run the task in the local checkout",
));
}
let dir = self.shared.paths.worktrees_dir.clone();
let (t, b) = (title.clone(), req.base.clone());
let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?;
@@ -399,7 +379,6 @@ impl AgentManager {
}
let now = now_ms();
let task = AgentTask {
project_root,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
@@ -560,10 +539,6 @@ 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();
@@ -773,41 +748,18 @@ impl AgentManager {
Ok(())
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.status != AgentStatus::Archived {
return Ok(());
}
if s.busy {
return Err(IdeError::invalid("the task is still stopping"));
}
s.task.status = AgentStatus::Ready;
s.task.error = None;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
task.kill();
let (isolation, cwd, root) = {
let (isolation, cwd) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
)
(s.task.isolation, s.task.cwd.0.clone())
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
let root = self.shared.paths.project_root.clone();
blocking(move || worktree::remove(&root, &cwd)).await?;
}
self.tasks.write().remove(id);
@@ -1065,7 +1017,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text,
attachments,
images: prompt.images,
sent_ms: Some(now_ms()),
done_ms: None,
steered: true,
@@ -1086,7 +1037,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text.clone(),
attachments,
images: prompt.images.clone(),
sent_ms: Some(now_ms()),
done_ms: None,
steered: false,
@@ -1233,6 +1183,7 @@ 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 };
@@ -1261,7 +1212,6 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1679,16 +1629,6 @@ 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())
}
-9
View File
@@ -47,9 +47,6 @@ pub enum Isolation {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentTask {
/// Original project folder; absent in tasks saved before per-task roots.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub id: String,
pub title: String,
/// [`AgentProvider::id`].
@@ -243,9 +240,6 @@ pub enum AgentEntry {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// The images sent with it, shown in the conversation.
#[serde(default)]
images: Vec<AgentImage>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
@@ -367,9 +361,6 @@ pub struct AgentProbe {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NewAgentTask {
/// Validated by the host; defaults to the window project root.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub prompt: AgentPrompt,
pub provider: String,
/// Settings for the new session (model, mode…).
+1 -1
View File
@@ -296,7 +296,7 @@ impl Shim {
let mut cmd = std::process::Command::new(&self.config.program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
let text = String::from_utf8_lossy(&out.stdout);
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
}
+20 -71
View File
@@ -21,26 +21,18 @@ use std::sync::Arc;
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
/// on `PATH` is often an old one that the service no longer serves the current models to.
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
/// A path, or a name found once (or not at all), is used as it is.
pub(crate) fn newest_install(command: &str) -> String {
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
.unwrap_or_default();
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() {
if Path::new(command).components().count() > 1 {
return command.to_owned();
}
let pathext = ide_core::process::host_var_os("PATHEXT");
let names =
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()));
let mut seen = Vec::new();
let candidates: Vec<PathBuf> = std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
let candidates: Vec<PathBuf> = path
.iter()
.flat_map(std::env::split_paths)
.map(|dir| dir.join(command))
.filter(|p| p.is_file())
.filter(|p| {
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
@@ -50,7 +42,7 @@ fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
})
.collect();
if candidates.len() < 2 {
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
return command.to_owned();
}
candidates
.into_iter()
@@ -64,7 +56,7 @@ fn version_of(program: &Path) -> Option<Vec<u64>> {
let mut cmd = std::process::Command::new(program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
parse_version(&String::from_utf8_lossy(&out.stdout))
}
fn parse_version(text: &str) -> Option<Vec<u64>> {
@@ -74,6 +66,16 @@ fn parse_version(text: &str) -> Option<Vec<u64>> {
})
}
#[cfg(test)]
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
/// The agent side of an in-process ACP connection.
pub(crate) trait Shim: Handler {
/// Called once, before any message arrives: the connection to the client, for
@@ -92,56 +94,3 @@ pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
shim.attach(to_client);
Peer::connect_framed(or, ow, client, Framing::Lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
#[test]
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
} else {
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
}
assert_eq!(newest_install_in("nope", &search), "nope");
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
}
#[cfg(unix)]
#[test]
fn newest_install_compares_versions_across_path_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old");
let new = dir.path().join("new");
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
std::fs::create_dir_all(path).unwrap();
let executable = path.join("agent");
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for paths in [[&old, &new], [&new, &old]] {
let search = std::env::join_paths(paths).unwrap();
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
}
}
}
+1 -1
View File
@@ -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_core::tool_path::search_path())?;
let http = Http::new(&ide_tools::default_search_path())?;
Ok(if cfg!(test) { http } else { http.https_only() })
}
+1 -17
View File
@@ -185,23 +185,7 @@ pub(crate) fn recorder(out: PathBuf) -> Connector {
let file = Arc::new(Mutex::new(String::new()));
let connections = Arc::new(Mutex::new(0u32));
Arc::new(move |spec, cwd, client, on_stderr| {
let program = {
let path = Path::new(&spec.command);
if path.components().count() > 1 || path.is_absolute() {
path.to_path_buf()
} else {
let search = spec
.env
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(_, v)| std::ffi::OsString::from(v))
.or_else(|| ide_core::process::shell_env().find(|(k, _)| k == "PATH").map(|(_, v)| v))
.or_else(|| ide_core::process::host_var_os("PATH"))
.unwrap_or_default();
ide_core::process::find_executable(&spec.command, &search).unwrap_or_else(|| path.to_path_buf())
}
};
let mut cmd = tokio::process::Command::new(&program);
let mut cmd = tokio::process::Command::new(&spec.command);
ide_core::process::prepare(cmd.as_std_mut());
let mut child = cmd
.args(&spec.args)
-1
View File
@@ -56,7 +56,6 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String {
let req = NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: provider.into(),
config: config(provider),
+3 -164
View File
@@ -210,11 +210,10 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: only the skills on disk.
// Before any session: the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
@@ -228,8 +227,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.cancel(&task.id, None).unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::Ready).await;
mgr_a.shutdown();
// Another project shares the settings but not the commands.
@@ -242,46 +239,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent);
for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] {
assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}");
}
let commands = mgr.commands("opencode", None).await.unwrap();
assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["compact"]);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "opencode".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let advertised = AgentCommand {
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
let commands = mgr.commands("claude", Some(&task.id)).await.unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, advertised.name);
assert_eq!(commands[0].description, advertised.description);
assert_eq!(commands[0].hint, advertised.hint);
// Finish the in-process agent before Tokio stops driving its permission response.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
let task = mgr.task(id).unwrap();
@@ -311,7 +268,6 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer\nmore detail"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
@@ -418,7 +374,6 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
@@ -473,7 +428,6 @@ async fn subagents_get_their_own_conversation() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("delegate"),
provider: "claude".into(),
config: vec![],
@@ -567,7 +521,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("do it"),
provider: "codex".into(),
config: vec![],
@@ -589,7 +542,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
assert!(
mgr.create(NewAgentTask {
project_root: None,
prompt: text(" "),
provider: "codex".into(),
config: vec![],
@@ -635,7 +587,6 @@ async fn real_claude_task_in_a_worktree() {
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
@@ -684,7 +635,6 @@ async fn forks_rollbacks_and_steering() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = |prompt: &str| NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "claude".into(),
config: Vec::new(),
@@ -735,7 +685,6 @@ async fn usage_limits_and_late_settings() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("limit"),
provider: "claude".into(),
// `effort` isn't offered until the session runs.
@@ -778,7 +727,6 @@ 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(),
@@ -804,17 +752,15 @@ 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;
// A task created without choosing approvals still asks; the New Task page sends the default.
// An unrelated new conversation still asks, even after the cache was updated.
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();
}
@@ -824,7 +770,6 @@ 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() }],
@@ -836,109 +781,3 @@ 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);
}
-1
View File
@@ -446,7 +446,6 @@ mod tests {
let user = |sent: f64| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered: false,
+2 -22
View File
@@ -53,9 +53,8 @@ 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 Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let repo = repo_at(cwd)?;
let root = repo.root().to_path_buf();
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
@@ -97,12 +96,7 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
};
repo.file_at(path, Some(rev), encoding)
repo_at(cwd)?.file_at(path, Some(rev))
}
/// A short ASCII branch-safe name from the task title.
@@ -142,20 +136,6 @@ mod tests {
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
}
#[test]
fn outside_a_repository_there_is_nothing_to_compare() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
assert!(changes(&dir, "HEAD").unwrap().is_empty());
assert!(snapshot(&dir).unwrap().is_empty());
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
assert!(remove(&dir, &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"])
-3
View File
@@ -234,9 +234,6 @@ 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)))
-6
View File
@@ -399,12 +399,6 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
out
}
/// Static fallback until Gradle has reported its tasks.
pub(crate) fn has_war_plugin(script: &str) -> bool {
let script = strip_comments(script);
script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war")
}
#[cfg(test)]
mod tests {
#[test]
-12
View File
@@ -11,7 +11,6 @@ pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
@@ -40,19 +39,8 @@ pub struct BuildModel {
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
pub fn load_linked(
root: &Path,
cache_dir: &Path,
maven_repo: Option<PathBuf>,
settings: &ide_core::settings::MavenSettings,
) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
+4 -148
View File
@@ -401,9 +401,6 @@ struct Inherited {
managed: Vec<RawDep>,
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
/// Maven lets a child enable a parent's profiles with `-P`.
profiles: Vec<(String, bool)>,
}
fn merge_by<T: Clone>(base: &mut Vec<T>, own: &[T], key: impl Fn(&T) -> String) {
@@ -526,11 +523,6 @@ impl Resolver {
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &p.plugins);
}
merge_by(
&mut ctx.profiles,
&pom.profiles.iter().map(|p| (p.id.clone(), p.active_by_default)).collect::<Vec<_>>(),
|(id, _)| id.clone(),
);
// Managed dependencies: own entries win; imported BOMs fill the gaps.
let props = self.properties_for(&pom, &ctx, &dir);
let mut imported: Vec<RawDep> = Vec::new();
@@ -943,10 +935,9 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| pom.compiler_release.as_deref().map(i))
.and_then(|v| java_version(&v));
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
for p in &pom.profiles {
if !model.profiles.iter().any(|x| x.id == p.id) {
model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default });
}
}
let artifact_id = i(&pom.artifact_id);
@@ -963,7 +954,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
aggregated,
plugins,
dependencies,
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
options: Vec::new(),
@@ -976,67 +967,6 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
model
}
/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory.
#[derive(Default)]
pub struct UnlinkedProjects {
poms: std::collections::BTreeSet<String>,
}
impl UnlinkedProjects {
pub fn load(root: &Path, settings: &ide_core::settings::MavenSettings) -> Self {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load(root), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
let mut result = Self::default();
let mut pending = settings.unlinked_projects.clone();
while let Some(pom) = pending.pop() {
let pom = normalized_pom(&pom);
if !result.poms.insert(pom.clone()) {
continue;
}
if let Some(project) = model.projects.iter().find(|p| normalized_pom(&p.pom) == pom) {
pending.extend(project.modules.iter().cloned());
}
}
result
}
pub fn contains(&self, pom: &str) -> bool {
self.poms.contains(&normalized_pom(pom))
}
pub fn poms(&self) -> impl Iterator<Item = &str> {
self.poms.iter().map(String::as_str)
}
pub fn filter(&self, model: &mut MavenModel) {
model.projects.retain(|p| !self.contains(&p.pom));
for project in &mut model.projects {
project.modules.retain(|p| !self.contains(p));
}
model.profiles.retain(|p| model.projects.iter().any(|project| project.profiles.contains(&p.id)));
}
}
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let mut parts = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
}
/// Every POM of the model (for change detection).
pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
model.projects.iter().map(|p| root.join(&p.pom)).collect()
@@ -1289,41 +1219,9 @@ mod tests {
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("dev", true), ("extra", false)]);
assert_eq!(parent.profiles, ["dev", "extra"]);
assert_eq!(core.profiles, ["dev", "extra"], "a module inherits its parent's profiles");
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
#[test]
fn profiles_of_a_parent_pom_are_inherited() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version>
<packaging>pom</packaging><modules><module>child</module></modules>
<profiles>
<profile><id>jdk8</id></profile>
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
</profiles>
</project>"#,
);
write(
root,
"child/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
<artifactId>child</artifactId>
<profiles><profile><id>own</id></profile></profiles>
</project>"#,
);
let model = load_with_repo(root, None);
let child = &model.projects[1];
// `-Pjdk8` on the module activates the parent's profile, so it is listed.
assert_eq!(child.profiles, ["jdk8", "jdk21", "own"]);
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("jdk8", false), ("jdk21", true), ("own", false)]);
}
#[test]
fn parent_and_bom_from_the_local_repository_and_wrapper() {
let tmp = tempfile::tempdir().unwrap();
@@ -1561,45 +1459,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod linkage_tests {
use super::*;
use ide_core::settings::MavenSettings;
#[test]
fn excludes_reactor_modules_and_profiles_but_keeps_other_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, body) in [
(
"a",
"<modules><module>child</module><module>../shared</module></modules><profiles><profile><id>only-a</id></profile></profiles>",
),
("a/child", ""),
("shared", ""),
("b", ""),
] {
std::fs::create_dir_all(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId>{body}</project>"),
)
.unwrap();
}
let original = load_with_repo(root, None);
let settings = MavenSettings { unlinked_projects: vec!["./a/pom.xml".into()], ..Default::default() };
let unlinked = UnlinkedProjects::from_model(&original, &settings);
assert!(unlinked.contains("a/child/pom.xml"));
assert!(unlinked.contains("shared/pom.xml"));
assert!(unlinked.contains("a/../shared/pom.xml"));
assert!(!unlinked.contains("b/pom.xml"));
let mut model = original.clone();
unlinked.filter(&mut model);
assert_eq!(model.projects.iter().map(|p| p.pom.as_str()).collect::<Vec<_>>(), ["b/pom.xml"]);
assert!(model.profiles.is_empty());
let mut restored = original.clone();
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
assert_eq!(restored, original);
}
}
+3 -21
View File
@@ -34,22 +34,6 @@ 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(
@@ -64,8 +48,7 @@ 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 program = program_path(program, env);
let mut command = Command::new(&program);
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)]
@@ -74,8 +57,7 @@ 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 {}: {e}", program.display())))?;
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
let (tx, rx) = mpsc::channel::<(Stream, String)>();
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
std::thread::spawn(move || {
@@ -86,7 +68,7 @@ pub(crate) fn run(
match reader.read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {
let line = ide_core::output_encoding::decode_tool_output(&buf);
let line = String::from_utf8_lossy(&buf);
let line = line.trim_end_matches(['\n', '\r']).to_owned();
if tx.send((kind, line)).is_err() {
break;
+6 -51
View File
@@ -23,7 +23,6 @@ pub struct SyncProgress {
#[derive(Clone, Debug, Default)]
pub struct SyncOptions {
pub maven_settings: ide_core::settings::MavenSettings,
/// `-o` / `--offline`.
pub offline: bool,
/// Maven profiles (`-P`), `!id` deactivates.
@@ -32,8 +31,6 @@ pub struct SyncOptions {
pub env: BTreeMap<String, String>,
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
pub maven_command: Option<String>,
/// Resolved Gradle launcher from Settings.
pub gradle_command: Option<String>,
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
pub maven_options: Vec<String>,
}
@@ -121,16 +118,6 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
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() {
@@ -139,6 +126,9 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
p.dependencies = c.dependencies.clone();
}
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
}
/// Merges the cached task list into `model`.
@@ -208,8 +198,7 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
let model = maven::load(root);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
@@ -277,14 +266,7 @@ pub fn sync_gradle(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_| {},
)?;
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
let cache = GradleCache {
hash: hash_files(&gradle::build_files(root, &model)),
at_ms: now_ms(),
@@ -298,29 +280,6 @@ pub fn sync_gradle(
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn gradle_sync_uses_resolved_command_and_environment() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "").unwrap();
let launcher = root.join("configured-gradle");
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$JAVA_HOME\" \"$GRADLE_USER_HOME\" > actual-env\n")
.unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options = SyncOptions {
gradle_command: Some(launcher.to_string_lossy().into_owned()),
env: BTreeMap::from([
("JAVA_HOME".into(), "/jdk21".into()),
("GRADLE_USER_HOME".into(), "/gradle-cache".into()),
]),
..Default::default()
};
sync_gradle(root, &root.join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(std::fs::read_to_string(root.join("actual-env")).unwrap(), "/jdk21\n/gradle-cache\n");
}
#[test]
fn caches_merge_and_go_stale() {
let tmp = tempfile::tempdir().unwrap();
@@ -356,16 +315,12 @@ mod tests {
std::fs::write(
root.join("pom.xml"),
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
</project>",
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
)
.unwrap();
let mut model = maven::load_with_repo(&root, None);
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.stale);
// The POM changed: the stale effective dependencies must not hide the new one (#249).
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
-172
View File
@@ -1,172 +0,0 @@
//! 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 }"));
}
}
-4
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
@@ -17,6 +16,3 @@ tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
+1 -17
View File
@@ -13,8 +13,6 @@ 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>,
@@ -34,13 +32,10 @@ pub enum IdeEvent {
doc: DocId,
dirty: bool,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
/// An open document's language changed (Settings › File Types, Override File Type).
DocLanguage {
doc: DocId,
language: crate::LanguageId,
/// See `DocumentInfo::ui_mode`.
ui_mode: Option<String>,
},
/// Full replacement of diagnostics for one file from one producer.
Diagnostics {
@@ -117,22 +112,11 @@ pub enum IdeEvent {
id: u64,
prompt: String,
kind: AskpassKind,
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
remember: bool,
},
/// Prompt `id` was answered (in any window) or gave up.
AskpassDone {
id: u64,
},
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
/// `dead`); `message` says why it failed to start or died.
NotebookKernel {
/// Backend document incarnation; stale queued events cannot target a reopen.
id: String,
path: crate::IpcPath,
state: String,
message: Option<String>,
},
}
/// What an askpass prompt asks for.
-1
View File
@@ -20,7 +20,6 @@ pub mod remote_settings;
pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod version_managers;
pub mod workflow_settings;
-77
View File
@@ -18,60 +18,6 @@
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";
@@ -291,29 +237,6 @@ 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",
-38
View File
@@ -218,25 +218,6 @@ 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::*;
@@ -382,23 +363,4 @@ mod tests {
prepare(&mut cmd);
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
}
#[test]
fn find_executable_finds_bare_names_on_the_given_path() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("agent"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("agent.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
// The `.cmd` shim wins over a POSIX script `Command` can't start.
assert_eq!(find_executable("agent", &search), Some(b.join("agent.cmd")));
} else {
assert_eq!(find_executable("agent", &search), Some(a.join("agent")));
}
assert_eq!(find_executable("nope", &search), None);
}
}
+1 -64
View File
@@ -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` | `attach`.
/// Where Open… puts a project while one is open: `ask` | `new` | `same`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
pub default_project_directory: String,
@@ -563,37 +563,12 @@ pub struct JavaSettings {
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
pub gradle: GradleSettings,
}
/// Build Tools › Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
/// Used only for the local distribution.
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleDistribution {
#[default]
Wrapper,
Local,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
@@ -716,41 +691,3 @@ mod tests {
assert!(Settings::default().files.file_types.is_empty());
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.unlinked_projects.is_empty());
let settings =
MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
assert_eq!(old.gradle, GradleSettings::default());
for distribution in [GradleDistribution::Wrapper, GradleDistribution::Local] {
let mut settings = Settings::default();
settings.languages.java.gradle = GradleSettings {
jvm: "/jdk21".into(),
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
}
}
}
-193
View File
@@ -1,193 +0,0 @@
//! Where the IDE looks up the executables it runs: language servers, the tools
//! their settings come from, installers. An app started from the desktop gets a
//! minimal `PATH`, and version managers set theirs up in shell rc files.
use crate::process::{host_var_os, login_env};
use crate::version_managers;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
/// Where executables are looked up: this process's `PATH` (the login shell's in the
/// app), the login shell's `PATH` as last read, then per-user directories and mise's,
/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server
/// launchers, their settings hooks and tool detection all look here, so a settings
/// hook finds the executable its server is launched from.
pub fn search_path() -> OsString {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
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"]);
}
}
+2 -6
View File
@@ -213,12 +213,8 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
.output()
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
if !out.status.success() {
let text = ide_core::output_encoding::decode_tool_output(&out.stderr).trim().to_owned();
let text = if text.is_empty() {
ide_core::output_encoding::decode_tool_output(&out.stdout).trim().to_owned()
} else {
text
};
let text = String::from_utf8_lossy(&out.stderr).trim().to_owned();
let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text };
return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}")));
}
let text = read_report(lcov)?;
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
-6
View File
@@ -63,12 +63,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
"js-debug" => cfg(
"js-debug-adapter",
+2 -4
View File
@@ -70,10 +70,8 @@ pub async fn build(
let on_output = std::sync::Arc::new(on_output);
let err_out = on_output.clone();
let err_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).split(b'\n');
while let Ok(Some(line)) = lines.next_segment().await {
let line = ide_core::output_encoding::decode_tool_output(&line);
let line = line.trim_end_matches('\r');
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
err_out(format!("{line}\n"));
}
});
-218
View File
@@ -1,218 +0,0 @@
//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project.
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
pub const ADAPTER: &str = "kotlin-debug-adapter";
pub struct SourceMirror {
dir: tempfile::TempDir,
paths: HashMap<PathBuf, PathBuf>,
copies: Vec<(PathBuf, PathBuf)>,
source_dir: PathBuf,
roots: HashMap<PathBuf, usize>,
}
impl SourceMirror {
pub fn create(project: &Path) -> IdeResult<Self> {
let base = fs::canonicalize(std::env::temp_dir())?;
// Check before creating anything, including when TMPDIR points into a project.
if base.starts_with(fs::canonicalize(project)?) {
return Err(IdeError::invalid("Kotlin source mirror must be outside the project"));
}
let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?;
let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() };
let mut mirror = Self {
dir,
paths: HashMap::new(),
copies: Vec::new(),
source_dir: source_dir.clone(),
roots: HashMap::new(),
};
mirror.scan(&source_dir)?;
Ok(mirror)
}
pub fn root(&self) -> &Path {
self.dir.path()
}
fn scan(&mut self, dir: &Path) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
if !matches!(
entry.file_name().to_str(),
Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea")
) {
self.scan(&path)?;
}
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) {
self.add(&path)?;
}
}
Ok(())
}
fn add(&mut self, real: &Path) -> IdeResult<()> {
if self.paths.contains_key(real) {
return Ok(());
}
let Ok(text) = fs::read_to_string(real) else {
tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display());
return Ok(());
};
let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" };
let relative = real.strip_prefix(&self.source_dir).unwrap_or(real);
let mut components = relative.components();
let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default();
let next_root = self.roots.len();
let index = *self.roots.entry(source_set).or_insert(next_root);
let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language);
for part in package(&text).split('.').filter(|s| !s.is_empty()) {
if part == ".." || part.contains(['/', '\\']) {
tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display());
return Ok(());
}
mirror.push(part);
}
mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?);
// Same package and file name in one source set (e.g. main and test of a module):
// the first wins; breakpoints in the other stay unverified instead of failing the session.
if self.paths.values().any(|p| p == &mirror) {
tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display());
return Ok(());
}
fs::create_dir_all(mirror.parent().unwrap())?;
if link(real, &mirror).is_err() {
fs::copy(real, &mirror)?;
self.copies.push((real.to_owned(), mirror.clone()));
}
self.paths.insert(real.to_owned(), mirror);
Ok(())
}
pub fn adapter_path(&self, real: &Path) -> Option<PathBuf> {
self.paths.get(real).cloned()
}
pub fn client_path(&self, mirror: &Path) -> Option<PathBuf> {
self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone()))
}
pub fn refresh(&mut self) -> IdeResult<()> {
for (real, mirror) in &self.copies {
if real.is_file() {
fs::copy(real, mirror)?;
}
}
self.scan(&self.source_dir.clone())
}
pub fn translate(&self, value: &mut Value) {
match value {
Value::Object(obj) => {
if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p)))
{
obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned()));
}
for child in obj.values_mut() {
self.translate(child);
}
}
Value::Array(items) => {
for child in items {
self.translate(child);
}
}
_ => {}
}
}
}
#[cfg(unix)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(real, mirror)
}
#[cfg(windows)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(real, mirror)
}
// Remove comments before reading the package directive (including nested Kotlin block comments).
fn package(text: &str) -> String {
let mut clean = String::new();
let mut chars = text.chars().peekable();
let mut depth = 0;
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
depth += 1;
clean.push(' ');
} else if depth > 0 && c == '*' && chars.peek() == Some(&'/') {
chars.next();
depth -= 1;
} else if depth == 0 && c == '/' && chars.peek() == Some(&'/') {
for c in chars.by_ref() {
if c == '\n' {
clean.push('\n');
break;
}
}
} else if depth == 0 {
clean.push(c);
}
}
clean
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("package")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.split(';').next().unwrap_or_default().trim().replace('`', ""))
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_mirror_round_trip_and_cleanup() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.kt");
fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap();
let mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
assert!(!path.starts_with(project.path()));
assert!(path.ends_with("src/main/kotlin/org/example/Main.kt"));
assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap());
let mut payload =
serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}});
mirror.translate(&mut payload);
assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref());
assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref());
let root = mirror.root().to_owned();
drop(mirror);
assert!(!root.exists());
assert!(real.exists());
}
#[test]
fn copies_refresh_after_save() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.java");
fs::write(&real, "package example;\nclass Main {}\n").unwrap();
let mut mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
fs::remove_file(&path).unwrap();
fs::copy(&real, &path).unwrap();
mirror.copies.push((real.clone(), path.clone()));
fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap();
mirror.refresh().unwrap();
assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap());
}
}
-166
View File
@@ -1,166 +0,0 @@
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
use ide_core::IdeResult;
use std::{io, time::Duration};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
task::JoinHandle,
};
pub(crate) struct StartupRelay {
release: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl StartupRelay {
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let connect = async {
let (adapter, _) = listener.accept().await?;
let jvm = TcpStream::connect((host.as_str(), port)).await?;
adapter.set_nodelay(true)?;
jvm.set_nodelay(true)?;
Ok::<_, io::Error>((adapter, jvm))
};
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
let (mut ar, mut aw) = adapter.into_split();
let (mut jr, mut jw) = jvm.into_split();
let upstream = forward(&mut ar, &mut jw, ready);
let downstream = tokio::io::copy(&mut jr, &mut aw);
// Close both sockets as soon as either side disconnects.
tokio::select! {
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
_ = downstream => {},
}
}
});
Ok((Self { release: Some(release), task }, local))
}
pub fn release(&mut self) {
if let Some(ready) = self.release.take() {
let _ = ready.send(());
}
}
}
impl Drop for StartupRelay {
fn drop(&mut self) {
self.task.abort();
}
}
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
let mut header = [0; 11];
reader.read_exact(&mut header).await?;
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
if !(11..=16 * 1024 * 1024).contains(&len) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
}
let mut packet = vec![0; len];
packet[..11].copy_from_slice(&header);
reader.read_exact(&mut packet[11..]).await?;
Ok(packet)
}
async fn forward(
reader: &mut (impl AsyncRead + Unpin),
writer: &mut (impl AsyncWrite + Unpin),
mut ready: oneshot::Receiver<()>,
) -> io::Result<()> {
let mut handshake = [0; 14];
reader.read_exact(&mut handshake).await?;
if &handshake != b"JDWP-Handshake" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
}
writer.write_all(&handshake).await?;
let mut released = false;
let mut held: Vec<Vec<u8>> = Vec::new();
loop {
// Retain the in-flight read when attach completes, including partial packets.
let read = packet(reader);
tokio::pin!(read);
let packet = tokio::select! {
result = &mut read => result?,
result = &mut ready, if !released => {
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
released = true;
for packet in held.drain(..) {
writer.write_all(&packet).await?;
}
read.await?
}
};
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
// Other commands must proceed: attach itself queries threads and installs requests.
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
if held.len() >= 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
}
held.push(packet);
} else {
writer.write_all(&packet).await?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
}
#[tokio::test]
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
let (mut adapter, mut reader) = tokio::io::duplex(4096);
let (mut writer, mut jvm) = tokio::io::duplex(4096);
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
adapter.write_all(b"JDWP-Handshake").await.unwrap();
let mut handshake = [0; 14];
jvm.read_exact(&mut handshake).await.unwrap();
assert_eq!(&handshake, b"JDWP-Handshake");
let resume = command(1, 1, 9);
let thread_resume = command(2, 11, 3);
let setup = command(3, 1, 4);
adapter.write_all(&resume).await.unwrap();
adapter.write_all(&thread_resume).await.unwrap();
adapter.write_all(&setup).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
let next = command(4, 1, 9);
adapter.write_all(&next[..6]).await.unwrap();
release.send(()).unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
adapter.write_all(&next[6..]).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), next);
drop(adapter);
assert!(task.await.unwrap().is_err());
}
#[tokio::test]
async fn dropping_session_closes_relay() {
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
drop(relay);
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_packet_lengths() {
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
let mut bytes = command(1, 1, 9);
bytes[..4].copy_from_slice(&len.to_be_bytes());
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
}
}
+3 -75
View File
@@ -13,8 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -565,14 +563,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
.and_then(|o| o.remove("__adapterJavaHome"))
.and_then(|v| v.as_str().map(str::to_owned))
{
spec.env.push(("JAVA_HOME".into(), home));
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -732,19 +722,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| s.spec.cwd.clone());
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
let caps = s
.conn
.request_timeout(
@@ -771,32 +749,8 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
.to_owned();
let port = arguments
.get("port")
.and_then(Value::as_u64)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let launch = s.conn.send_request(&s.request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
@@ -826,7 +780,6 @@ impl DebugManager {
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
}
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
}
}
@@ -849,11 +802,7 @@ impl DebugManager {
let _ = self.send_exception_breakpoints(s).await;
}
if s.supports("supportsConfigurationDoneRequest") {
if s.adapter == kotlin::ADAPTER {
drop(s.conn.send_request("configurationDone", json!({}))?);
} else {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
@@ -864,9 +813,6 @@ impl DebugManager {
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -923,7 +869,6 @@ impl DebugManager {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
@@ -941,23 +886,6 @@ impl DebugManager {
Ok(states)
}
/// Refresh fallback copies after an editor save.
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
tokio::task::spawn_blocking(move || {
for s in sessions {
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
&& let Err(e) = mirror.refresh()
{
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
}
}
Ok(())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
self.states.lock().get(path).cloned().unwrap_or_default()
+2 -109
View File
@@ -54,8 +54,6 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
}
/// Adapters that only match files by their resolved path: Godot maps a script to
@@ -97,8 +95,6 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
kotlin_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
}
}
@@ -108,14 +104,6 @@ impl Session {
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
pub fn adapter_path(&self, path: &Path) -> PathBuf {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
return path.to_path_buf();
}
@@ -130,14 +118,7 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
})
self.resolved_paths.lock().get(Path::new(path)).cloned()
}
pub fn supports(&self, cap: &str) -> bool {
@@ -255,7 +236,6 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
let _ = c.start_kill();
}
@@ -265,19 +245,6 @@ impl Session {
}
}
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|| path.starts_with("//")
|| path.starts_with(r"\\")
}
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
fn with_conditions(v: &mut Value, condition: Option<&str>, hit_condition: Option<&str>) {
if let Some(c) = condition.filter(|c| !c.trim().is_empty()) {
v["condition"] = c.into();
@@ -330,10 +297,7 @@ pub(crate) async fn event_loop(
}
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut body: Value) {
if let Some(mirror) = s.kotlin_mirror.lock().as_ref() {
mirror.translate(&mut body);
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body: Value) {
let int = |k: &str| body.get(k).and_then(Value::as_i64);
let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned);
match event {
@@ -522,74 +486,3 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
}
});
}
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default()
};
let classpath =
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
if !modules.is_empty() {
cmd.arg("--module-path")
.arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?);
}
cmd.arg(
args.get("mainClass")
.and_then(Value::as_str)
.ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?,
)
.args(strings("programArgs"))
.current_dir(&s.spec.cwd)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
if let Some(v) = v.as_str() {
cmd.env(k, v);
}
}
}
let mut child = cmd.spawn()?;
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
})
.await
.map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??;
forward_output(s, stdout, "stdout");
s.debuggees.lock().push(child);
args["hostName"] = json!("127.0.0.1");
args["port"] = json!(port);
args["timeout"] = json!(10000);
Ok(())
}
+3 -127
View File
@@ -15,8 +15,6 @@ struct Script {
run_in_terminal: Option<Vec<String>>,
/// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives.
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
}
struct FakeLauncher {
@@ -132,9 +130,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
ok!(msg, Value::Null);
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -143,14 +139,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
send!(
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9}}})
);
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -897,7 +893,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -1020,122 +1015,3 @@ fn root_and_address_placeholders() {
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
assert_eq!(adapter::parse_address("6006"), None);
}
#[tokio::test]
async fn kotlin_configures_without_configuration_done_response() {
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let mut config = config();
config.adapter = kotlin::ADAPTER.into();
config.request = "attach".into();
config.cwd = Some(project.path().to_owned().into());
config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string());
let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap();
let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap());
assert!(!root.starts_with(project.path()));
let request = h.request("setBreakpoints");
assert_eq!(request["arguments"]["source"]["name"], "Main.kt");
let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap());
assert!(mapped.starts_with(&root));
assert!(mapped.is_file());
assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path));
h.manager.stop(id).await.unwrap();
}
/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example,
/// loop at line 5, println(i) at line 6), and the unmodified official adapter.
/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable.
#[tokio::test]
#[ignore = "requires official Kotlin adapter and compiled reporter fixture"]
async fn kotlin_official_release_ten_no_delay_runs() {
let config: LaunchConfig = serde_json::from_slice(
&std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(),
)
.unwrap();
let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER");
let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt");
for run in 1..=10 {
let (owner, events) = recording_owner("kotlin-probe");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let h = Harness { manager, owner, events, log: Arc::default() };
h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap();
let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap();
let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 5);
assert_eq!(frames[0].path.as_deref(), Some(source.as_path()));
let scopes = h.manager.scopes(id, frames[0].id).await.unwrap();
let mut variables = Vec::new();
for scope in scopes {
variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap());
}
assert!(variables.iter().any(|v| v.name == "i" && v.value == "0"));
h.manager.step(id, thread_id, StepKind::Over).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await;
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 4);
h.manager.stop(id).await.unwrap();
println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped");
}
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
for (client, native, returned) in [
("C:/work/Main.java", r"C:\work\Main.java", r"C:\work\Main.java"),
("C:/work/Main.java", r"C:\work\Main.java", "C:/work/Main.java"),
(r"c:\work\Main.java", r"c:\work\Main.java", "c:/work/Main.java"),
(r"D:/my project\Main.java", r"D:\my project\Main.java", r"D:\my project\Main.java"),
("//server/share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
(r"\\server\share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
("/work/Main.java", "/work/Main.java", "/work/Main.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(4)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "java-debug".into();
cfg.request = request.into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("initialize")["arguments"]["pathFormat"], "path");
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let commands = h.commands();
assert!(
commands.iter().position(|c| c == "setBreakpoints").unwrap()
< commands.iter().position(|c| c == "configurationDone").unwrap()
);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.wait_event(|e| {
matches!(e, DebugEvent::BreakpointsChanged { path: p } if p.as_ref() == path.as_path())
&& h.manager.breakpoint_states(&path)[0].message.as_deref() == Some("moved")
})
.await;
assert_eq!(h.manager.breakpoint_states(&path)[0].message.as_deref(), Some("moved"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test]
async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
for (returned, expected) in [
(r"C:\work\Other.java", "C:/work/Other.java"),
(r"\\server\share\Other.java", "//server/share/Other.java"),
("C:/work/Other.java", "C:/work/Other.java"),
("/work/Other.java", "/work/Other.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let mut cfg = config();
cfg.adapter = "java-debug".into();
let id = h.start(cfg).await.unwrap();
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(PathBuf::from(expected).as_path()));
h.manager.stop(id).await.unwrap();
}
}
-3
View File
@@ -16,9 +16,6 @@ tracing.workspace = true
parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
ide-ssh.workspace = true
+21 -409
View File
@@ -5,9 +5,6 @@
//! whatever its type.
use crate::source::{DataSource, DbDriver, DbSslMode};
use bb8::ManageConnection;
use bb8::Pool as Bb8Pool;
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -47,65 +44,10 @@ pub struct QueryResult {
pub execution_ms: f64,
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
fn deref(&self) -> &Self::Target {
self.0.as_ref().expect("live SQL Server connection")
}
}
impl std::ops::DerefMut for MssqlConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().expect("live SQL Server connection")
}
}
impl ManageConnection for MssqlConnectionManager {
type Connection = MssqlConnection;
type Error = tiberius::error::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
use tokio_util::compat::TokioAsyncWriteCompatExt;
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
config.host(host);
config.port(port);
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
MssqlClient::connect(config, tcp.compat_write()).await?
}
result => result?,
};
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.simple_query("SELECT 1").await?.into_results().await?;
Ok(())
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.0.is_none()
}
}
pub enum Pool {
Sqlite(SqlitePool),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -115,7 +57,6 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) => unreachable!(),
}
};
}
@@ -138,129 +79,6 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
}
}
pub(crate) fn mssql_err(e: tiberius::error::Error) -> IdeError {
match &e {
tiberius::error::Error::Server(d) => {
let code = d.code();
// Wrong login: SQL Server 18456.
let auth = matches!(code, 18452 | 18456 | 18470);
IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("[{code}] {}", d.message()),
)
}
tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => {
IdeError::new(ErrorKind::Io, e.to_string())
}
_ => IdeError::new(ErrorKind::Protocol, e.to_string()),
}
}
fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
match e {
bb8::RunError::User(e) => mssql_err(e),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
fn format_date(days: i64) -> String {
let z = days + 306;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}")
}
fn format_hms(secs: u64, frac: u64, scale: u8) -> String {
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
if scale == 0 || frac == 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
let frac = format!("{frac:0width$}", width = usize::from(scale));
format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0'))
}
}
fn format_time(t: tiberius::time::Time) -> String {
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
format_hms(t.increments() / div, t.increments() % div, scale)
}
fn format_numeric(n: tiberius::numeric::Numeric) -> String {
let scale = u32::from(n.scale());
if scale == 0 {
return n.value().to_string();
}
let sign = if n.value() < 0 { "-" } else { "" };
let abs = n.value().unsigned_abs();
let div = 10u128.pow(scale);
format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize)
}
fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option<String> {
use tiberius::ColumnData as C;
match cell {
C::U8(v) => v.map(|v| v.to_string()),
C::I16(v) => v.map(|v| v.to_string()),
C::I32(v) => v.map(|v| v.to_string()),
C::I64(v) => v.map(|v| v.to_string()),
C::F32(v) => v.map(|v| v.to_string()),
C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => {
// Beyond ~9e11, money precision is limited by the driver's f64 decoding.
v.map(|v| format!("{:.4}", v))
}
C::F64(v) => v.map(|v| v.to_string()),
C::Bit(v) => v.map(|v| v.to_string()),
C::String(v) => v.as_deref().map(str::to_owned),
C::Guid(v) => v.map(|v| v.to_string()),
C::Binary(v) => v.as_deref().map(hex),
C::Numeric(v) => v.map(format_numeric),
C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()),
C::Date(v) => v.map(|d| format_date(i64::from(d.days()))),
C::Time(v) => v.map(format_time),
C::DateTime(v) => v.map(|dt| {
let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3))
}),
C::SmallDateTime(v) => v.map(|dt| {
let secs = u64::from(dt.seconds_fragments()) * 60;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0))
}),
C::DateTime2(v) => {
v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time())))
}
C::DateTimeOffset(v) => v.map(|dto| {
let dt = dto.datetime2();
let t = dt.time();
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64;
let local_secs = utc_secs + i64::from(dto.offset()) * 60;
let days = local_secs.div_euclid(86_400);
let secs = local_secs.rem_euclid(86_400) as u64;
let sign = if dto.offset() < 0 { '-' } else { '+' };
let off = dto.offset().unsigned_abs();
format!(
"{} {}{sign}{:02}:{:02}",
format_date(days),
format_hms(secs, t.increments() % div, scale),
off / 60,
off % 60
)
}),
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
@@ -379,37 +197,6 @@ 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) => {{
@@ -526,7 +313,6 @@ impl Pool {
Pool::Sqlite(_) => DbDriver::Sqlite,
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
}
}
@@ -667,60 +453,20 @@ impl Pool {
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
let mut config = Config::new();
config.host(&source.host);
config.port(source.port.unwrap_or(1433));
if !source.user.is_empty() {
config.authentication(tiberius::AuthMethod::sql_server(
source.user.clone(),
password.unwrap_or_default().to_owned(),
));
}
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
.connection_timeout(timeout)
.build(manager)
.await
.map_err(mssql_err)?;
let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?;
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(Pool::SqlServer(pool))
}
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) => {}
_ => with_pool!(self, p => p.close().await),
}
with_pool!(self, p => p.close().await)
}
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
_ => with_pool!(self, p => p.is_closed()),
}
with_pool!(self, p => p.is_closed())
}
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
Ok(fetched?.1)
}
@@ -739,47 +485,21 @@ impl Pool {
/// Runs `statements` in one transaction; each must change `expect_one`
/// row when flagged (table editor submits).
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
for (sql, expect_one) in statements {
let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total();
if *expect_one && n != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
mssql_command(&mut conn, "COMMIT TRAN").await?;
Ok(total)
with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
.await;
if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() {
// A failed rollback must never return an open transaction to the pool.
conn.0.take();
}
result
total += n;
}
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
tx.commit().await.map_err(db_err)?;
Ok(total)
})
}
}
@@ -789,7 +509,6 @@ pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
}
impl Held {
@@ -798,25 +517,18 @@ impl Held {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
})
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
other => {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) => unreachable!(),
}
.map_err(db_err)
}
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
}
.map_err(db_err)
}
/// Closes the connection instead of returning it to the pool (its statement was
@@ -830,9 +542,6 @@ impl Held {
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
}
}
@@ -880,13 +589,6 @@ impl Held {
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
};
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
@@ -906,7 +608,6 @@ impl Held {
pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
}
}
@@ -923,95 +624,6 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
async fn sqlserver_connect_reports_io_errors() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = tiberius::Config::new();
config.host("127.0.0.1");
config.port(listener.local_addr().unwrap().port());
drop(listener);
let result = MssqlConnectionManager(config).connect().await;
assert!(matches!(result, Err(tiberius::error::Error::Io { .. })));
}
#[test]
fn sqlserver_cells_preserve_values_and_nulls() {
use std::borrow::Cow;
use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time};
use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric};
let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7));
let cases = [
(C::U8(Some(255)), "255"),
(C::I16(Some(-123)), "-123"),
(C::I32(Some(-456)), "-456"),
(C::I64(Some(i64::MAX)), "9223372036854775807"),
(C::F32(Some(1.25)), "1.25"),
(C::F64(Some(1.25)), "1.25"),
(C::Bit(Some(true)), "true"),
(C::String(Some(Cow::Borrowed("hello"))), "hello"),
(C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"),
(
C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())),
"abcdef01-2345-6789-abcd-ef0123456789",
),
(C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"),
(C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"),
(
C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))),
"99999999999999999999999999999999999999",
),
(C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("<a/>")))), "<a/>"),
(C::Date(Some(Date::new(0))), "0001-01-01"),
(C::Date(Some(Date::new(3_652_058))), "9999-12-31"),
(C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"),
(C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"),
(C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"),
(C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"),
(C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"),
];
for (cell, expected) in cases {
assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}");
}
for cell in [
C::U8(None),
C::I16(None),
C::I32(None),
C::I64(None),
C::F32(None),
C::F64(None),
C::Bit(None),
C::String(None),
C::Guid(None),
C::Binary(None),
C::Numeric(None),
C::Xml(None),
C::Date(None),
C::Time(None),
C::DateTime(None),
C::SmallDateTime(None),
C::DateTime2(None),
C::DateTimeOffset(None),
] {
assert_eq!(tiberius_cell(&cell, T::Null), None);
}
for ty in [T::Money, T::Money4] {
for (v, expected) in
[(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")]
{
assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected));
}
assert_eq!(tiberius_cell(&C::F64(None), ty), None);
}
}
fn prop(name: &str, value: &str) -> DbProperty {
DbProperty { name: name.into(), value: value.into() }
}
-8
View File
@@ -549,7 +549,6 @@ impl DbManager {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
};
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
pool.close().await;
@@ -822,13 +821,6 @@ impl DbManager {
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::SqlServer => {
let obj = format!("{}.{}", schema, name).replace('\'', "''");
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
.into_iter()
.next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Postgres => {
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
let (oid, kind) = match rel.as_deref() {
-3
View File
@@ -36,7 +36,6 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
}
}
@@ -60,7 +59,6 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
DbDriver::SqlServer => Ok(Vec::new()),
}
}
@@ -199,7 +197,6 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
}
#[test]
-12
View File
@@ -16,8 +16,6 @@ pub enum DbDriver {
Postgres,
/// MySQL and MariaDB.
Mysql,
/// Microsoft SQL Server.
SqlServer,
}
impl DbDriver {
@@ -26,7 +24,6 @@ impl DbDriver {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some(5432),
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
}
}
}
@@ -251,7 +248,6 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
_ => None,
}
@@ -476,14 +472,6 @@ mod tests {
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
}
#[test]
fn sqlserver_defaults() {
assert_eq!(DbDriver::SqlServer.default_port(), Some(1433));
assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer));
}
#[test]
fn parses_urls() {
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
+3 -105
View File
@@ -8,7 +8,6 @@ use specta::Type;
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
match driver {
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
@@ -53,7 +52,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -63,10 +61,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1"
}
DbDriver::SqlServer => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1"
}
}
}
@@ -89,11 +83,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
DbDriver::SqlServer => format!(
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
}
}
@@ -121,16 +110,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \
ORDER BY ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT column_name, data_type, is_nullable, column_default, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {s} \
AND tc.table_name = {t} AND k.column_name = c.column_name) THEN 1 ELSE 0 END \
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
ORDER BY c.ordinal_position"
),
}
}
@@ -155,13 +134,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \
ORDER BY index_name, seq_in_index"
),
DbDriver::SqlServer => format!(
"SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.name \
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
ORDER BY i.name, ic.key_ordinal"
),
}
}
@@ -199,20 +171,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
}
DbDriver::SqlServer => {
"SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, c.is_nullable, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \
AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END, \
CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
FROM information_schema.columns c JOIN information_schema.tables t \
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
}
}
@@ -244,13 +202,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000"
}
DbDriver::SqlServer => {
"SELECT table_schema, table_name, constraint_name, column_name, \
referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \
AND table_schema NOT IN ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
}
}
@@ -267,9 +218,6 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
DbDriver::Mysql => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
}
}
@@ -279,7 +227,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
}
}
@@ -303,14 +250,8 @@ pub fn table_page(
) -> String {
let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table));
if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) {
let mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
if let Some(i) = f.find(';') {
f = &f[..i];
}
f = f.trim();
if !f.is_empty() {
sql.push_str(&format!(" WHERE {f}"));
}
let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
sql.push_str(&format!(" WHERE {f}"));
}
if !sort.is_empty() {
let keys: Vec<String> = sort
@@ -319,14 +260,7 @@ pub fn table_page(
.collect();
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
}
if driver == DbDriver::SqlServer {
if sort.is_empty() {
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
sql
}
@@ -466,10 +400,6 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
),
DbDriver::SqlServer => format!(
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
),
}
}
@@ -508,10 +438,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::SqlServer => Some(format!(
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
}
}
@@ -633,10 +559,6 @@ mod tests {
"SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100"
);
assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0");
assert_eq!(
table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500),
"SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY"
);
}
#[test]
@@ -664,30 +586,6 @@ mod tests {
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
}
#[test]
fn sqlserver_quoting_and_schema() {
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]");
assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]");
assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()"));
assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN");
assert_eq!(
schemas(DbDriver::SqlServer, true),
"SELECT schema_name FROM information_schema.schemata ORDER BY 1"
);
assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')"));
}
#[test]
fn sqlserver_introspection_queries() {
assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables"));
assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns"));
assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes"));
assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL"));
assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'"));
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
-97
View File
@@ -1,97 +0,0 @@
//! Live SQL Server checks, skipped unless OPENIDEA_TEST_SQLSERVER=host:port:user:password
//! names a throwaway server.
use ide_core::CancellationToken;
use ide_db::{DataSource, DbDriver, DbSslMode, Held, Pool};
use std::time::Duration;
#[tokio::test]
async fn sqlserver_values_paging_transactions_and_cancellation() {
let Ok(server) = std::env::var("OPENIDEA_TEST_SQLSERVER") else { return };
let mut parts = server.splitn(4, ':');
let source = DataSource {
driver: DbDriver::SqlServer,
host: parts.next().unwrap().into(),
port: Some(parts.next().unwrap().parse().unwrap()),
user: parts.next().unwrap().into(),
database: "tempdb".into(),
ssl: DbSslMode::Disable,
..Default::default()
};
let pool = Pool::connect(&source, parts.next(), None).await.unwrap();
let cancel = CancellationToken::new();
let mut held = Held::acquire(&pool).await.unwrap();
let table = format!("tempdb.dbo.oi_pr276_values_{}", std::process::id());
held.command(&format!("CREATE TABLE {table} (id int PRIMARY KEY, d decimal(38, 7), dt datetime2(7), guid uniqueidentifier, m money, tiny tinyint, x xml)")).await.unwrap();
held.command(&format!("INSERT INTO {table} VALUES (1, 12345678901234567890.1234567, '2024-02-29T12:34:56.1234567', 'abcdef01-2345-6789-abcd-ef0123456789', 12.3400, 255, '<a/>'), (2, NULL, NULL, NULL, NULL, NULL, NULL)")).await.unwrap();
let r = held.execute(&pool, &format!("SELECT * FROM {table} ORDER BY id"), 1, &cancel).await.unwrap();
assert!(r.has_more);
assert_eq!(
r.rows[0],
[
"1",
"12345678901234567890.1234567",
"2024-02-29 12:34:56.1234567",
"abcdef01-2345-6789-abcd-ef0123456789",
"12.3400",
"255",
"<a/>"
]
.map(|s| Some(s.to_owned()))
);
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 2"), 5, &cancel).await.unwrap();
assert!(!r.has_more);
assert!(r.rows[0][1..].iter().all(Option::is_none));
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 3"), 5, &cancel).await.unwrap();
assert_eq!(r.columns.len(), 7);
assert!(r.rows.is_empty());
let r = held.execute(&pool,
"SELECT CAST('2024-02-29T00:15:00.1234567+05:30' AS datetimeoffset(7)), CAST('2024-02-29' AS date), CAST('12:34:56.1234567' AS time(7)), CAST('2024-02-29T12:34:56.007' AS datetime), CAST('2024-02-29T12:34:00' AS smalldatetime), CAST(-12.3400 AS smallmoney)",
1, &cancel).await.unwrap();
assert_eq!(
r.rows[0],
[
"2024-02-29 00:15:00.1234567+05:30",
"2024-02-29",
"12:34:56.1234567",
"2024-02-29 12:34:56.007",
"2024-02-29 12:34:00",
"-12.3400"
]
.map(|s| Some(s.to_owned()))
);
held.command(&format!("DROP TABLE {table}")).await.unwrap();
drop(held);
// A unique permanent table lets transaction calls acquire any pooled connection.
let table = format!("tempdb.dbo.oi_pr276_{}", std::process::id());
pool.execute(&format!("CREATE TABLE {table} (id int PRIMARY KEY, v int)"), 1, &cancel).await.unwrap();
pool.transaction(&[(format!("INSERT INTO {table} VALUES (1, 10)"), true)]).await.unwrap();
for failing in [format!("INSERT INTO {table} VALUES (1, 20)"), format!("UPDATE {table} SET v = 30 WHERE id = 999")]
{
assert!(
pool.transaction(&[(format!("UPDATE {table} SET v = 99 WHERE id = 1"), true), (failing, true)])
.await
.is_err()
);
assert_eq!(pool.rows(&format!("SELECT v FROM {table}"), 1).await.unwrap()[0][0].as_deref(), Some("10"));
assert_eq!(pool.rows("SELECT @@TRANCOUNT", 1).await.unwrap()[0][0].as_deref(), Some("0"));
}
assert_eq!(pool.transaction(&[(format!("UPDATE {table} SET v = 11 WHERE id = 1"), true)]).await.unwrap(), 1);
pool.execute(&format!("DROP TABLE {table}"), 1, &cancel).await.unwrap();
let cancel = CancellationToken::new();
let stop = cancel.clone();
let task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
stop.cancel();
});
assert!(
tokio::time::timeout(Duration::from_secs(2), pool.execute("WAITFOR DELAY '00:00:10'; SELECT 1", 1, &cancel))
.await
.unwrap()
.is_err()
);
task.await.unwrap();
assert_eq!(pool.rows("SELECT 1", 1).await.unwrap()[0][0].as_deref(), Some("1"));
}
+2 -2
View File
@@ -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);
ide_core::output_encoding::decode_tool_output(&buf)
String::from_utf8_lossy(&buf).into_owned()
})
}
@@ -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 = ide_core::output_encoding::decode_tool_output(pending).trim_end().to_string();
let line = String::from_utf8_lossy(pending).trim_end().to_string();
pending.clear();
let _ = tx.send(Event::Line(stream, line));
}
+15 -57
View File
@@ -4,14 +4,13 @@
//! 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 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.
//! 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.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::sync::OnceLock;
/// One installed family. `monospaced` is the font system's own flag (fixed
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
@@ -22,23 +21,10 @@ pub struct FontFamily {
pub monospaced: bool,
}
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
/// The installed families, sorted case-insensitively, read on first use and
/// re-queried when `refresh` is true.
pub fn families(refresh: bool) -> Arc<[FontFamily]> {
static CACHE: Cache = Mutex::new(None);
cached(&CACHE, refresh, imp::list)
}
fn cached(cache: &Cache, refresh: bool, list: impl FnOnce(bool) -> Vec<(String, bool)>) -> Arc<[FontFamily]> {
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if !refresh && let Some(families) = guard.as_ref() {
return Arc::clone(families);
}
let families: Arc<[FontFamily]> = normalize(list(refresh)).into();
*guard = Some(Arc::clone(&families));
families
/// 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()))
}
/// Merges the per-face records a font system reports into one entry per
@@ -93,7 +79,7 @@ mod imp {
};
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
pub(crate) fn list() -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
unsafe {
@@ -130,15 +116,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(refresh: bool) -> Vec<(String, bool)> {
pub(crate) fn list() -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read(refresh) }.unwrap_or_default()
unsafe { read() }.unwrap_or_default()
}
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
let mut collection = None;
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
unsafe { factory.GetSystemFontCollection(&mut collection, false) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
@@ -185,7 +171,7 @@ mod imp {
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
/// `fc-list` isn't installed.
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
pub(crate) fn list() -> Vec<(String, bool)> {
Command::new("fc-list")
.args(["-f", "%{family[0]}\t%{spacing}\n"])
.stdin(Stdio::null())
@@ -200,7 +186,7 @@ mod imp {
#[cfg(not(any(unix, windows)))]
mod imp {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
pub(crate) fn list() -> Vec<(String, bool)> {
Vec::new()
}
}
@@ -208,7 +194,6 @@ 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 }
@@ -258,38 +243,12 @@ 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(false);
let list = families();
assert!(list.len() > 20, "only {} families", list.len());
assert!(list.iter().any(|f| f.monospaced));
assert!(list.iter().any(|f| !f.monospaced));
@@ -301,6 +260,5 @@ mod tests {
assert!(menlo.monospaced);
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
}
assert_eq!(&*families(true), &*list);
}
}
+1 -5
View File
@@ -10,7 +10,6 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
@@ -35,20 +34,17 @@ ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
tokio = { workspace = true, features = ["io-std"] }
tracing.workspace = true
tracing-subscriber.workspace = true
parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
tempfile.workspace = true
+1 -2
View File
@@ -281,8 +281,7 @@ fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &d
for pipe in pipes.into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in BufReader::new(pipe).split(b'\n').map_while(Result::ok) {
let line = ide_core::output_encoding::decode_tool_output(&line).trim_end_matches('\r').to_owned();
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
-183
View File
@@ -1,183 +0,0 @@
//! Automatic runtime provisioning belongs to the host, not SDK discovery.
use ide_core::{CancellationToken, IdeEvent, SharedSink, TaskId};
use ide_language::LanguageServerConfig;
use ide_sdk::java::{jdtls_jdks, jdtls_min_java, jdtls_runtime, language_server_runtimes_dir};
use parking_lot::{Condvar, Mutex};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Weak};
use std::time::Duration;
#[derive(Default)]
struct State {
busy: bool,
sinks: Vec<Weak<dyn ide_core::EventSink>>,
attempted: HashSet<(PathBuf, u32)>,
}
#[derive(Default)]
struct Provisioner {
state: Mutex<State>,
changed: Condvar,
}
static PROVISIONER: LazyLock<Provisioner> = LazyLock::new(Provisioner::default);
fn needs_download(explicit: bool, suitable: bool, attempted: bool) -> bool {
!explicit && !suitable && !attempted
}
impl Provisioner {
fn subscribe(&self, sink: &SharedSink) {
let mut state = self.state.lock();
state.sinks.retain(|s| s.strong_count() > 0);
let weak = Arc::downgrade(sink);
if !state.sinks.iter().any(|s| s.ptr_eq(&weak)) {
state.sinks.push(weak);
}
}
fn emit(&self, event: IdeEvent) {
let sinks: Vec<_> = self.state.lock().sinks.iter().filter_map(Weak::upgrade).collect();
for sink in sinks {
sink.emit(event.clone());
}
}
fn run(&self, key: (PathBuf, u32), cancel: &CancellationToken, work: impl FnOnce()) {
let mut state = self.state.lock();
while state.busy && !cancel.is_cancelled() {
self.changed.wait_for(&mut state, Duration::from_millis(100));
}
if cancel.is_cancelled() || !state.attempted.insert(key) {
return;
}
state.busy = true;
drop(state);
// Even a panicking installer must release waiters and retain the attempt.
struct Flight<'a>(&'a Provisioner);
impl Drop for Flight<'_> {
fn drop(&mut self) {
self.0.state.lock().busy = false;
self.0.changed.notify_all();
}
}
let _flight = Flight(self);
work();
}
}
pub(crate) fn prepare(
cfg: &LanguageServerConfig,
data_dir: &Path,
explicit: bool,
sink: &SharedSink,
launch_cancel: &CancellationToken,
) {
let min_java = jdtls_min_java(cfg);
let jdks = jdtls_jdks(min_java, data_dir);
// A launch environment override is explicit too, even if it is invalid.
if !needs_download(explicit || cfg.env.contains_key("JAVA_HOME"), jdtls_runtime(&jdks, min_java).is_some(), false) {
return;
}
PROVISIONER.subscribe(sink);
PROVISIONER.run((data_dir.to_path_buf(), min_java), launch_cancel, || {
if jdtls_runtime(&jdtls_jdks(min_java, data_dir), min_java).is_some() {
return;
}
let id = TaskId::next();
let title = format!("Downloading Java {min_java} language server runtime");
// Closing one project must not cancel a download other projects await.
let cancel = CancellationToken::new();
let _handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let emit = |message, fraction, done| {
PROVISIONER.emit(IdeEvent::Task { id, title: title.clone(), message, fraction, done, cancellable: !done });
};
emit(Some("Eclipse Temurin".into()), None, false);
let result = ide_tools::Tools::new(data_dir).download_java_runtime(
min_java,
&language_server_runtimes_dir(data_dir),
&cancel,
&|event| match event {
ide_tools::InstallEvent::Line { text } => emit(Some(text), None, false),
ide_tools::InstallEvent::Progress { fraction, message } => {
emit(Some(message), fraction.map(|f| f as f32), false)
}
},
);
emit(result.as_ref().err().map(ToString::to_string), Some(1.0), true);
if let Err(error) = result {
// The configuration hook retains the existing manual download action.
tracing::warn!("automatic jdtls runtime download: {error}");
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn download_decision() {
assert!(needs_download(false, false, false));
for (explicit, suitable, attempted) in [(true, false, false), (false, true, false), (false, false, true)] {
assert!(!needs_download(explicit, suitable, attempted));
}
}
#[test]
fn simultaneous_projects_share_one_attempt_and_failures_do_not_retry() {
let provisioner = Provisioner::default();
let calls = AtomicUsize::new(0);
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
barrier.wait();
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
});
});
}
});
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || panic!("retried"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn different_versions_are_still_single_flight() {
let provisioner = Provisioner::default();
let active = AtomicUsize::new(0);
let calls = AtomicUsize::new(0);
std::thread::scope(|scope| {
for feature in [25, 26, 27] {
let provisioner = &provisioner;
let active = &active;
let calls = &calls;
scope.spawn(move || {
provisioner.run((PathBuf::from("/data"), feature), &CancellationToken::new(), || {
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
std::thread::sleep(Duration::from_millis(10));
calls.fetch_add(1, Ordering::SeqCst);
active.fetch_sub(1, Ordering::SeqCst);
});
});
}
});
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn cancelled_waiter_does_not_consume_an_attempt() {
let provisioner = Provisioner::default();
let cancel = CancellationToken::new();
cancel.cancel();
provisioner.run((PathBuf::from("/data"), 25), &cancel, || panic!("cancelled"));
assert!(provisioner.state.lock().attempted.is_empty());
}
}
+4 -55
View File
@@ -4,8 +4,6 @@
//! 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;
@@ -134,75 +132,26 @@ impl Host {
}
pub fn close_project(&self, window: &str) {
let project = self.projects.write().remove(window);
if let Some(project) = project {
if let Some(project) = self.projects.write().remove(window) {
if !project.light {
self.environment.project_closed();
}
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 });
}
}
/// 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 &notebooks {
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.
/// Stops what this host started: language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.environment.shutdown();
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
project.notebooks.stop_admission();
for project in projects {
project.agents.shutdown();
}
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
// outer LSP timeout must never abort it before owned children are reaped.
for project in &projects {
project.notebooks.shutdown().await;
}
let stop_language_servers = async {
for project in &projects {
project.lsp.shutdown_all().await;
}
};
if tokio::time::timeout(std::time::Duration::from_secs(10), stop_language_servers).await.is_err() {
tracing::warn!("language servers did not shut down in time");
project.lsp.shutdown_all().await;
}
}
-796
View File
@@ -1,796 +0,0 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
use serde::Serialize;
use specta::Type;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex as AsyncMutex, watch};
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: String,
pub cell: String,
pub msg_id: String,
pub kind: String,
#[specta(type = specta_typescript::Unknown)]
pub content: serde_json::Value,
pub version: u32,
pub dirty: bool,
pub changed_cells: Vec<String>,
}
#[derive(Default)]
struct Registry {
stopped: bool,
paths: HashMap<PathBuf, String>,
documents: HashMap<String, Arc<ManagedDocument>>,
}
struct ManagedDocument {
id: String,
path: PathBuf,
state: Mutex<DocumentState>,
control: AsyncMutex<()>,
}
struct DocumentState {
document: NotebookDocument,
generation: u64,
transitioning: bool,
closed: bool,
active: Option<Arc<Batch>>,
kernel_status: Option<(String, Option<String>)>,
}
struct Batch {
cancel: CancellationToken,
done: watch::Sender<bool>,
}
struct BatchGuard {
document: Arc<ManagedDocument>,
batch: Arc<Batch>,
}
pub(crate) struct ExecutionAdmission {
guard: BatchGuard,
generation: u64,
plan: Vec<(String, String)>,
}
impl Drop for BatchGuard {
fn drop(&mut self) {
let mut state = self.document.state.lock();
if state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, &self.batch)) {
if let Err(error) = state.document.set_execution_active(false) {
tracing::error!("notebook batch release: {error}");
}
state.active = None;
}
self.batch.done.send_replace(true);
}
}
pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
}
impl NotebookService {
pub fn new(runtime_dir: PathBuf, sink: SharedSink) -> Arc<Self> {
let registry = Arc::new(Mutex::new(Registry::default()));
let status_registry = registry.clone();
let status_sink = sink.clone();
let kernels = KernelManager::new(
runtime_dir,
Arc::new(move |path, status, message| {
let document = {
let registry = status_registry.lock();
if registry.stopped {
return;
}
registry.paths.get(path).and_then(|id| registry.documents.get(id)).cloned()
};
if let Some(document) = document {
let deliver = {
let mut state = document.state.lock();
if state.closed {
return;
}
state.kernel_status = Some((status.as_str().to_owned(), message.clone()));
!state.transitioning
};
if deliver {
status_sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: path.to_path_buf().into(),
state: status.as_str().to_owned(),
message,
});
}
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
let path = dunce::canonicalize(path)?;
// Serialize first load with path insertion: split views share one incarnation.
let mut registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::cancelled());
}
if let Some(id) = registry.paths.get(&path) {
return snapshot(&registry.documents[id]);
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
id.clone(),
Arc::new(ManagedDocument {
id,
path,
state: Mutex::new(DocumentState {
document,
generation: 0,
transitioning: false,
closed: false,
active: None,
kernel_status: None,
}),
control: AsyncMutex::new(()),
}),
);
Ok(initial)
}
fn resolve(&self, id: &str) -> IdeResult<Arc<ManagedDocument>> {
let registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::not_found("notebook project has closed"));
}
registry.documents.get(id).cloned().ok_or_else(|| IdeError::not_found("notebook incarnation is closed"))
}
pub fn get(&self, id: &str) -> IdeResult<NotebookSnapshot> {
snapshot(self.resolve(id)?.as_ref())
}
pub fn edit(&self, id: &str, version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.edit(version, edit)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.save()
}
pub fn undo(&self, id: &str, redo: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
if redo { state.document.redo() } else { state.document.undo() }
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
let document = self.resolve(id)?;
let batch = document.state.lock().active.clone();
if let Some(batch) = &batch {
batch.cancel.cancel();
}
// Draining makes prepareClose/save a stable checkpoint, including when
// Python catches KeyboardInterrupt or cancel races with kernel startup.
let interrupt = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
interrupt
}
async fn drain(&self, document: &ManagedDocument, batch: Option<Arc<Batch>>) -> IdeResult<()> {
let Some(batch) = batch else { return Ok(()) };
let mut done = batch.done.subscribe();
match tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done)).await {
Ok(Ok(_)) => return Ok(()),
Ok(Err(_)) => return Err(IdeError::internal("notebook execution completion channel closed")),
Err(_) => {}
}
// An uncooperative kernel must not outlive a close/reload authorization.
self.kernels.shutdown_kernel(&document.path).await?;
tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done))
.await
.map_err(|_| IdeError::unavailable("notebook execution did not stop after kernel shutdown"))?
.map_err(|_| IdeError::internal("notebook execution completion channel closed"))?;
Ok(())
}
pub async fn reload(&self, id: &str, restart: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let _control = document.control.lock().await;
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
if document.state.lock().closed {
return Err(IdeError::cancelled());
}
if restart {
self.kernels.restart(&document.path).await?;
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
document.state.lock().document.reload()
}
}
.await;
let status = {
let mut state = document.state.lock();
state.transitioning = false;
if state.closed { None } else { state.kernel_status.clone() }
};
if let Some((state, message)) = status {
self.sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: document.path.clone().into(),
state,
message,
});
}
result
}
/// Frontend disposal may arrive after project replacement has already retired this id.
pub async fn close(&self, id: &str) -> IdeResult<()> {
let document = match self.resolve(id) {
Ok(document) => document,
Err(error) if error.kind == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let _control = document.control.lock().await;
if document.state.lock().closed {
return Ok(());
}
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
self.kernels.shutdown_kernel(&document.path).await
}
.await;
if let Err(error) = result {
document.state.lock().transitioning = false;
return Err(error);
}
document.state.lock().closed = true;
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
Ok(())
}
pub(crate) fn admit(
&self,
id: &str,
cells: Vec<String>,
cancel: CancellationToken,
) -> IdeResult<ExecutionAdmission> {
admit(self.resolve(id)?, cells, cancel)
}
pub(crate) async fn execute(
self: &Arc<Self>,
admission: ExecutionAdmission,
python: PathBuf,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
let document = admission.guard.document.clone();
let service = self.clone();
// Keep admission and native execution owned if an IPC receiver disappears.
tokio::spawn(async move {
let ExecutionAdmission { guard, generation, plan } = admission;
let result =
service.run_batch(document.clone(), generation, plan, python, guard.batch.clone(), on_output).await;
drop(guard);
match result {
Ok(()) => snapshot(&document),
Err(error) => Err(error),
}
})
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
async fn run_batch(
&self,
document: Arc<ManagedDocument>,
generation: u64,
plan: Vec<(String, String)>,
python: PathBuf,
batch: Arc<Batch>,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<()> {
for (cell, source) in plan {
{
let mut state = document.state.lock();
if batch.cancel.is_cancelled() || state.closed || state.generation != generation {
return Err(IdeError::cancelled());
}
// A user may delete a queued cell while an earlier one executes.
// Never resurrect it from the captured source or a late output.
match state.document.begin_execution(&cell) {
Ok(()) => {}
Err(error) if error.kind == ErrorKind::NotFound => continue,
Err(error) => return Err(error),
}
}
let output_error = Arc::new(Mutex::new(None));
let callback_document = document.clone();
let callback_batch = batch.clone();
let callback_error = output_error.clone();
let callback_cell = cell.clone();
let channel = on_output.clone();
let result = self
.kernels
.execute(
&document.path,
&python,
&source,
batch.cancel.clone(),
Arc::new(move |output: KernelOutput| {
let event = match commit_output(
&callback_document,
&callback_batch,
generation,
&callback_cell,
output,
) {
Ok(Some(event)) => event,
Ok(None) => return,
Err(error) => {
*callback_error.lock() = Some(error);
callback_batch.cancel.cancel();
return;
}
};
let _ = channel.send(event);
}),
)
.await;
if let Some(error) = output_error.lock().take() {
return Err(error);
}
let result = result?;
if batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if result.status != "ok" {
break;
}
}
let state = document.state.lock();
if state.closed || state.generation != generation || batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Synchronously revoke admission before handing cleanup to an async runtime.
pub fn stop_admission(&self) {
let mut registry = self.registry.lock();
registry.stopped = true;
for document in registry.documents.values() {
let mut state = document.state.lock();
state.closed = true;
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
}
pub async fn 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();
}
}
+2 -53
View File
@@ -87,14 +87,8 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
let project = host.project(window)?;
let root = project.root.clone();
let requested = request.project_root.take();
request.project_root = Some(IpcPath(
host.blocking(move || validate_project_root(&root, requested.as_ref().map(|p| p.0.as_path()))).await?,
));
project.agents.create(request).await
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
host.project(window)?.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
@@ -146,10 +140,6 @@ 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
}
@@ -193,7 +183,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
@@ -202,43 +191,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
let Some(requested) = requested else {
return Ok(root.to_path_buf());
};
if !requested.is_absolute() {
return Err(IdeError::invalid("Choose an absolute project root"));
}
let requested = dunce::canonicalize(requested)?;
let window_root = dunce::canonicalize(root)?;
if requested == window_root
|| ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.projects.contains(&requested))
{
return Ok(requested);
}
Err(IdeError::invalid("Agent project must be the window project or a workspace member"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
let member = tmp.path().join("member");
let other = tmp.path().join("other");
for dir in [&root, &member, &other, &member.join("child")] {
std::fs::create_dir_all(dir).unwrap();
}
assert_eq!(validate_project_root(&root, None).unwrap(), root);
assert_eq!(validate_project_root(&root, Some(&root)).unwrap(), dunce::canonicalize(&root).unwrap());
assert!(validate_project_root(&root, Some(&member)).is_err());
ide_workspace::workspace::Workspace::save(&root, "Workspace", vec![member.clone()], true).unwrap();
assert_eq!(validate_project_root(&root, Some(&member)).unwrap(), dunce::canonicalize(&member).unwrap());
for rejected in [other, member.join("child"), member.join("../other"), "relative".into()] {
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
}
}
}
+6 -77
View File
@@ -27,12 +27,7 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> B
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mut model = ide_build::load_linked(
root,
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
);
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
let mvn = ide_sdk::apply::maven_command(root, settings);
@@ -46,8 +41,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
if let Some(g) = &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = java_env(jdk.as_ref(), g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -68,7 +62,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
let hint = missing(g.java_version);
explain(&mut g.sync_error, hint);
}
model
@@ -113,8 +107,6 @@ pub async fn build_sync(
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
@@ -123,27 +115,12 @@ pub async fn build_sync(
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
Ok(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& 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)
result?
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
@@ -226,7 +203,6 @@ 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 {
@@ -236,23 +212,7 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
}
}
let mut plan = combined.ok_or_else(|| IdeError::invalid("No workspace project to build"))?;
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
};
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
@@ -301,34 +261,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
let mut settings = Settings::default();
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(default.command, wrapper.to_string_lossy());
settings.languages.java.gradle.jvm = "jdk21".into();
settings.languages.java.gradle.user_home = "user-home".into();
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
settings.languages.java.gradle.home = "gradle-install".into();
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(
model.command,
root.join("gradle-install")
.join("bin")
.join(if cfg!(windows) { "gradle.bat" } else { "gradle" })
.to_string_lossy()
);
assert_eq!(model.env["JAVA_HOME"], root.join("jdk21").to_string_lossy());
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
}
}
+2 -41
View File
@@ -1,5 +1,3 @@
mod output;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DebugSessionId, IdeResult};
@@ -16,54 +14,17 @@ 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 (sink, flush_output) = output::coalescing_sink(host.window_sink(label));
let owner = SessionOwner::new(host.owner_window(label), sink);
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
let settings = match host.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
if config.adapter == 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())
};
let result = host.debugger.start_with(config, owner, custom).await;
flush_output();
result
host.debugger.start_with(config, owner, custom).await
}
/// A JVM launched by java-debug writes what java-debug decodes its output as
-182
View File
@@ -1,182 +0,0 @@
use ide_core::event::DebugEvent;
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: Duration = Duration::from_millis(50);
const MAX_BYTES: usize = 64 * 1024;
#[derive(Default)]
struct Pending {
sessions: HashMap<DebugSessionId, (String, String)>,
scheduled: bool,
}
fn flush(pending: &mut Pending, sink: &SharedSink, session: DebugSessionId) {
if let Some((category, output)) = pending.sessions.remove(&session) {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
/// All debug output sources (including build output and child sessions) share this
/// bounded batch before reaching desktop or remote IPC. Control events flush
/// their session first, so a stop or exit cannot overtake preceding output.
pub(super) fn coalescing_sink(sink: SharedSink) -> (SharedSink, impl Fn()) {
let pending = Arc::new(Mutex::new(Pending::default()));
let flush_pending = {
let pending = pending.clone();
let sink = sink.clone();
move || {
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
};
let runtime = tokio::runtime::Handle::current();
let output: SharedSink = Arc::new(move |event| {
let mut state = pending.lock();
match event {
IdeEvent::Debug { session, event: DebugEvent::Output { category, output } } => {
if output.is_empty() {
return;
}
if state.sessions.get(&session).is_some_and(|(c, _)| *c != category) {
flush(&mut state, &sink, session);
}
let mut rest = output.as_str();
while !rest.is_empty() {
let (_, text) = state.sessions.entry(session).or_insert_with(|| (category.clone(), String::new()));
let mut end = rest.len().min(MAX_BYTES - text.len());
while !rest.is_char_boundary(end) {
end -= 1;
}
text.push_str(&rest[..end]);
rest = &rest[end..];
if text.len() == MAX_BYTES || !rest.is_empty() {
flush(&mut state, &sink, session);
}
}
if !state.scheduled && !state.sessions.is_empty() {
state.scheduled = true;
let pending = pending.clone();
let sink = sink.clone();
runtime.spawn(async move {
tokio::time::sleep(WINDOW).await;
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
state.scheduled = false;
});
}
}
IdeEvent::Debug { session, event } => {
flush(&mut state, &sink, session);
sink.emit(IdeEvent::Debug { session, event });
}
event => sink.emit(event),
}
});
(output, flush_pending)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
(coalescing_sink(Arc::new(move |event| saved.lock().push(event))).0, events)
}
fn output(sink: &SharedSink, session: u32, category: &str, output: &str) {
sink.emit(IdeEvent::Debug {
session: DebugSessionId(session),
event: DebugEvent::Output { category: category.into(), output: output.into() },
});
}
#[tokio::test(start_paused = true)]
async fn explicit_start_completion_flush_does_not_duplicate_output() {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
let (sink, flush) = coalescing_sink(Arc::new(move |event| saved.lock().push(event)));
output(&sink, 1, "console", "build failed\n");
flush();
assert_eq!(events.lock().len(), 1);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
assert_eq!(events.lock().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn batches_per_session_and_flushes_idle_output() {
let (sink, events) = setup();
for _ in 0..500 {
output(&sink, 1, "stdout", "line\n");
}
output(&sink, 2, "stdout", "other\n");
assert!(events.lock().is_empty());
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 2);
assert!(events.iter().any(|event| matches!(event,
IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Output { output, .. } }
if output == &"line\n".repeat(500))));
}
#[tokio::test]
async fn categories_and_control_events_keep_order() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "out");
output(&sink, 1, "stderr", "err");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[0], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stdout" && output == "out"));
assert!(matches!(&events[1], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stderr" && output == "err"));
assert!(matches!(&events[2], IdeEvent::Debug { event: DebugEvent::Terminated, .. }));
}
#[tokio::test(start_paused = true)]
async fn terminating_one_session_preserves_other_pending_output() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "first");
output(&sink, 2, "stdout", "second");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
assert_eq!(events.lock().len(), 2);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[2],
IdeEvent::Debug { session: DebugSessionId(2), event: DebugEvent::Output { output, .. } }
if output == "second"));
}
#[tokio::test]
async fn large_unicode_events_are_bounded_without_loss() {
let (sink, events) = setup();
let text = "日志\n".repeat(100_000);
output(&sink, 1, "stdout", &text);
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let mut combined = String::new();
for event in events.lock().iter() {
if let IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } = event {
assert!(output.len() <= MAX_BYTES);
combined.push_str(output);
}
}
assert_eq!(combined, text);
}
}
+2 -4
View File
@@ -28,14 +28,12 @@ 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.debugger.refresh_source_mirrors(host.owner_window(window)).await
host.blocking(move || project.documents.save(doc)).await
}
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save_all()).await?;
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
host.blocking(move || project.documents.save_all()).await
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
-8
View File
@@ -22,13 +22,6 @@ 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)
@@ -115,7 +108,6 @@ 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")?)
});
+5 -27
View File
@@ -4,11 +4,10 @@ use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_core::{CancellationToken, IdeError, IdeResult};
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.
@@ -18,39 +17,22 @@ 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, &cancel)
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
} else {
Vec::new()
};
let mut info = project.lsp.java_launch(&module, test, build).await?;
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
@@ -82,9 +64,6 @@ async fn 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)
}
@@ -103,14 +82,13 @@ 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, &CancellationToken::new()).await
project.lsp.java_build(&module).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
.await
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
+6 -23
View File
@@ -186,19 +186,8 @@ 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,
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_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
host.project(window)?.lsp.rename(doc, pos, new_name).await
}
pub async fn lsp_format(
@@ -231,16 +220,11 @@ 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<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
) -> IdeResult<Vec<HierarchyItem>> {
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
}
pub async fn lsp_type_hierarchy(
@@ -351,7 +335,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("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
});
d.add("lsp_format", |c| async move {
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
@@ -364,8 +348,7 @@ 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("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?)
.await
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
});
d.add("lsp_type_hierarchy", |c| async move {
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
-2
View File
@@ -15,7 +15,6 @@ pub mod history;
pub mod java;
pub mod lsp;
pub mod nav;
pub mod notebook;
pub mod project;
mod python;
pub mod refactor;
@@ -61,7 +60,6 @@ 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);
-155
View File
@@ -1,155 +0,0 @@
//! 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
});
}
+1 -42
View File
@@ -14,8 +14,6 @@ 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 {
@@ -30,7 +28,6 @@ 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(),
}
}
@@ -56,42 +53,6 @@ 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,
@@ -168,9 +129,7 @@ pub fn set_settings(
host.runtime.spawn(async move { lsp.reconfigure().await });
}
}
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
|| "languages.java.maven.unlinkedProjects".starts_with(key);
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
+10 -80
View File
@@ -9,59 +9,16 @@ 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 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);
}
}
}
}
let mut all: Vec<RunConfig> =
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
all.extend(user);
// Project interpreter / package manager / toolchains from Languages & Frameworks.
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
Ok(all)
}
/// 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 fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
ide_run::save_user_configs(&host.project(window)?.root, configs)
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
@@ -70,9 +27,6 @@ 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();
@@ -81,41 +35,17 @@ 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 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(),
};
let settings = project.settings.effective();
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
let mut config = config;
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);
}
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
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: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
python_paths: super::python::paths(&project),
};
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
}
-79
View File
@@ -49,87 +49,8 @@ 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)) });
+5 -14
View File
@@ -42,16 +42,13 @@ 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 { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
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();
@@ -69,7 +66,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).filter(|_| include_root).chain(&content_roots) {
for dir in std::iter::once(&root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
@@ -77,13 +74,7 @@ pub async fn start(
Some(&literals),
&|p| documents.text_of(p),
&cancel,
&|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 });
},
&|file| events(SearchEvent::File { file }),
)?;
total.files += s.files;
total.matches += s.matches;
+24 -55
View File
@@ -45,16 +45,13 @@ pub enum RemoteOp {
branch: String,
},
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
/// remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
/// remote picker).
/// remote branch `target` of the same remote.
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 {
@@ -190,33 +187,18 @@ 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.vcs_encoding(&path, None)?))
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
}
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(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
}
pub async fn vcs_blame(
host: &Host,
window: &str,
path: PathBuf,
text: Option<String>,
revision: Option<String>,
) -> IdeResult<Vec<BlameLine>> {
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
let project = host.project(window)?;
// 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
let text = project.documents.text_of(&path);
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
@@ -261,13 +243,12 @@ 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<Vec<String>> {
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
for path in &paths {
@@ -283,7 +264,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(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
result.map(|_| ())
})
.await
}
@@ -295,7 +276,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, &|path| project.vcs_encoding(path, Some(":0"))))).await
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
}
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
@@ -312,12 +293,7 @@ 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, &|path| project.vcs_encoding(path, None))
})
})
.await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
}
pub async fn vcs_checkout(
@@ -467,17 +443,12 @@ 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, 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::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::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
RemoteOp::PullFrom { remote, branches, rebase, options } => {
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
@@ -493,14 +464,12 @@ 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, project.vcs_encoding(&path, Some(":2"))?)))
.await
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).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, project.vcs_encoding(&path, None)?)))
.await
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
}
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
@@ -512,11 +481,13 @@ 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 encoding = project.vcs_encoding(&path, Some(":0"))?;
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
let current = ide_vfs::load_text(&path)?.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();
r.stage_hunks(&path, &current, &selected, encoding)
let patch = ide_vcs::build_partial_patch(&rel, &index_text, &current, &selected);
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
})
}
@@ -845,9 +816,7 @@ 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")?, c.arg("text")?, c.arg("revision")?).await
});
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).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
+39 -244
View File
@@ -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, LanguageId, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_index::ProjectIndex;
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
use ide_lsp::LspManager;
@@ -46,8 +46,6 @@ 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)>>,
@@ -67,8 +65,6 @@ 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)
@@ -83,14 +79,20 @@ 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(&root, &settings.effective(), &detected_roots.read());
let excludes = exclude_globs(&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 = documents(&sink, &languages, 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)),
))
};
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)?);
{
@@ -106,7 +108,7 @@ impl Host {
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
extra_roots.clone(),
excludes.clone(),
languages.clone(),
self.syntax(),
@@ -114,40 +116,31 @@ 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 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);
let effective = settings.effective();
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, &data_dir);
let jdks = ide_sdk::java::jdtls_jdks(min_java);
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
});
@@ -156,7 +149,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,
@@ -165,27 +158,6 @@ 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,
);
}
}));
}
{
@@ -216,7 +188,6 @@ 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();
@@ -232,12 +203,6 @@ 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(
@@ -256,10 +221,7 @@ impl Host {
if *detected_roots.read() != fresh {
// Detected excluded folders (`target`, `build`) leave the index too.
let effective = settings.effective();
index.set_scope(
index_roots(&root, content_roots(&effective, &root)),
exclude_globs(&root, &effective, &fresh),
);
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
*detected_roots.write() = fresh;
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
@@ -267,7 +229,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).chain(&batch.vcs).any(|p| {
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
else {
return false;
@@ -277,10 +239,7 @@ impl Host {
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
}
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() });
}
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}),
)?
};
@@ -323,10 +282,6 @@ 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,
@@ -346,7 +301,6 @@ impl Host {
db,
http_history,
agents,
notebooks,
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
@@ -421,7 +375,13 @@ 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 = documents(&sink, &languages, 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)),
))
};
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())?;
@@ -433,10 +393,6 @@ 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,
@@ -454,7 +410,6 @@ impl Host {
db,
http_history,
agents,
notebooks,
watcher,
content_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
@@ -485,18 +440,6 @@ 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,
@@ -522,9 +465,7 @@ 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()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root));
match Repos::discover_roots(&roots, &skip) {
match Repos::discover(root, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
@@ -536,10 +477,6 @@ 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()
@@ -630,7 +567,7 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -665,23 +602,17 @@ impl Project {
q: &ide_lsp::CandidateQuery,
cancel: &CancellationToken,
) -> ide_lsp::CandidateFiles {
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 {
if !q.root.starts_with(&self.root) {
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(&root.join(rel), None));
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
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) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
Err(e) => {
tracing::debug!("reference candidates: {}", e.message);
@@ -692,7 +623,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.root, &self.settings.effective(), &self.detected_roots.read())
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
}
/// Project Structure content roots besides the project folder (existing directories).
@@ -718,8 +649,7 @@ impl Project {
return;
}
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
self.lsp.set_content_roots(roots.clone());
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
self.index.set_scope(roots.clone(), excludes.clone());
self.watcher.set_excludes(&excludes);
let mut watchers = self.content_watchers.lock();
watchers.retain(|(p, _)| roots.contains(p));
@@ -738,29 +668,11 @@ 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_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
None => Ok(ide_vfs::load_text(path)?.text),
}
}
}
@@ -823,16 +735,8 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
}
}
/// 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.
/// Project Structure content roots outside `root` (existing absolute 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());
@@ -869,27 +773,13 @@ fn content_root_watcher(
.ok()
}
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> {
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
let mut globs = settings.file_excludes();
globs.extend(linked_roots(root, settings, detected).excluded_globs());
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
globs
}
pub(crate) fn project_key(root: &Path) -> String {
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;
@@ -1016,98 +906,3 @@ 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));
}
}
+1 -6
View File
@@ -200,12 +200,7 @@ 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")?,
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
)
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
})
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
-79
View File
@@ -1,79 +0,0 @@
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);
}
-111
View File
@@ -1,111 +0,0 @@
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");
}
-114
View File
@@ -1,114 +0,0 @@
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");
}
+14 -21
View File
@@ -15,7 +15,6 @@ 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,
@@ -23,7 +22,7 @@ pub(crate) struct PathFilter {
/// applies nested ignore files itself).
gitignore: Gitignore,
/// What the filter was built from: `(extra roots, exclude globs)`.
config: (crate::IndexRoots, Vec<String>),
config: (Vec<PathBuf>, Vec<String>),
}
struct ExtraRoot {
@@ -69,7 +68,7 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
}
impl PathFilter {
pub(crate) fn new(root: &Path, extra_roots: &crate::IndexRoots, exclude_globs: &[String]) -> Self {
pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self {
let mut set = GlobSetBuilder::new();
for raw in exclude_globs {
let p = raw.trim().trim_end_matches('/');
@@ -93,9 +92,10 @@ impl PathFilter {
}
let excludes = set.build().unwrap_or_else(|_| GlobSet::empty());
let mut extra: Vec<ExtraRoot> = Vec::new();
for r in &extra_roots.folders {
for r in extra_roots {
let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone());
let nested = (extra_roots.include_root && (path.starts_with(root) || root.starts_with(&path)))
let nested = 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,17 +105,16 @@ impl PathFilter {
}
Self {
root: root.to_owned(),
include_root: extra_roots.include_root,
extra,
excludes,
gitignore: root_ignores(root),
config: (extra_roots.clone(), exclude_globs.to_vec()),
config: (extra_roots.to_vec(), exclude_globs.to_vec()),
}
}
/// Whether the filter was built from these roots and globs.
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 same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool {
self.config.0 == extra_roots && self.config.1 == excludes
}
pub(crate) fn root(&self) -> &Path {
@@ -124,14 +123,12 @@ 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())
.filter(|_| self.include_root)
.chain(self.extra.iter().map(|e| e.path.as_path()))
std::iter::once(self.root.as_path()).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 self.include_root && path.starts_with(&self.root) {
if path.starts_with(&self.root) {
return Some((&self.root, None, &self.gitignore));
}
self.extra
@@ -162,9 +159,6 @@ 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)
}
@@ -296,8 +290,7 @@ mod tests {
touch(&root, f);
}
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
let filter =
PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "target".into(), "docs/**".into()]);
let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]);
let mut found: Vec<String> =
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
found.sort();
@@ -320,7 +313,7 @@ mod tests {
touch(&base, f);
}
std::fs::write(shared.join(".gitignore"), "out/\n").unwrap();
let filter = PathFilter::new(&root, &vec![shared.clone(), root.join("src")].into(), &["target".into()]);
let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]);
assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots");
let mut found: Vec<String> = filter
.roots()
@@ -334,7 +327,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(&vec![shared.clone(), root.join("src")].into(), &["target".into()]));
assert!(!filter.same_config(&vec![shared].into(), &["target".into()]));
assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()]));
assert!(!filter.same_config(&[shared], &["target".into()]));
}
}
+5 -26
View File
@@ -70,19 +70,6 @@ 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.
@@ -90,7 +77,7 @@ impl ProjectIndex {
pub fn open(
root: &Path,
cache_dir: &Path,
content_roots: impl Into<IndexRoots>,
content_roots: Vec<PathBuf>,
excludes: Vec<String>,
languages: Arc<LanguageRegistry>,
syntax: Arc<Syntax>,
@@ -101,7 +88,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.into(), &excludes);
let filter = filter::PathFilter::new(&root, &content_roots, &excludes);
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
Self::start(shared, root, true)
}
@@ -115,7 +102,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, &Vec::new().into(), &[]);
let filter = filter::PathFilter::new(&root, &[], &[]);
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None));
Self::start(shared, root, false)
}
@@ -134,8 +121,7 @@ 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: impl Into<IndexRoots>, excludes: Vec<String>) -> bool {
let content_roots = content_roots.into();
pub fn set_scope(&self, content_roots: Vec<PathBuf>, excludes: Vec<String>) -> bool {
if self.shared.filter().same_config(&content_roots, &excludes) {
return false;
}
@@ -236,14 +222,7 @@ 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();
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()
recent.into_iter().filter(|p| p.to_str().is_some_and(ide_core::text::is_virtual_uri) || p.exists()).collect()
}
/// Blocks until the initial scan and every queued update has been applied
-35
View File
@@ -508,38 +508,3 @@ 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());
}
+3 -7
View File
@@ -168,15 +168,11 @@ impl Shared {
fn load_persisted(&self) {
let Some(store) = &self.store else { return };
let filter = self.filter();
let root = filter.root().to_owned();
let root = self.filter().root().to_owned();
match store.load() {
Ok(records) => {
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 entries: Vec<FileEntry> =
records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect();
let mut d = self.data.write();
for e in entries {
d.insert(e);
+1 -23
View File
@@ -332,25 +332,6 @@ 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
@@ -379,10 +360,7 @@ impl Peer {
framing: Framing,
) -> std::io::Result<Self> {
use std::process::Stdio;
// 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);
let mut cmd = tokio::process::Command::new(command);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(args)
.current_dir(cwd)
-1
View File
@@ -58,7 +58,6 @@ tree-sitter-gdscript = "6.1.0"
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
[[bench]]
name = "syntax"
-17
View File
@@ -9,10 +9,8 @@ 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,
@@ -43,10 +41,6 @@ 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)]
@@ -55,17 +49,6 @@ 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 {
-89
View File
@@ -1,89 +0,0 @@
//! 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"));
}
}
+2 -61
View File
@@ -8,7 +8,6 @@ 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")),
@@ -277,27 +276,6 @@ 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
@@ -370,10 +348,8 @@ mod tests {
use super::*;
use std::collections::{HashMap, HashSet};
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
/// (the UI resolves `ui-mode` against these).
/// `name`s exported by `@codemirror/language-data` (the UI resolves `ui-mode` against these).
const UI_MODES: &[&str] = &[
"Batch",
"C",
"C++",
"CQL",
@@ -535,7 +511,6 @@ mod tests {
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "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");
}
@@ -577,37 +552,6 @@ 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");
@@ -677,8 +621,7 @@ mod tests {
fn ui_modes_exist_in_codemirror_language_data() {
for (name, src) in BUILTIN {
let cfg = parse(src).unwrap();
let framework_modes = cfg.framework_ui_modes.iter().map(|f| &f.ui_mode);
for mode in cfg.ui_mode.iter().chain(framework_modes) {
if let Some(mode) = &cfg.ui_mode {
assert!(UI_MODES.contains(&mode.as_str()), "{name}: ui-mode {mode:?} is not a language-data name");
}
}
@@ -736,8 +679,6 @@ 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"),
+1
View File
@@ -21,6 +21,7 @@ futures.workspace = true
globset.workspace = true
toml.workspace = true
percent-encoding = "2"
which = "8"
[dev-dependencies]
tempfile.workspace = true
-34
View File
@@ -1176,40 +1176,6 @@ 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();
+24 -118
View File
@@ -8,7 +8,8 @@
use crate::LspManager;
use crate::server::{Running, Server};
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_core::{IdeError, IdeResult, LanguageId};
use ide_jsonrpc::RpcError;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
@@ -19,9 +20,7 @@ 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);
/// 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 BUILD_TIMEOUT: Duration = Duration::from_secs(300);
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);
@@ -71,8 +70,6 @@ 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).
@@ -166,19 +163,10 @@ 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, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
async fn jdtls(&self, path: &Path) -> IdeResult<(Arc<Server>, Running)> {
let java = LanguageId::new("java");
let server = self
.servers_for(&java, path)
@@ -186,7 +174,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 cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
match server.wait_ready(READY_TIMEOUT).await {
Some(run) => Ok((server, run)),
None => {
let status = server.status();
@@ -203,146 +191,79 @@ impl LspManager {
}
}
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()));
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);
match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
Err(_) => Err(IdeError::new(
ErrorKind::Protocol,
format!(
"{SERVER}: {method} timed out after {} s; retry, or check that jdtls is not stuck",
timeout.as_secs()
),
)),
Err(_) => Err(crate::rpc_err(&server.name, method, RpcError::Timeout)),
}
}
/// `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>,
cancel: &CancellationToken,
) -> IdeResult<Value> {
async fn jdtls_project_command(&self, path: &Path, command: &str, arguments: Vec<Value>) -> IdeResult<Value> {
let deadline = Instant::now() + IMPORT_WAIT;
loop {
match self.jdtls_command_cancellable(path, command, arguments.clone(), cancel).await {
match self.jdtls_command(path, command, arguments.clone()).await {
Err(e) if not_imported(&e) && Instant::now() < deadline => {
tracing::debug!("{command}: {e}; waiting for the project import");
cancellable_wait(cancel, tokio::time::sleep(Duration::from_millis(1500))).await?;
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, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
pub async fn java_build(&self, path: &Path) -> IdeResult<JavaBuildStatus> {
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT).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,
cancel: &CancellationToken,
) -> IdeResult<JavaClasspath> {
pub async fn java_classpath(&self, path: &Path, test: bool) -> 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)], cancel)
.await?;
let v =
self.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)]).await?;
Ok(parse_classpaths(&v))
}
/// The JDK home jdtls uses for the project containing `path`.
pub async fn java_vm_location(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<Option<PathBuf>> {
pub async fn java_vm_location(&self, path: &Path) -> 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,
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());
}
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 };
let (errors, error_count) = match build {
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
cancellable_wait(cancel, self.java_compile_errors(module)).await?
}
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => self.java_compile_errors(module).await,
_ => (Vec::new(), 0),
};
let cp = self.java_classpath(module, test, cancel).await?;
let java_home = match self.java_vm_location(module, cancel).await {
let cp = self.java_classpath(module, test).await?;
let java_home = match self.java_vm_location(module).await {
Ok(home) => home,
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => {
tracing::debug!("java.project.getSettings: {e}");
None
@@ -353,7 +274,6 @@ 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,
@@ -365,7 +285,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, &CancellationToken::new()).await else { return (Vec::new(), 0) };
let Ok((server, _)) = self.jdtls(module).await else { return (Vec::new(), 0) };
let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS);
let found = collect();
if found.1 > 0 {
@@ -403,20 +323,6 @@ 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 {
+101 -10
View File
@@ -1,12 +1,11 @@
//! 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;
use std::sync::{Arc, OnceLock};
#[derive(Debug)]
pub enum LaunchError {
@@ -73,7 +72,8 @@ impl Launcher for ProcessLauncher {
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
let binary = which::which_in(&config.command, Some(&search), root)
.ok()
.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 {
ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some()
which::which_in(&config.command, Some(server_search_path(config)), root).is_ok()
}
}
@@ -104,6 +104,9 @@ 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
@@ -150,6 +153,74 @@ 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.
@@ -160,10 +231,32 @@ 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 same lookup as `framework-ui-modes`).
/// The outermost ancestor of `file` within `project_root` containing a root marker.
pub fn marked_root(project_root: &Path, file: &Path, markers: &[String]) -> Option<PathBuf> {
ide_language::marked_root(Some(project_root), file, markers)
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)
})
})
}
#[cfg(test)]
@@ -204,9 +297,7 @@ mod tests {
fn search_path_contains_env_path() {
let p = search_path();
let dirs: Vec<PathBuf> = std::env::split_paths(&p).collect();
// 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:?}");
assert!(dirs.contains(&PathBuf::from("/usr/bin")));
}
}
+28 -107
View File
@@ -24,7 +24,7 @@ mod uri;
mod virtual_doc;
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -75,8 +75,6 @@ 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;
@@ -157,7 +155,6 @@ 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>,
@@ -166,7 +163,6 @@ 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,
@@ -287,7 +283,6 @@ impl LspManager {
Arc::new_cyclic(|me| Self {
me: me.clone(),
project_root,
content_roots: RwLock::new(Vec::new()),
languages,
sink,
launcher,
@@ -296,7 +291,6 @@ 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),
@@ -308,24 +302,9 @@ 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_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
let root = launch::find_root(&self.project_root, path, &cfg.root_markers).unwrap_or_else(|| {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
@@ -353,9 +332,7 @@ impl LspManager {
if sc.fallback && available_before {
continue;
}
if sc.require_root_marker
&& launch::marked_root(&self.project_root_for(path), path, &sc.root_markers).is_none()
{
if sc.require_root_marker && launch::marked_root(&self.project_root, path, &sc.root_markers).is_none() {
continue;
}
let server = self.server_for(sc, language, path);
@@ -431,16 +408,7 @@ 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 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);
}
let cfg = me.configured(&tail);
tail.set_effective_config(cfg.clone());
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
@@ -1412,34 +1380,21 @@ 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,
cancel: CancellationToken,
) -> IdeResult<Renamed> {
pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
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, &cancel))).await;
let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name))).await;
let mut out = Outcome::new();
let mut incomplete = None;
for edit in edits {
match 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),
}
}
Ok(edit) => match &mut out.value {
Some(merged) => convert::merge_workspace_edit(merged, edit),
None => out.value = Some(edit),
},
Err(e) => out.fail(e),
}
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
out.finish(WorkspaceEdit::default())
}
/// The servers that can rename at `pos`, the first one when none can (a
@@ -1456,27 +1411,16 @@ impl LspManager {
vec![&targets[0]]
}
async fn rename_on(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
new_name: &str,
cancel: &CancellationToken,
) -> IdeResult<Renamed> {
async fn rename_on(&self, snap: &DocSnap, t: &Target, pos: Pos, new_name: &str) -> IdeResult<WorkspaceEdit> {
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, Some(cancel), LONG_TIMEOUT).await?;
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, 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 {
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 });
return query().await;
}
let files = |e: &WorkspaceEdit| {
e.files
@@ -1487,9 +1431,9 @@ impl LspManager {
})
.collect()
};
let w = self.widened(snap, t, pos, cancel, query, files).await?;
let w = self.widened(snap, t, pos, &CancellationToken::new(), query, files).await?;
// The second edit covers the first: its files stay analysed.
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
Ok(w.second.unwrap_or(w.first))
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -1784,14 +1728,11 @@ impl LspManager {
doc: DocId,
pos: Pos,
direction: HierarchyDirection,
cancel: CancellationToken,
) -> IdeResult<CallHierarchy> {
) -> IdeResult<Vec<HierarchyItem>> {
let (snap, targets) = self.doc_targets(doc, "call hierarchy", |c| c.has("callHierarchyProvider")).await?;
let t = &targets[0];
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: Option<Vec<lsp::CallHierarchyItem>> =
self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, 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();
@@ -1800,7 +1741,7 @@ impl LspManager {
match direction {
HierarchyDirection::Incoming => {
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
self.call(t, "callHierarchy/incomingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
self.call(t, "callHierarchy/incomingCalls", params, None, 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());
@@ -1810,7 +1751,7 @@ impl LspManager {
}
HierarchyDirection::Outgoing => {
let calls: Option<Vec<lsp::CallHierarchyOutgoingCall>> =
self.call(t, "callHierarchy/outgoingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
let (src, _) = uri_target(item.uri.as_str());
for c in calls.unwrap_or_default() {
@@ -1822,22 +1763,19 @@ impl LspManager {
}
Ok(out)
};
let (out, incomplete) = match direction {
let out = 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, cancel, query, files).await?;
(w.second.unwrap_or(w.first), w.incomplete)
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
w.second.unwrap_or(w.first)
}
HierarchyDirection::Outgoing => (query().await?, None),
HierarchyDirection::Outgoing => query().await?,
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref()));
Ok(CallHierarchy { items: out, incomplete })
Ok(out)
}
pub async fn type_hierarchy(
@@ -1963,10 +1901,6 @@ 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));
@@ -1987,22 +1921,9 @@ 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 {
// 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 next = self.configured(&s);
let current = s.effective_config();
if next == current {
continue;
@@ -2295,7 +2216,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, CancellationToken::new()));
assert_send(m.call_hierarchy(doc, Pos::default(), HierarchyDirection::Incoming));
assert_send(m.restart(ide_core::ServerId(1)));
assert_send(m.shutdown_all());
assert_send(m.semantic_tokens(doc));
+10 -30
View File
@@ -138,9 +138,6 @@ 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 });
}
@@ -163,37 +160,22 @@ impl LspManager {
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
/// they are closed when the returned guard drops. Files too large or not
/// readable as text are left out and flagged on the guard.
/// they are closed when the returned guard drops.
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
// 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 read = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
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)
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<_>>()
})
.await
.unwrap_or_default()
};
let mut guard = TransientFiles::empty(self, t);
guard.skipped = skipped;
let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() };
if cancel.is_cancelled() {
return guard;
}
@@ -224,8 +206,6 @@ 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<'_> {
@@ -246,7 +226,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(), skipped: false }
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }
}
}
-16
View File
@@ -331,8 +331,6 @@ 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)]
@@ -341,17 +339,3 @@ 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>,
}
+7 -189
View File
@@ -245,7 +245,6 @@ fn harness_configured(
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: servers
.iter()
@@ -359,29 +358,6 @@ 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())]);
@@ -928,38 +904,6 @@ 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())]);
@@ -1168,7 +1112,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(), Default::default()).await.unwrap().edit;
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into()).await.unwrap();
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
@@ -1205,19 +1149,13 @@ 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, Default::default()).await.unwrap_err();
let err = h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming).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, Default::default())
.await
.unwrap()
.items;
let calls = h.mgr.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "main");
assert_eq!(calls[0].from_ranges, vec![rng(1, 0, 1, 3)]);
@@ -1262,7 +1200,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(), Default::default()).await.unwrap().edit;
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into()).await.unwrap();
assert_eq!(edits_in(&edit, &path).len(), 2);
assert!(h.launcher.state("ts").requested("textDocument/rename").is_empty());
@@ -1274,7 +1212,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(), Default::default()).await.unwrap().edit;
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into()).await.unwrap();
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())]);
}
@@ -1810,7 +1748,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, params| {
Arc::new(move |s, method, _| {
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));
@@ -1824,19 +1762,6 @@ fn open_files_only(blind: Vec<String>) -> Responder {
}
Some(json!({ "changes": changes }))
}
"textDocument/prepareCallHierarchy" => {
let uri = &params["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,
}
})
@@ -1958,113 +1883,6 @@ 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();
@@ -2245,7 +2063,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(), Default::default()).await.unwrap().edit;
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into()).await.unwrap();
let mut paths: Vec<PathBuf> = edit
.files
.iter()
+3 -4
View File
@@ -54,7 +54,6 @@ 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,
@@ -297,7 +296,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@6 @vtsls/language-server`.
/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @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() {
@@ -444,13 +443,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(), Default::default()).await.unwrap().edit;
let edit = mgr.rename(inline, count, "total".into()).await.unwrap();
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(), Default::default()).await.unwrap().edit;
let edit = mgr.rename(ts, title, "heading".into()).await.unwrap();
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))] {
-30
View File
@@ -1,30 +0,0 @@
[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"] }
-988
View File
@@ -1,988 +0,0 @@
//! 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(())
}
-408
View File
@@ -1,408 +0,0 @@
//! 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 })
}
}
-284
View File
@@ -1,284 +0,0 @@
//! Native, app-owned Jupyter kernels. One supervised process per notebook path.
//!
//! Output routing ends at the matching shell reply AND IOPub idle. Readers remain
//! alive and drain later background output, but never attribute it to another run.
mod process;
mod transport;
use ide_core::{CancellationToken, IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex as AsyncMutex;
pub(super) type StatusCallback = Arc<dyn Fn(&Path, KernelState, Option<String>) + Send + Sync>;
pub(super) type OutputCallback = Arc<dyn Fn(KernelOutput) + Send + Sync>;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct KernelOutput {
pub kind: String,
#[specta(type = specta_typescript::Unknown)]
pub content: Value,
/// Actual Jupyter parent request ID, not a UI request ID.
pub msg_id: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum KernelState {
Starting,
Idle,
Busy,
Dead,
}
impl KernelState {
pub fn as_str(self) -> &'static str {
match self {
Self::Starting => "starting",
Self::Idle => "idle",
Self::Busy => "busy",
Self::Dead => "dead",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExecutionResult {
pub status: String,
pub execution_count: Option<u32>,
}
struct Slot {
operation: AsyncMutex<Option<transport::Running>>,
interrupt: Mutex<Option<CancellationToken>>,
stop: Mutex<Option<CancellationToken>>,
closed: AtomicBool,
}
impl Slot {
fn new() -> Self {
Self {
operation: AsyncMutex::new(None),
interrupt: Mutex::new(None),
stop: Mutex::new(None),
closed: AtomicBool::new(false),
}
}
fn cancel(&self) {
if let Some(token) = self.interrupt.lock().as_ref() {
token.cancel();
}
}
fn stop(&self) {
self.cancel();
if let Some(token) = self.stop.lock().as_ref() {
token.cancel();
}
}
}
struct ActiveExecution<'a>(&'a Slot);
impl Drop for ActiveExecution<'_> {
fn drop(&mut self) {
if let Some(token) = self.0.interrupt.lock().take() {
token.cancel();
}
}
}
pub struct KernelManager {
runtime_dir: PathBuf,
on_status: StatusCallback,
slots: Mutex<HashMap<PathBuf, Arc<Slot>>>,
closed: AtomicBool,
}
impl KernelManager {
pub fn new(runtime_dir: PathBuf, on_status: StatusCallback) -> Self {
Self { runtime_dir, on_status, slots: Mutex::new(HashMap::new()), closed: AtomicBool::new(false) }
}
pub async fn execute(
&self,
notebook: &Path,
python: &Path,
code: &str,
cancel: CancellationToken,
on_output: OutputCallback,
) -> IdeResult<ExecutionResult> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let slot = {
let mut slots = self.slots.lock();
if self.closed.load(Ordering::Acquire) {
return Err(IdeError::unavailable("notebook kernel manager is shut down"));
}
slots.entry(notebook.to_path_buf()).or_insert_with(|| Arc::new(Slot::new())).clone()
};
// Register cancellation before taking the operation lock: interrupt can
// otherwise race the tiny lock-to-token gap and miss a newly admitted run.
let interrupt = CancellationToken::new();
{
let mut active = slot.interrupt.lock();
if active.is_some() {
return Err(IdeError::unavailable("notebook kernel is busy"));
}
*active = Some(interrupt.clone());
}
let _active = ActiveExecution(&slot);
// A second caller must not sit in a queue and execute stale source later.
let mut running = slot.operation.try_lock().map_err(|_| IdeError::unavailable("notebook kernel is busy"))?;
if slot.closed.load(Ordering::Acquire) {
return Err(IdeError::unavailable("notebook kernel was closed"));
}
if running.as_ref().is_some_and(|r| r.python != python || r.is_finished())
&& let Some(old) = running.take()
{
old.shutdown().await;
}
if cancel.is_cancelled() || interrupt.is_cancelled() || slot.closed.load(Ordering::Acquire) {
return Err(IdeError::cancelled());
}
if running.is_none() {
let (new, ready) = transport::Running::spawn(
self.runtime_dir.clone(),
notebook.to_path_buf(),
python.to_path_buf(),
self.on_status.clone(),
);
*slot.stop.lock() = Some(new.stop.clone());
*running = Some(new);
if slot.closed.load(Ordering::Acquire) {
slot.stop();
}
let result = tokio::select! {
biased;
_ = cancel.cancelled() => Err(IdeError::cancelled()),
_ = interrupt.cancelled() => Err(IdeError::cancelled()),
result = ready => result.unwrap_or_else(|_| Err(IdeError::unavailable("kernel startup task ended"))),
};
if let Err(error) = result {
if let Some(failed) = running.take() {
failed.shutdown().await;
}
slot.stop.lock().take();
return Err(error);
}
}
let result = running.as_ref().expect("initialized kernel").execute(code, cancel, interrupt, on_output).await;
if running.as_ref().is_some_and(transport::Running::is_finished) {
if let Some(dead) = running.take() {
dead.shutdown().await;
}
slot.stop.lock().take();
}
result
}
/// Cancels the active request, including startup. The execution future drains
/// reply+idle before returning, or stops the owned process on interrupt timeout.
pub async fn interrupt(&self, notebook: &Path) -> IdeResult<()> {
let slot = self.slots.lock().get(notebook).cloned();
if let Some(slot) = slot {
slot.cancel();
// Wait for native interrupt delivery/drain, not just token mutation.
let _drained = slot.operation.lock().await;
}
Ok(())
}
pub async fn restart(&self, notebook: &Path) -> IdeResult<()> {
let slot = self.slots.lock().get(notebook).cloned();
let Some(slot) = slot else { return Ok(()) };
slot.stop();
let mut running = slot.operation.lock().await;
if slot.closed.load(Ordering::Acquire) {
return Err(IdeError::unavailable("notebook kernel was closed"));
}
let Some(old) = running.take() else { return Ok(()) };
let python = old.python.clone();
old.shutdown().await;
let (new, ready) =
transport::Running::spawn(self.runtime_dir.clone(), notebook.to_path_buf(), python, self.on_status.clone());
*slot.stop.lock() = Some(new.stop.clone());
*running = Some(new);
if slot.closed.load(Ordering::Acquire) {
slot.stop();
}
let result = ready.await.unwrap_or_else(|_| Err(IdeError::unavailable("kernel restart task ended")));
if result.is_err() {
if let Some(failed) = running.take() {
failed.shutdown().await;
}
slot.stop.lock().take();
}
result
}
pub async fn shutdown_kernel(&self, notebook: &Path) -> IdeResult<()> {
let slot = {
let slots = self.slots.lock();
// Keep a closed tombstone until old readers/process have drained.
// Reopening this path cannot overlap old-generation status events.
let slot = slots.get(notebook).cloned();
if let Some(slot) = &slot {
slot.closed.store(true, Ordering::Release);
slot.stop();
}
slot
};
if let Some(slot) = slot {
if let Some(running) = slot.operation.lock().await.take() {
running.shutdown().await;
}
slot.stop.lock().take();
let mut slots = self.slots.lock();
if slots.get(notebook).is_some_and(|current| Arc::ptr_eq(current, &slot)) {
slots.remove(notebook);
}
}
Ok(())
}
pub async fn shutdown(&self) {
let slots = {
let mut slots = self.slots.lock();
self.closed.store(true, Ordering::Release);
slots
.drain()
.map(|(_, slot)| {
slot.closed.store(true, Ordering::Release);
slot.stop();
slot
})
.collect::<Vec<_>>()
};
// All stop tokens are signalled before awaiting any process.
for slot in slots {
if let Some(running) = slot.operation.lock().await.take() {
running.shutdown().await;
}
}
}
}
impl Drop for KernelManager {
fn drop(&mut self) {
for slot in self.slots.get_mut().values() {
slot.closed.store(true, Ordering::Release);
slot.stop();
}
}
}
-447
View File
@@ -1,447 +0,0 @@
use ide_core::{IdeError, IdeResult};
use jupyter_protocol::ConnectionInfo;
use parking_lot::Mutex;
use std::io::Write;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::task::JoinHandle;
use uuid::Uuid;
const STDERR_BYTES: usize = 16 * 1024;
pub(super) struct Process {
child: Child,
#[cfg(unix)]
pid: Option<u32>,
stderr: Arc<Mutex<String>>,
drains: Vec<JoinHandle<()>>,
// Kept until the process and sockets have stopped; contains the HMAC secret.
_directory: tempfile::TempDir,
#[cfg(windows)]
windows: windows::Resources,
}
impl Process {
pub async fn spawn(runtime: &Path, notebook: &Path, python: &Path) -> IdeResult<(Self, ConnectionInfo)> {
if !python.is_absolute() {
return Err(IdeError::invalid("select an absolute Python executable path for this notebook"));
}
std::fs::create_dir_all(runtime)?;
let directory = tempfile::Builder::new().prefix("kernel-").tempdir_in(runtime)?;
make_private(directory.path())?;
let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let (ports, listeners) = jupyter_zmq_client::peek_ports_with_listeners(ip, 5).await.map_err(protocol_error)?;
let info = ConnectionInfo {
transport: jupyter_protocol::connection_info::Transport::TCP,
ip: ip.to_string(),
stdin_port: ports[0],
control_port: ports[1],
hb_port: ports[2],
shell_port: ports[3],
iopub_port: ports[4],
signature_scheme: "hmac-sha256".into(),
key: format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()),
kernel_name: Some("openidea-python".into()),
};
let connection = directory.path().join("connection.json");
write_private(&connection, &serde_json::to_vec(&info).map_err(protocol_error)?)?;
#[cfg(windows)]
let windows = windows::Resources::new()?;
let mut argv = vec![python.as_os_str().to_os_string()];
#[cfg(not(windows))]
argv.extend(["-m".into(), "ipykernel_launcher".into(), "-f".into(), "{connection_file}".into()]);
#[cfg(windows)]
argv.extend(["-c".into(), windows::BOOTSTRAP.into(), "-f".into(), "{connection_file}".into()]);
// App-owned spec, not registered in user/global Jupyter directories. The
// argv array is also the launch source of truth, without shell quoting.
let spec = serde_json::json!({
"argv": argv.iter().map(|v| v.to_string_lossy().into_owned()).collect::<Vec<_>>(),
"display_name": "OpenIDEA Python", "language": "python", "interrupt_mode": "signal"
});
write_private(&directory.path().join("kernel.json"), &serde_json::to_vec(&spec).map_err(protocol_error)?)?;
let mut command = Command::new(python);
for arg in &argv[1..] {
if arg == "{connection_file}" {
command.arg(&connection);
} else {
command.arg(arg);
}
}
command
.current_dir(notebook.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new(".")))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env_remove("JPY_INTERRUPT_EVENT")
.env_remove("JPY_PARENT_PID")
.env("PYTHONUNBUFFERED", "1");
ide_core::process::prepare(command.as_std_mut());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
command.env("JPY_PARENT_PID", std::process::id().to_string());
}
#[cfg(windows)]
windows.configure(&mut command);
let child = command.spawn().map_err(|e| {
IdeError::unavailable(format!(
"cannot start selected Python {}: {e}. Select an executable environment with ipykernel installed",
python.display()
))
})?;
drop(listeners);
#[cfg(unix)]
let pid = child.id();
let mut process = Self {
child,
#[cfg(unix)]
pid,
stderr: Arc::new(Mutex::new(String::new())),
drains: Vec::new(),
_directory: directory,
#[cfg(windows)]
windows,
};
if let Some(stderr) = process.child.stderr.take() {
let tail = process.stderr.clone();
process.drains.push(tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let text = ide_core::output_encoding::decode_tool_output(&line);
let mut tail = tail.lock();
tail.push_str(&text);
let mut start = tail.len().saturating_sub(STDERR_BYTES);
while !tail.is_char_boundary(start) {
start += 1;
}
tail.drain(..start);
}
}
}
}));
}
if let Some(mut stdout) = process.child.stdout.take() {
process.drains.push(tokio::spawn(async move {
let mut buffer = [0_u8; 4096];
while let Ok(count) = stdout.read(&mut buffer).await {
if count == 0 {
break;
}
}
}));
}
#[cfg(windows)]
process.windows.assign_and_release(&process.child)?;
Ok((process, info))
}
pub fn stderr(&self) -> String {
self.stderr.lock().clone()
}
pub fn death(&mut self) -> IdeResult<Option<String>> {
match self.child.try_wait()? {
None => Ok(None),
Some(status) => {
self.kill_tree();
Ok(Some(format!("kernel exited ({status}); {}", self.stderr())))
}
}
}
pub fn interrupt(&self) -> IdeResult<()> {
#[cfg(unix)]
{
let pid = self.pid.ok_or_else(|| IdeError::unavailable("kernel is no longer running"))?;
// SAFETY: this child was created in its own process group. Never
// signal the host group or a discovered/external kernel.
if unsafe { libc::kill(-(pid as i32), libc::SIGINT) } != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
#[cfg(windows)]
{
self.windows.interrupt()
}
#[cfg(not(any(unix, windows)))]
{
Err(IdeError::unavailable("native kernel interruption is unsupported on this platform"))
}
}
fn kill_tree(&mut self) {
#[cfg(unix)]
if let Some(pid) = self.pid.take() {
// SAFETY: group ID belongs exclusively to the owned kernel.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(windows)]
self.windows.kill_tree();
}
pub async fn shutdown(&mut self) {
self.kill_tree();
let _ = self.child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(3), self.child.wait()).await;
for task in self.drains.drain(..) {
task.abort();
}
}
}
impl Drop for Process {
fn drop(&mut self) {
self.kill_tree();
let _ = self.child.start_kill();
for task in &self.drains {
task.abort();
}
// tokio Child's kill_on_drop also kills/reaps if this future is aborted.
}
}
fn protocol_error(error: impl std::fmt::Display) -> IdeError {
IdeError::unavailable(format!("Jupyter kernel setup: {error}"))
}
fn write_private(path: &Path, bytes: &[u8]) -> IdeResult<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(bytes)?;
Ok(())
}
fn make_private(path: &Path) -> IdeResult<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
#[cfg(windows)]
windows::private_directory(path)?;
Ok(())
}
#[cfg(windows)]
mod windows {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, LocalFree};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW,
};
use windows_sys::Win32::Security::{
DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl, PROTECTED_DACL_SECURITY_INFORMATION, SECURITY_ATTRIBUTES,
};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{CreateEventW, SetEvent};
// Launcher glue only. All Jupyter traffic is authenticated native ZeroMQ.
// Child opens its own handles: no assumption about Rust HANDLE inheritance.
// The gate prevents any ipykernel imports/descendants before job assignment.
pub const BOOTSTRAP: &str = r#"import ctypes, os, runpy, sys
k = ctypes.WinDLL('kernel32', use_last_error=True)
k.OpenEventW.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_wchar_p]
k.OpenEventW.restype = ctypes.c_void_p
k.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
k.WaitForSingleObject.restype = ctypes.c_uint32
k.CloseHandle.argtypes = [ctypes.c_void_p]
k.CloseHandle.restype = ctypes.c_int
interrupt = k.OpenEventW(0x00100002, 0, os.environ.pop('OPENIDEA_INTERRUPT_EVENT'))
gate = k.OpenEventW(0x00100000, 0, os.environ.pop('OPENIDEA_LAUNCH_EVENT'))
if not interrupt or not gate:
raise ctypes.WinError(ctypes.get_last_error())
if k.WaitForSingleObject(gate, 30000) != 0:
raise RuntimeError('OpenIDEA kernel launch gate failed')
k.CloseHandle(gate)
os.environ['JPY_INTERRUPT_EVENT'] = str(interrupt)
try:
runpy.run_module('ipykernel_launcher', run_name='__main__')
finally:
k.CloseHandle(interrupt)
"#;
struct Handle(usize);
impl Handle {
fn new(raw: HANDLE) -> IdeResult<Self> {
if raw.is_null() { Err(std::io::Error::last_os_error().into()) } else { Ok(Self(raw as usize)) }
}
fn raw(&self) -> HANDLE {
self.0 as HANDLE
}
fn signal(&self) -> IdeResult<()> {
// SAFETY: owned live event handle, retained for this process lifetime.
if unsafe { SetEvent(self.raw()) } == 0 { Err(std::io::Error::last_os_error().into()) } else { Ok(()) }
}
}
impl Drop for Handle {
fn drop(&mut self) {
unsafe {
CloseHandle(self.raw());
}
}
}
struct Descriptor(*mut std::ffi::c_void);
impl Descriptor {
fn new() -> IdeResult<Self> {
// Protected ACL: owner and SYSTEM only, inherited by runtime files.
let sddl = "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;SY)\0".encode_utf16().collect::<Vec<_>>();
let mut raw = std::ptr::null_mut();
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut raw,
std::ptr::null_mut(),
)
} == 0
{
return Err(std::io::Error::last_os_error().into());
}
Ok(Self(raw))
}
}
impl Drop for Descriptor {
fn drop(&mut self) {
unsafe {
LocalFree(self.0);
}
}
}
pub fn private_directory(path: &Path) -> IdeResult<()> {
let descriptor = Descriptor::new()?;
let mut present = 0;
let mut defaulted = 0;
let mut dacl = std::ptr::null_mut();
if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) } == 0
|| present == 0
{
return Err(std::io::Error::last_os_error().into());
}
let path = path.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<_>>();
let error = unsafe {
SetNamedSecurityInfoW(
path.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
dacl,
std::ptr::null_mut(),
)
};
if error != 0 {
return Err(std::io::Error::from_raw_os_error(error as i32).into());
}
Ok(())
}
pub struct Resources {
interrupt: Handle,
gate: Handle,
job: Handle,
interrupt_name: String,
gate_name: String,
}
impl Resources {
pub fn new() -> IdeResult<Self> {
let interrupt_name = format!("Local\\OpenIDEA-interrupt-{}", Uuid::new_v4());
let gate_name = format!("Local\\OpenIDEA-launch-{}", Uuid::new_v4());
fn event(name: &str) -> IdeResult<Handle> {
let name = name.encode_utf16().chain(Some(0)).collect::<Vec<_>>();
// Event-specific rights; owner and SYSTEM only. File inheritance
// flags/FA are not used for kernel synchronization objects.
let sddl = "D:P(A;;GA;;;OW)(A;;GA;;;SY)\0".encode_utf16().collect::<Vec<_>>();
let mut raw = std::ptr::null_mut();
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut raw,
std::ptr::null_mut(),
)
} == 0
{
return Err(std::io::Error::last_os_error().into());
}
let descriptor = Descriptor(raw);
let attrs = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let handle = Handle::new(unsafe { CreateEventW(&attrs, 0, 0, name.as_ptr()) })?;
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
return Err(IdeError::unavailable("kernel event name collision"));
}
Ok(handle)
}
let interrupt = event(&interrupt_name)?;
let gate = event(&gate_name)?;
let job = Handle::new(unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) })?;
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if unsafe {
SetInformationJobObject(
job.raw(),
JobObjectExtendedLimitInformation,
&info as *const _ as *const _,
std::mem::size_of_val(&info) as u32,
)
} == 0
{
return Err(std::io::Error::last_os_error().into());
}
Ok(Self { interrupt, gate, job, interrupt_name, gate_name })
}
pub fn configure(&self, command: &mut Command) {
command.env("OPENIDEA_INTERRUPT_EVENT", &self.interrupt_name).env("OPENIDEA_LAUNCH_EVENT", &self.gate_name);
}
pub fn assign_and_release(&self, child: &Child) -> IdeResult<()> {
let handle = child
.raw_handle()
.ok_or_else(|| IdeError::unavailable("kernel exited before Windows job assignment"))?;
if unsafe { AssignProcessToJobObject(self.job.raw(), handle as HANDLE) } == 0 {
return Err(IdeError::unavailable(format!(
"cannot contain kernel process in owned Windows job: {}",
std::io::Error::last_os_error()
)));
}
self.gate.signal()
}
pub fn interrupt(&self) -> IdeResult<()> {
self.interrupt.signal()
}
pub fn kill_tree(&self) {
unsafe {
TerminateJobObject(self.job.raw(), 1);
}
}
}
}
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff. Show more