diff --git a/Cargo.lock b/Cargo.lock index 4ce321de..203d8215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3972,9 +3972,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ide-agent" +version = "0.1.17" +dependencies = [ + "ide-core", + "ide-jsonrpc", + "ide-vcs", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", + "uuid", +] + [[package]] name = "ide-analysis" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ast-grep-core", "ide-core", @@ -3988,7 +4006,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dirs 6.0.0", "dunce", @@ -4003,11 +4021,11 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.15" +version = "0.1.17" [[package]] name = "ide-core" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dunce", "serde", @@ -4019,7 +4037,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-run", @@ -4033,7 +4051,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4050,7 +4068,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dirs 6.0.0", "futures", @@ -4069,7 +4087,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "libc", @@ -4083,7 +4101,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-vcs", @@ -4097,10 +4115,11 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dirs 6.0.0", "dunce", + "ide-agent", "ide-analysis", "ide-build", "ide-core", @@ -4137,7 +4156,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "parking_lot", @@ -4155,7 +4174,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.15" +version = "0.1.17" dependencies = [ "blake3", "criterion", @@ -4177,7 +4196,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.15" +version = "0.1.17" dependencies = [ "futures", "ide-core", @@ -4192,7 +4211,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.15" +version = "0.1.17" dependencies = [ "blake3", "criterion", @@ -4213,6 +4232,7 @@ dependencies = [ "tree-sitter-css", "tree-sitter-dart", "tree-sitter-elixir", + "tree-sitter-gdscript", "tree-sitter-go", "tree-sitter-graphql", "tree-sitter-haskell", @@ -4244,7 +4264,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.15" +version = "0.1.17" dependencies = [ "futures", "globset", @@ -4267,7 +4287,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-language", @@ -4285,7 +4305,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-run", @@ -4298,7 +4318,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.15" +version = "0.1.17" dependencies = [ "globset", "ide-core", @@ -4315,7 +4335,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.15" +version = "0.1.17" dependencies = [ "encoding_rs", "ide-core", @@ -4335,7 +4355,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dunce", "ide-core", @@ -4354,7 +4374,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.15" +version = "0.1.17" dependencies = [ "criterion", "globset", @@ -4374,7 +4394,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "parking_lot", @@ -4387,7 +4407,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dirs 6.0.0", "flate2", @@ -4406,7 +4426,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "ide-language", @@ -4421,7 +4441,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "libc", @@ -4434,7 +4454,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.15" +version = "0.1.17" dependencies = [ "criterion", "ide-core", @@ -4446,7 +4466,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.15" +version = "0.1.17" dependencies = [ "flate2", "hex", @@ -4466,7 +4486,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.15" +version = "0.1.17" dependencies = [ "dunce", "gix", @@ -4483,7 +4503,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.15" +version = "0.1.17" dependencies = [ "chardetng", "dunce", @@ -4504,7 +4524,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.15" +version = "0.1.17" dependencies = [ "ide-core", "open", @@ -4520,7 +4540,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.15" +version = "0.1.17" dependencies = [ "blake3", "dunce", @@ -5945,11 +5965,12 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.15" +version = "0.1.17" dependencies = [ "blake3", "dirs 6.0.0", "dunce", + "ide-agent", "ide-analysis", "ide-build", "ide-clipboard", @@ -9398,6 +9419,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-gdscript" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a37fe8c0a10c0c39ecd5b2f7db53933f691488f5572409a4d3c0dfeb3f6108" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-go" version = "0.25.0" diff --git a/Cargo.toml b/Cargo.toml index 5d932145..86ba837f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.15" +version = "0.1.17" edition = "2024" rust-version = "1.96" license = "Apache-2.0" @@ -34,6 +34,7 @@ ide-http = { path = "crates/ide-http" } ide-db = { path = "crates/ide-db" } ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } +ide-agent = { path = "crates/ide-agent" } ide-clipboard = { path = "crates/ide-clipboard" } ide-ssh = { path = "crates/ide-ssh" } ide-remote = { path = "crates/ide-remote" } diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 7d02d1e7..d31065b2 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -157,3 +157,86 @@ If the Work includes a "NOTICE" text file as part of its distribution, then any 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +## Solarized + +The Solarized palettes in `src/styles/tokens.css` are adapted from +[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3). +See [theme sources and mapping](docs/themes.md). MIT license: + +```text +Copyright (c) 2011 Ethan Schoonover + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +## Atom One Light and One Dark + +The One palettes and semantic syntax mapping in `src/styles/tokens.css` are +adapted from GitHub’s Atom One syntax and UI themes. +Source revisions are listed in [theme sources and mapping](docs/themes.md). +Both syntax themes use the following MIT notice: + +```text +Copyright (c) 2016 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +Both UI themes use the following MIT notice: + +```text +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/crates/ide-agent/Cargo.toml b/crates/ide-agent/Cargo.toml new file mode 100644 index 00000000..248f6007 --- /dev/null +++ b/crates/ide-agent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ide-agent" +description = "Coding agents over the Agent Client Protocol: tasks, sessions, worktrees and their timelines" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-jsonrpc.workspace = true +ide-vcs.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +parking_lot.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs new file mode 100644 index 00000000..59fbfe18 --- /dev/null +++ b/crates/ide-agent/src/config.rs @@ -0,0 +1,224 @@ +//! Session settings (model, mode, thinking effort…), slash commands and context use, as +//! the agent reports them over ACP. + +use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage}; +use serde_json::{Value, json}; + +/// 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 { + let mut out = result.get("configOptions").map(parse_options).unwrap_or_default(); + let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category)); + if !has(&out, "mode") + && let Some(modes) = result.get("modes") + { + out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode)); + } + if !has(&out, "model") + && let Some(models) = result.get("models") + { + out.push(legacy( + models, + "model", + "Model", + "availableModels", + "modelId", + "currentModelId", + AgentConfigVia::Model, + )); + } + out.retain(|o| !o.values.is_empty()); + out +} + +/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns). +pub(crate) fn parse_options(options: &Value) -> Vec { + let Some(list) = options.as_array() else { return Vec::new() }; + list.iter() + .filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select")) + .filter_map(|o| { + let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned(); + let mut values = Vec::new(); + for v in o.get("options").and_then(Value::as_array).into_iter().flatten() { + // Grouped options: `{ group, name, options: [...] }`. + match v.get("options").and_then(Value::as_array) { + Some(group) => values.extend(group.iter().filter_map(value_of)), + None => values.extend(value_of(v)), + } + } + Some(AgentConfigOption { + name: str_of(o, "name").unwrap_or_else(|| id.clone()), + description: str_of(o, "description"), + category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())), + current: str_of(o, "currentValue"), + values, + via: AgentConfigVia::Option, + id, + }) + }) + .collect() +} + +/// A `config_option_update`: its options replace the config-option ones; `modes` / `models` +/// stand-ins stay. +pub(crate) fn merge_update(config: &mut Vec, options: &Value) { + let fresh = parse_options(options); + let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category); + config.retain(|o| o.via != AgentConfigVia::Option && !covered(o)); + let legacy = std::mem::take(config); + config.extend(fresh); + config.extend(legacy); + config.retain(|o| !o.values.is_empty()); +} + +/// `current_mode_update`. +pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) { + if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) { + o.current = Some(mode.to_owned()); + } +} + +/// The request (method, params without the session id) that sets `option` to `value`. +pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) { + match option.via { + AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })), + AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })), + AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })), + } +} + +/// `available_commands_update`. +pub(crate) fn commands_of(update: &Value) -> Vec { + update + .get("availableCommands") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|c| { + Some(AgentCommand { + name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(), + description: str_of(c, "description").unwrap_or_default(), + hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned), + }) + }) + .collect() +} + +/// `usage_update`: `{ used, size, cost?: { amount, currency } }`. +pub(crate) fn usage_of(update: &Value) -> Option { + let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32); + let used = tokens("used")?; + let size = tokens("size").filter(|s| *s > 0)?; + Some(AgentUsage { + used, + size, + cost: update.pointer("/cost/amount").and_then(Value::as_f64), + currency: update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned), + }) +} + +fn legacy( + v: &Value, + id: &str, + name: &str, + list: &str, + key: &str, + current: &str, + via: AgentConfigVia, +) -> AgentConfigOption { + let values = v + .get(list) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let value = m.get(key)?.as_str()?.to_owned(); + Some(AgentConfigValue { + name: str_of(m, "name").unwrap_or_else(|| value.clone()), + description: str_of(m, "description"), + value, + }) + }) + .collect(); + AgentConfigOption { + id: id.to_owned(), + name: name.to_owned(), + description: None, + category: Some(id.to_owned()), + current: str_of(v, current), + values, + via, + } +} + +fn value_of(v: &Value) -> Option { + let value = v.get("value")?.as_str()?.to_owned(); + Some(AgentConfigValue { + name: str_of(v, "name").unwrap_or_else(|| value.clone()), + description: str_of(v, "description"), + value, + }) +} + +fn str_of(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_str).map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> { + c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect() + } + + #[test] + fn config_options_win_over_the_older_fields() { + let r = json!({ + "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]}, + "configOptions": [ + {"id": "mode", "category": "mode", "type": "select", "currentValue": "plan", + "options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]}, + {"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium", + "options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]}, + {"id": "fast", "type": "boolean", "currentValue": false} + ], + "models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]} + }); + let c = options_of(&r); + assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]); + assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::>(), ["low", "Medium"]); + assert_eq!(set_request(&c[0], "default").0, "session/set_config_option"); + assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"}))); + } + + #[test] + fn updates_replace_options_and_track_the_mode() { + let mut c = options_of(&json!({ + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]}, + "configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}] + })); + assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id"); + set_mode(&mut c, "code"); + assert_eq!(c[1].current.as_deref(), Some("code")); + assert_eq!(set_request(&c[1], "ask").0, "session/set_mode"); + merge_update( + &mut c, + &json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]), + ); + assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]); + } + + #[test] + fn commands_and_usage() { + let cmds = commands_of(&json!({"availableCommands": [ + {"name": "review", "description": "Review changes", "input": {"hint": "what to review"}}, + {"name": "/init", "description": "Write CLAUDE.md", "input": null} + ]})); + assert_eq!(cmds[0].hint.as_deref(), Some("what to review")); + assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None)); + let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap(); + assert_eq!((u.used, u.cost, u.currency.as_deref()), (1200, Some(0.42), Some("USD"))); + assert!(usage_of(&json!({"used": 1, "size": 0})).is_none()); + } +} diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs new file mode 100644 index 00000000..c08548da --- /dev/null +++ b/crates/ide-agent/src/lib.rs @@ -0,0 +1,1078 @@ +//! Coding agents (Claude Code, Codex, Gemini CLI…) driven over the Agent Client Protocol. +//! +//! A task is one agent conversation, working in its own git worktree or in the project +//! checkout. Each task runs its agent's ACP server as a child process (newline-delimited +//! JSON-RPC over stdio), folds `session/update` notifications into a timeline, and streams +//! timeline and status changes to the subscribed window. Tasks are saved under the +//! project's store directory and survive restarts; the agent session is reloaded on the +//! next prompt when the agent supports `session/load`. + +mod config; +mod model; +mod provider; +mod timeline; +mod worktree; + +pub use model::*; + +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use timeline::Timeline; + +/// Receives the events of one project's tasks. +pub type AgentSink = Arc; + +/// Entry changes are batched for this long before they are sent. +const FLUSH_DELAY: Duration = Duration::from_millis(40); +/// Updates that arrive this late after `session/load` returns are still replayed history. +const REPLAY_GRACE: Duration = Duration::from_millis(300); +/// Notifications are delivered on their own thread: the last `session/update`s of a turn +/// can trail its `session/prompt` response by this much. +const TURN_GRACE: Duration = Duration::from_millis(100); +const STDERR_LINES: usize = 20; +/// Referenced files up to this size are embedded in the prompt (when the agent takes it). +const MAX_EMBED: u64 = 256 * 1024; + +/// The agent tasks of one project. +pub struct AgentManager { + shared: Arc, + tasks: RwLock>>, +} + +pub struct AgentPaths { + pub project_root: PathBuf, + /// One `.json` per task. + pub store_dir: PathBuf, + /// Parent of the tasks' worktrees. + pub worktrees_dir: PathBuf, + /// Settings and commands each agent offered last time (shared by all projects). + pub models_file: PathBuf, + /// User-defined agents, one `.toml` each (`name`, `command`, `args`, `env`). + pub custom_agents_dir: Option, +} + +/// Starts the ACP server of a provider in a directory, speaking to `client`. +type Connector = Arc< + dyn Fn(&provider::ProviderSpec, &Path, Arc, Box) -> std::io::Result + + Send + + Sync, +>; + +struct Shared { + paths: AgentPaths, + runtime: tokio::runtime::Handle, + connector: Connector, + sink: RwLock>, + known: Mutex>, + providers: RwLock>, +} + +/// What an agent offered in its last session, for the New Task page. +#[derive(Clone, Default, Serialize, Deserialize)] +struct Known { + #[serde(default)] + config: Vec, + #[serde(default)] + commands: Vec, +} + +impl Shared { + fn spec(&self, id: &str) -> Option { + self.providers.read().iter().find(|p| p.id == id).cloned() + } + + fn emit(&self, event: AgentEvent) { + let sink = self.sink.read().clone(); + if let Some(sink) = sink { + sink(event); + } + } + + fn known(&self, provider: &str) -> Known { + self.known.lock().get(provider).cloned().unwrap_or_default() + } + + fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) { + let snapshot = { + let mut cache = self.known.lock(); + let known = cache.entry(provider.to_owned()).or_default(); + let mut changed = false; + if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) { + known.config = c.to_vec(); + changed = true; + } + if let Some(c) = commands.filter(|c| !c.is_empty() && known.commands != *c) { + known.commands = c.to_vec(); + changed = true; + } + if !changed { + return; + } + serde_json::to_vec_pretty(&*cache) + }; + if let Ok(bytes) = snapshot + && let Err(e) = write_file(&self.paths.models_file, &bytes) + { + tracing::warn!("saving agent settings: {e}"); + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskFile { + task: AgentTask, + session_id: Option, + entries: Vec, + /// Settings the user chose, re-applied to every new or reloaded session. + #[serde(default)] + wanted: Vec, +} + +struct TaskState { + task: AgentTask, + session_id: Option, + timeline: Timeline, + wanted: Vec, + /// A turn is running or about to (prompts arriving now are queued). + busy: bool, +} + +/// A running agent and its session. +#[derive(Clone)] +struct Live { + peer: Peer, + session: String, + /// `promptCapabilities.image`. + images: bool, + /// `promptCapabilities.embeddedContext`: referenced files are sent with their text. + embedded: bool, +} + +struct Task { + id: String, + shared: Arc, + state: Mutex, + live: Mutex>, + /// Serializes starting the agent. + connecting: tokio::sync::Mutex<()>, + /// Unanswered permission requests: where to send the chosen option id. + permissions: Mutex>>>, + next_permission: AtomicU32, + /// Set while `session/load` replays history we already have. + replaying: AtomicBool, + /// The current turn is being cancelled to send the first queued message right away. + send_now: AtomicBool, + dirty: Mutex>, + flush_scheduled: AtomicBool, + stderr: Mutex>, +} + +impl AgentManager { + /// Loads the project's saved tasks. Spawned work runs on `runtime`. + pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self { + let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| { + Peer::spawn_framed(&spec.command, &spec.args, cwd, &spec.env, client, on_stderr, Framing::Lines) + }); + Self::with_connector(paths, runtime, connector) + } + + fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self { + let known = + std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref())); + let shared = Arc::new(Shared { + paths, + runtime, + connector, + sink: RwLock::new(None), + known: Mutex::new(known), + providers, + }); + let mut tasks = HashMap::new(); + for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() { + let path = file.path(); + if path.extension().is_none_or(|e| e != "json") { + continue; + } + match std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + .and_then(|v| serde_json::from_value::(upgrade(v)).map_err(|e| e.to_string())) + { + Ok(mut f) => { + // The agent process did not survive the restart: whatever was in flight is over. + if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) { + f.task.status = AgentStatus::Ready; + let mut timeline = Timeline::from_entries(std::mem::take(&mut f.entries)); + timeline.end_turn(); + f.entries = timeline.entries; + } + let task = Task::restore(f, shared.clone()); + tasks.insert(task.id.clone(), task); + } + Err(e) => tracing::warn!("skipping agent task {}: {e}", path.display()), + } + } + Self { shared, tasks: RwLock::new(tasks) } + } + + /// Routes events to `sink` (replacing the previous subscriber); returns every task. + pub fn subscribe(&self, sink: AgentSink) -> Vec { + *self.shared.sink.write() = Some(sink); + let mut tasks: Vec = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect(); + tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms)); + tasks + } + + /// The agents (custom ones re-read), whether each is installed, and what they offered last time. + pub fn providers(&self) -> Vec { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + let list = specs + .iter() + .map(|p| { + let known = self.shared.known(&p.id); + AgentProvider { + id: p.id.clone(), + name: p.name.clone(), + command: std::iter::once(p.command.as_str()) + .chain(p.args.iter().map(String::as_str)) + .collect::>() + .join(" "), + installed: provider::installed(&p.command), + config: known.config, + commands: known.commands, + } + }) + .collect(); + *self.shared.providers.write() = specs; + list + } + + fn task(&self, id: &str) -> IdeResult> { + self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}"))) + } + + pub fn entries(&self, id: &str) -> IdeResult> { + Ok(self.task(id)?.state.lock().timeline.entries.clone()) + } + + /// Creates the task (and its worktree) and sends `req.prompt` as its first message. + pub async fn create(&self, req: NewAgentTask) -> IdeResult { + let spec = self + .shared + .spec(&req.provider) + .ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?; + let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?; + let title = title_of(&prompt); + let root = self.shared.paths.project_root.clone(); + let (cwd, branch, base, base_commit) = match req.isolation { + Isolation::Worktree => { + 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?; + (c.path, Some(c.branch), Some(c.base), Some(c.base_commit)) + } + Isolation::Local => { + let r = root.clone(); + let head = blocking(move || worktree::head_of(&r)).await.ok(); + let (base, commit) = head.unzip(); + (root, None, base, commit) + } + }; + // Until the session reports its own, show what the agent offered last time with the choices applied. + let known = self.shared.known(&spec.id); + let mut config = known.config; + for choice in &req.config { + if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) { + o.current = Some(choice.value.clone()); + } + } + let now = now_ms(); + let task = AgentTask { + id: uuid::Uuid::new_v4().simple().to_string(), + title, + provider: spec.id.clone(), + model: model_of(&config), + status: AgentStatus::Starting, + isolation: req.isolation, + cwd: IpcPath(cwd), + branch, + base, + base_commit, + error: None, + created_ms: now, + updated_ms: now, + config, + commands: known.commands, + usage: None, + queue: Vec::new(), + }; + let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config }; + let handle = Task::restore(file, self.shared.clone()); + handle.state.lock().busy = true; + self.tasks.write().insert(task.id.clone(), handle.clone()); + handle.emit_task(); + handle.persist(); + self.shared.runtime.spawn(async move { handle.run(prompt).await }); + Ok(task) + } + + /// Sends a message. While the agent works it is queued, or with `now` the current turn + /// is cancelled and the message goes next. + pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> { + let task = self.task(id)?; + let prompt = clean(prompt).ok_or_else(|| IdeError::invalid("empty message"))?; + let start = { + let mut s = task.state.lock(); + if s.task.status == AgentStatus::Archived { + return Err(IdeError::invalid("the task is archived")); + } + if s.busy { + let item = AgentQueued { id: uuid::Uuid::new_v4().simple().to_string(), prompt }; + if now { + s.task.queue.insert(0, item); + } else { + s.task.queue.push(item); + } + None + } else { + s.busy = true; + Some(prompt) + } + }; + match start { + Some(prompt) => { + self.shared.runtime.spawn(async move { task.run(prompt).await }); + } + None => { + task.persist(); + task.emit_task(); + if now { + task.send_now.store(true, Ordering::Release); + task.cancel(); + } + } + } + Ok(()) + } + + /// Drops a queued message. + pub fn unqueue(&self, id: &str, queued: &str) -> IdeResult { + let task = self.task(id)?; + let item = { + let mut s = task.state.lock(); + let i = + s.task.queue.iter().position(|q| q.id == queued).ok_or_else(|| IdeError::not_found("already sent"))?; + s.task.queue.remove(i) + }; + task.persist(); + task.emit_task(); + Ok(item) + } + + /// Sends queued message `queued` next, cancelling the current turn if there is one. + pub fn send_queued_now(&self, id: &str, queued: &str) -> IdeResult<()> { + let task = self.task(id)?; + let idle = { + let mut s = task.state.lock(); + let i = + s.task.queue.iter().position(|q| q.id == queued).ok_or_else(|| IdeError::not_found("already sent"))?; + let item = s.task.queue.remove(i); + if s.busy { + s.task.queue.insert(0, item); + None + } else { + s.busy = true; + Some(item) + } + }; + task.persist(); + task.emit_task(); + match idle { + Some(item) => { + self.shared.runtime.spawn(async move { task.run(item.prompt).await }); + } + None => { + task.send_now.store(true, Ordering::Release); + task.cancel(); + } + } + Ok(()) + } + + /// Changes a session setting (model, mode…): right away when the agent runs, else when it next starts. + pub async fn set_config(&self, id: &str, option: &str, value: String) -> IdeResult<()> { + let task = self.task(id)?; + let target = { + let s = task.state.lock(); + let o = + s.task.config.iter().find(|o| o.id == option).ok_or_else(|| IdeError::not_found("no such setting"))?; + if !o.values.iter().any(|v| v.value == value) { + return Err(IdeError::invalid(format!("{} has no value {value}", o.name))); + } + o.clone() + }; + let live = task.live.lock().clone().filter(|l| !l.peer.is_closed()); + if let Some(live) = live { + let (method, mut params) = config::set_request(&target, &value); + params["sessionId"] = json!(live.session); + let reply: Value = + live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?; + if let Some(options) = reply.get("configOptions") { + config::merge_update(&mut task.state.lock().task.config, options); + } + } + { + let mut s = task.state.lock(); + s.wanted.retain(|c| c.id != option); + s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() }); + if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) { + o.current = Some(value); + } + s.task.model = model_of(&s.task.config).or(s.task.model.take()); + } + task.persist(); + task.emit_task(); + Ok(()) + } + + /// Stops the current turn (`session/cancel`); pending permission requests are cancelled. + /// Queued messages stay queued. + pub fn cancel(&self, id: &str) -> IdeResult<()> { + self.task(id)?.cancel(); + Ok(()) + } + + /// Answers permission request `permission` with option `option` (`None` cancels it). + pub fn respond(&self, id: &str, permission: u32, option: Option) -> IdeResult<()> { + let task = self.task(id)?; + let pending = task.permissions.lock().remove(&permission); + match pending { + Some(reply) => { + let _ = reply.send(option); + Ok(()) + } + None => Err(IdeError::not_found("the request was already answered")), + } + } + + /// Stops the agent and moves the task to Archived; its worktree stays. + pub fn archive(&self, id: &str) -> IdeResult<()> { + let task = self.task(id)?; + task.kill(); + task.set_status(AgentStatus::Archived, None); + Ok(()) + } + + /// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays). + pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> { + let task = self.task(id)?; + task.kill(); + let (isolation, cwd) = { + let s = task.state.lock(); + (s.task.isolation, s.task.cwd.0.clone()) + }; + if remove_worktree && 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); + let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json"))); + self.shared.emit(AgentEvent::Removed { id: id.to_owned() }); + Ok(()) + } + + /// Files the task changed since it started. + pub async fn changes(&self, id: &str) -> IdeResult> { + let (cwd, base) = self.task(id)?.cwd_and_base(); + blocking(move || worktree::changes(&cwd, &base)).await + } + + /// Content of `path` (in the task's directory) when the task started. + pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult> { + let (cwd, base) = self.task(id)?.cwd_and_base(); + blocking(move || worktree::file_at(&cwd, &path, &base)).await + } + + /// Stops every agent (project closed). + pub fn shutdown(&self) { + for task in self.tasks.read().values() { + task.kill(); + } + *self.shared.sink.write() = None; + } +} + +impl Task { + fn restore(file: TaskFile, shared: Arc) -> Arc { + let task = file.task; + let timeline = Timeline::from_entries(file.entries); + Arc::new(Self { + id: task.id.clone(), + shared, + state: Mutex::new(TaskState { + task, + session_id: file.session_id, + timeline, + wanted: file.wanted, + busy: false, + }), + live: Mutex::new(None), + connecting: tokio::sync::Mutex::new(()), + permissions: Mutex::new(HashMap::new()), + next_permission: AtomicU32::new(1), + replaying: AtomicBool::new(false), + send_now: AtomicBool::new(false), + dirty: Mutex::new(BTreeSet::new()), + flush_scheduled: AtomicBool::new(false), + stderr: Mutex::new(VecDeque::new()), + }) + } + + fn cwd_and_base(&self) -> (PathBuf, String) { + let s = self.state.lock(); + (s.task.cwd.0.clone(), s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into())) + } + + fn emit_task(&self) { + let task = self.state.lock().task.clone(); + self.shared.emit(AgentEvent::Task { task }); + } + + fn set_status(&self, status: AgentStatus, error: Option) { + { + let mut s = self.state.lock(); + s.task.status = status; + s.task.error = error; + s.task.updated_ms = now_ms(); + } + // Saved first: whoever reacts to the status change finds it on disk. + self.persist(); + self.emit_task(); + } + + fn persist(&self) { + let bytes = { + let s = self.state.lock(); + serde_json::to_vec(&TaskFile { + task: s.task.clone(), + session_id: s.session_id.clone(), + entries: s.timeline.entries.clone(), + wanted: s.wanted.clone(), + }) + }; + let path = self.shared.paths.store_dir.join(format!("{}.json", self.id)); + if let Err(e) = bytes.map_err(|e| e.to_string()).and_then(|b| write_file(&path, &b)) { + tracing::warn!("saving agent task {}: {e}", self.id); + } + } + + fn mark_dirty(self: &Arc, indices: impl IntoIterator) { + self.dirty.lock().extend(indices); + if !self.flush_scheduled.swap(true, Ordering::AcqRel) { + let task = self.clone(); + self.shared.runtime.spawn(async move { + tokio::time::sleep(FLUSH_DELAY).await; + task.flush_scheduled.store(false, Ordering::Release); + task.flush(); + }); + } + } + + fn flush(&self) { + let indices = std::mem::take(&mut *self.dirty.lock()); + let events: Vec = { + let s = self.state.lock(); + indices + .into_iter() + .filter_map(|i| { + let entry = s.timeline.entries.get(i)?.clone(); + Some(AgentEvent::Entry { task: self.id.clone(), index: i as u32, entry }) + }) + .collect() + }; + for e in events { + self.shared.emit(e); + } + } + + fn push_entry(self: &Arc, entry: AgentEntry) -> usize { + let i = self.state.lock().timeline.push(entry); + self.mark_dirty([i]); + i + } + + /// Runs `prompt`, then the queued messages, until the queue is empty, a turn fails, or + /// the user stops one. The caller set `busy`. + async fn run(self: Arc, mut prompt: AgentPrompt) { + loop { + let result = self.turn(&prompt).await; + tokio::time::sleep(TURN_GRACE).await; + let changed = self.state.lock().timeline.end_turn(); + self.mark_dirty(changed); + let send_now = self.send_now.swap(false, Ordering::AcqRel); + let next = { + let mut s = self.state.lock(); + let go_on = s.task.status != AgentStatus::Archived + && match &result { + Ok(stop) => stop != "cancelled" || send_now, + Err(_) => false, + }; + let next = (go_on && !s.task.queue.is_empty()).then(|| s.task.queue.remove(0)); + if next.is_none() { + s.busy = false; + } + next + }; + if self.state.lock().task.status == AgentStatus::Archived { + self.persist(); + return; + } + if let Ok(stop) = &result + && stop == "refusal" + { + self.push_entry(AgentEntry::Error { message: "The agent declined to continue.".into() }); + } + match (next, result) { + (Some(queued), _) => prompt = queued.prompt, + (None, Ok(_)) => return self.set_status(AgentStatus::Ready, None), + (None, Err(message)) => { + self.push_entry(AgentEntry::Error { message: message.clone() }); + return self.set_status(AgentStatus::Failed, Some(message)); + } + } + } + } + + /// Sends one prompt, starting the agent first if needed; returns the stop reason. + async fn turn(self: &Arc, prompt: &AgentPrompt) -> Result { + let attachments = prompt + .files + .iter() + .map(|f| file_name(&f.0)) + .chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into()))) + .collect(); + self.push_entry(AgentEntry::User { text: prompt.text.clone(), attachments }); + self.set_status(AgentStatus::Running, None); + let live = self.ensure_session().await?; + if !prompt.images.is_empty() && !live.images { + return Err("This agent can't read images.".into()); + } + let contents = if live.embedded && !prompt.files.is_empty() { + let files: Vec = prompt.files.iter().map(|f| f.0.clone()).collect(); + tokio::task::spawn_blocking(move || files.iter().map(|f| embeddable(f)).collect()).await.unwrap_or_default() + } else { + Vec::new() + }; + let result: Result = live + .peer + .request("session/prompt", json!({ "sessionId": live.session, "prompt": blocks(prompt, &contents) }), None) + .await; + match result { + Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()), + Err(e) => Err(self.describe(e)), + } + } + + async fn ensure_session(self: &Arc) -> Result { + let _guard = self.connecting.lock().await; + if let Some(live) = self.live.lock().clone() + && !live.peer.is_closed() + { + return Ok(live); + } + let (provider_id, cwd, saved) = { + let s = self.state.lock(); + (s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone()) + }; + let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; + self.stderr.lock().clear(); + let weak = Arc::downgrade(self); + let on_stderr: Box = Box::new(move |line| { + if let Some(t) = weak.upgrade() { + let mut buf = t.stderr.lock(); + if buf.len() == STDERR_LINES { + buf.pop_front(); + } + buf.push_back(line); + } + }); + let client = Arc::new(Client { task: Arc::downgrade(self) }); + let peer = (self.shared.connector)(&spec, &cwd, client, on_stderr) + .map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?; + + let init: Value = peer + .request( + "initialize", + json!({ + "protocolVersion": 1, + "clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false }, "terminal": false }, + "clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") }, + }), + None, + ) + .await + .map_err(|e| self.describe(e))?; + let can_load = init.pointer("/agentCapabilities/loadSession").and_then(Value::as_bool) == Some(true); + let capable = |c: &str| { + init.pointer(&format!("/agentCapabilities/promptCapabilities/{c}")).and_then(Value::as_bool) == Some(true) + }; + let (images, embedded) = (capable("image"), capable("embeddedContext")); + let cwd_s = cwd.to_string_lossy().into_owned(); + + let mut session = None; + if let (Some(id), true) = (saved, can_load) { + self.replaying.store(true, Ordering::Release); + let loaded: Result = + peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await; + tokio::time::sleep(REPLAY_GRACE).await; + self.replaying.store(false, Ordering::Release); + match loaded { + Ok(v) => session = Some((id, v)), + Err(e) => tracing::info!("agent task {}: session/load failed, starting a new session: {e}", self.id), + } + } + let (session_id, result) = match session { + Some(s) => s, + None => { + let v: Value = peer + .request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None) + .await + .map_err(|e| match e { + RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => { + format!("{} needs you to sign in. {}", spec.name, spec.login_hint) + } + e => self.describe(e), + })?; + let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned(); + (id, v) + } + }; + + // The session's settings, then the user's choices on top. + // A session that reports none keeps the last known ones, but what is set is unknown. + let reported = config::options_of(&result); + let known = !reported.is_empty(); + let (mut options, wanted) = { + let s = self.state.lock(); + (if known { reported } else { s.task.config.clone() }, s.wanted.clone()) + }; + self.shared.remember(&provider_id, Some(&options), None); + for choice in &wanted { + let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; + let already = known && o.current.as_deref() == Some(choice.value.as_str()); + if already || !o.values.iter().any(|v| v.value == choice.value) { + continue; + } + let (method, mut params) = config::set_request(&o, &choice.value); + params["sessionId"] = json!(session_id); + match peer.request::<_, Value>(method, params, None).await { + Ok(reply) => { + if let Some(fresh) = reply.get("configOptions") { + config::merge_update(&mut options, fresh); + } + if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) { + o.current = Some(choice.value.clone()); + } + } + Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value), + } + } + { + let mut s = self.state.lock(); + s.task.model = model_of(&options).or(s.task.model.take()); + s.task.config = options; + s.session_id = Some(session_id.clone()); + } + self.persist(); + self.emit_task(); + let live = Live { peer, session: session_id, images, embedded }; + *self.live.lock() = Some(live.clone()); + Ok(live) + } + + /// A user-facing message for a failed request, with the agent's last stderr lines when it died. + fn describe(&self, e: RpcError) -> String { + match e { + RpcError::Closed => { + let tail: Vec = self.stderr.lock().iter().cloned().collect(); + if tail.is_empty() { + "The agent process exited.".into() + } else { + format!("The agent process exited:\n{}", tail.join("\n")) + } + } + RpcError::Server { message, .. } => message, + e => e.to_string(), + } + } + + fn on_update(self: &Arc, update: &Value) { + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default(); + // Session state (also while `session/load` replays history). + let provider = self.state.lock().task.provider.clone(); + let task_changed = { + let mut s = self.state.lock(); + match kind { + "config_option_update" => { + if let Some(options) = update.get("configOptions") { + config::merge_update(&mut s.task.config, options); + s.task.model = model_of(&s.task.config).or(s.task.model.take()); + } + true + } + "current_mode_update" => { + if let Some(mode) = update.get("currentModeId").and_then(Value::as_str) { + config::set_mode(&mut s.task.config, mode); + } + true + } + "available_commands_update" => { + s.task.commands = config::commands_of(update); + true + } + "usage_update" => { + s.task.usage = config::usage_of(update).or(s.task.usage.take()); + true + } + _ => false, + } + }; + if task_changed { + let (config, commands) = { + let s = self.state.lock(); + (s.task.config.clone(), s.task.commands.clone()) + }; + self.shared.remember(&provider, Some(&config), Some(&commands)); + if kind != "usage_update" { + self.persist(); + } + self.emit_task(); + return; + } + if self.replaying.load(Ordering::Acquire) { + return; + } + let applied = self.state.lock().timeline.apply(update); + self.mark_dirty(applied.changed); + } + + /// Blocks the calling (blocking-pool) thread until the user answers. + fn ask_permission(self: &Arc, params: &Value) -> Value { + let id = self.next_permission.fetch_add(1, Ordering::Relaxed); + let title = params + .pointer("/toolCall/title") + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .unwrap_or("The agent asks for permission") + .to_owned(); + let options: Vec = params + .get("options") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|o| { + Some(AgentPermissionOption { + id: o.get("optionId")?.as_str()?.to_owned(), + name: o.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(), + kind: o.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned(), + }) + }) + .collect() + }) + .unwrap_or_default(); + // The tool call it is about arrives as a notification on another thread: let it land first. + if let Some(tool) = params.pointer("/toolCall/toolCallId").and_then(Value::as_str) { + for _ in 0..20 { + if self.state.lock().timeline.has_tool(tool) { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + let (tx, rx) = std::sync::mpsc::channel(); + let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None }); + self.permissions.lock().insert(id, tx); + self.set_status(AgentStatus::NeedsInput, None); + + let answer = rx.recv().ok().flatten(); + let name = answer + .as_ref() + .and_then(|a| options.iter().find(|o| &o.id == a)) + .map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone()); + { + let mut s = self.state.lock(); + if let Some(AgentEntry::Permission { chosen, .. }) = s.timeline.entries.get_mut(entry) { + *chosen = Some(name); + } + } + self.mark_dirty([entry]); + let still_asking = !self.permissions.lock().is_empty(); + if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput { + self.set_status(AgentStatus::Running, None); + } + match answer { + Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }), + None => json!({ "outcome": { "outcome": "cancelled" } }), + } + } + + fn cancel_permissions(&self) { + for (_, reply) in self.permissions.lock().drain() { + let _ = reply.send(None); + } + } + + fn cancel(&self) { + self.cancel_permissions(); + if let Some(live) = self.live.lock().clone() { + let _ = live.peer.notify("session/cancel", json!({ "sessionId": live.session })); + } + } + + fn kill(&self) { + self.cancel_permissions(); + if let Some(live) = self.live.lock().take() { + live.peer.kill(); + } + } +} + +/// Answers the agent's requests on behalf of one task. +struct Client { + task: Weak, +} + +impl Handler for Client { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/update" + && let (Some(task), Some(update)) = (self.task.upgrade(), params.get("update")) + { + task.on_update(update); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned()))?; + match method { + "session/request_permission" => Ok(task.ask_permission(¶ms)), + _ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))), + } + } +} + +/// The prompt trimmed, or `None` when there is nothing to send. +fn clean(mut prompt: AgentPrompt) -> Option { + prompt.text = prompt.text.trim().to_owned(); + (!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt) +} + +/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else +/// a `resource_link`), then the images. +fn blocks(prompt: &AgentPrompt, contents: &[Option]) -> Vec { + let mut out = Vec::new(); + if !prompt.text.is_empty() { + out.push(json!({ "type": "text", "text": prompt.text })); + } + for (i, f) in prompt.files.iter().enumerate() { + let uri = file_uri(&f.0); + out.push(match contents.get(i).cloned().flatten() { + Some(text) => json!({ "type": "resource", "resource": { "uri": uri, "text": text } }), + None => json!({ "type": "resource_link", "uri": uri, "name": file_name(&f.0) }), + }); + } + for i in &prompt.images { + out.push(json!({ "type": "image", "mimeType": i.mime_type, "data": i.data })); + } + out +} + +/// The text of a referenced file, when it is a small UTF-8 file. +fn embeddable(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_EMBED { + return None; + } + String::from_utf8(std::fs::read(path).ok()?).ok() +} + +/// A task file from before a field existed, with that field's empty value. +fn upgrade(mut file: Value) -> Value { + if let Some(task) = file.get_mut("task").and_then(Value::as_object_mut) { + for (key, empty) in + [("config", json!([])), ("commands", json!([])), ("usage", Value::Null), ("queue", json!([]))] + { + task.entry(key).or_insert(empty); + } + } + for entry in file.get_mut("entries").and_then(Value::as_array_mut).into_iter().flatten() { + if entry.get("type").and_then(Value::as_str) == Some("user") + && let Some(e) = entry.as_object_mut() + { + e.entry("attachments").or_insert(json!([])); + } + } + file +} + +fn model_of(config: &[AgentConfigOption]) -> Option { + config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) +} + +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()) +} + +/// `file://` URI of an absolute path, percent-encoded. +fn file_uri(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let mut out = String::from(if s.starts_with('/') { "file://" } else { "file:///" }); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn title_of(prompt: &AgentPrompt) -> String { + let line = prompt.text.lines().map(str::trim).find(|l| !l.is_empty()); + let line = match line { + Some(l) => l.to_owned(), + None => prompt.files.first().map(|f| file_name(&f.0)).unwrap_or_else(|| "Image".into()), + }; + match line.char_indices().nth(60) { + Some((cut, _)) => format!("{}…", &line[..cut]), + None => line, + } +} + +fn now_ms() -> f64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64) +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| e.to_string()) +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs new file mode 100644 index 00000000..17ed1b89 --- /dev/null +++ b/crates/ide-agent/src/model.rs @@ -0,0 +1,285 @@ +//! IPC types: tasks, their timeline entries and the events streamed to the UI. + +use ide_core::IpcPath; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentStatus { + /// Creating the worktree / starting the agent. + Starting, + Running, + /// Waiting for a permission answer. + NeedsInput, + /// The turn ended; the result can be reviewed or the conversation continued. + Ready, + Failed, + Archived, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum Isolation { + /// A new git worktree on its own branch. + Worktree, + /// The project's own checkout. + Local, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentTask { + pub id: String, + pub title: String, + /// [`AgentProvider::id`]. + pub provider: String, + /// Model the task runs on (its `model` setting), when known. + pub model: Option, + pub status: AgentStatus, + pub isolation: Isolation, + /// Directory the agent works in (the worktree, or the project root). + pub cwd: IpcPath, + /// Branch of the task's worktree. + pub branch: Option, + /// Branch (or revision) the worktree was created from. + pub base: Option, + /// Commit the task's changes are compared against. + pub base_commit: Option, + /// Why the task failed, when it did. + pub error: Option, + pub created_ms: f64, + pub updated_ms: f64, + /// The session's settings (model, mode, thinking effort…) as last reported, with the + /// user's choices applied. + pub config: Vec, + /// Slash commands the agent offers. + pub commands: Vec, + /// Context window use, when the agent reports it. + pub usage: Option, + /// Messages waiting for the current turn to end, oldest first. + pub queue: Vec, +} + +/// One value of a select-type session setting. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigValue { + pub value: String, + pub name: String, + pub description: Option, +} + +/// How a setting is changed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentConfigVia { + /// `session/set_config_option`. + #[default] + Option, + /// `session/set_mode` (agents that report `modes` instead of a mode config option). + Mode, + /// `session/set_model` (agents that report `models` instead of a model config option). + Model, +} + +/// A session setting: an ACP select config option, or the older `modes` / `models`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigOption { + pub id: String, + pub name: String, + pub description: Option, + /// ACP category: `model`, `mode`, `thought_level`, or agent-specific. + pub category: Option, + pub current: Option, + pub values: Vec, + pub via: AgentConfigVia, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommand { + /// Without the leading `/`. + pub name: String, + pub description: String, + /// What to type after the command, when it takes input. + pub hint: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsage { + /// Tokens in the context window. + pub used: u32, + /// Size of the context window. + pub size: u32, + /// Session cost so far. + pub cost: Option, + pub currency: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentImage { + pub mime_type: String, + /// Base64, without a `data:` prefix. + pub data: String, + pub name: Option, +} + +/// A message to the agent: text, plus files to look at and images. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPrompt { + pub text: String, + pub files: Vec, + pub images: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQueued { + pub id: String, + pub prompt: AgentPrompt, +} + +/// A setting chosen for a new task, by [`AgentConfigOption::id`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigChoice { + pub id: String, + pub value: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentToolStatus { + Pending, + Running, + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentToolDiff { + pub path: IpcPath, + pub added: u32, + pub removed: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPlanItem { + pub content: String, + /// `pending`, `in_progress` or `completed`. + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPermissionOption { + pub id: String, + pub name: String, + /// `allow_once`, `allow_always`, `reject_once` or `reject_always`. + pub kind: String, +} + +/// One item of a task's conversation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum AgentEntry { + User { + text: String, + /// Names of the files and images sent with it. + attachments: Vec, + }, + Agent { + text: String, + }, + Thought { + text: String, + }, + Tool { + id: String, + title: String, + /// ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… + kind: String, + status: AgentToolStatus, + locations: Vec, + /// Text the tool reported (command output…), truncated. + output: Option, + diffs: Vec, + }, + Plan { + items: Vec, + }, + Permission { + id: u32, + title: String, + options: Vec, + /// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". + chosen: Option, + }, + Error { + message: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProvider { + pub id: String, + pub name: String, + /// The command line that starts its ACP server. + pub command: String, + /// Whether the command was found (on `PATH`). + pub installed: bool, + /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. + pub config: Vec, + /// Slash commands it offered last time. + pub commands: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NewAgentTask { + pub prompt: AgentPrompt, + pub provider: String, + /// Settings for the new session (model, mode…). + pub config: Vec, + pub isolation: Isolation, + /// Branch to start the worktree from; the current branch when `None`. + pub base: Option, +} + +/// A file the task changed, relative to [`AgentTask::base_commit`]. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentChange { + pub path: IpcPath, + pub rel_path: String, + pub kind: ide_vcs::ChangeKind, + pub added: u32, + pub removed: u32, +} + +/// Streamed to the window that subscribed (`AgentManager::subscribe`). +#[derive(Clone, Debug, Serialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum AgentEvent { + /// A task was created or its metadata changed. + Task { + task: AgentTask, + }, + Removed { + id: String, + }, + /// Entry `index` of `task` was added or replaced. + Entry { + task: String, + index: u32, + entry: AgentEntry, + }, +} diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs new file mode 100644 index 00000000..df81325a --- /dev/null +++ b/crates/ide-agent/src/provider.rs @@ -0,0 +1,158 @@ +//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined +//! agents from `/agents/.toml`. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ProviderSpec { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub env: Vec<(String, String)>, + /// How to sign in when the agent reports it needs authentication. + pub login_hint: String, +} + +/// `(id, name, command line, sign-in hint)`. npm adapters are pinned so a session's +/// protocol does not change under the user. +const BUILTIN: &[(&str, &str, &[&str], &str)] = &[ + ( + "claude", + "Claude Code", + &["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.97.0"], + "Run `claude` in a terminal and sign in.", + ), + ("codex", "Codex", &["npx", "-y", "@agentclientprotocol/codex-acp@2.3.0"], "Run `codex login` in a terminal."), + ("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."), + ("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."), + ("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."), + ("opencode", "OpenCode", &["opencode", "acp"], "Run `opencode auth login` in a terminal."), + ("qwen", "Qwen Code", &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."), + ("kimi", "Kimi CLI", &["kimi", "acp"], "Run `kimi` in a terminal and sign in."), + ("goose", "Goose", &["goose", "acp"], "Run `goose configure` in a terminal."), + ("auggie", "Auggie", &["auggie", "--acp"], "Run `auggie login` in a terminal."), + ("kiro", "Kiro", &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."), + ("vibe", "Mistral Vibe", &["vibe-acp"], "Run `vibe` in a terminal and sign in."), +]; + +fn builtins() -> Vec { + BUILTIN + .iter() + .map(|(id, name, cmd, hint)| ProviderSpec { + id: (*id).into(), + name: (*name).into(), + command: cmd[0].into(), + args: cmd[1..].iter().map(|a| (*a).to_owned()).collect(), + env: Vec::new(), + login_hint: (*hint).into(), + }) + .collect() +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CustomAgent { + name: Option, + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a +/// built-in id replaces it. +pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { + let mut specs = builtins(); + let mut files: Vec = custom_dir + .and_then(|d| std::fs::read_dir(d).ok()) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "toml")) + .collect(); + files.sort(); + for path in files { + let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + let parsed = std::fs::read_to_string(&path) + .map_err(|e| e.to_string()) + .and_then(|t| toml::from_str::(&t).map_err(|e| e.to_string())); + let agent = match parsed { + Ok(a) => a, + Err(e) => { + tracing::warn!("skipping agent {}: {e}", path.display()); + continue; + } + }; + let mut env: Vec<(String, String)> = agent.env.into_iter().collect(); + env.sort(); + let spec = ProviderSpec { + name: agent.name.unwrap_or_else(|| id.clone()), + command: agent.command, + args: agent.args, + env, + login_hint: "Sign in with the agent's own command line first.".into(), + id, + }; + match specs.iter_mut().find(|s| s.id == spec.id) { + Some(existing) => *existing = spec, + None => specs.push(spec), + } + } + specs +} + +/// Whether `command` resolves to an executable (a path, or a name on `PATH`). +pub(crate) fn installed(command: &str) -> bool { + let p = Path::new(command); + if p.components().count() > 1 { + return is_executable(p); + } + let exts: &[&str] = if cfg!(windows) { &["exe", "cmd", "bat"] } else { &[] }; + std::env::var_os("PATH").is_some_and(|path| { + std::env::split_paths(&path).any(|dir| { + let c = dir.join(command); + is_executable(&c) || exts.iter().any(|e| is_executable(&c.with_extension(e))) + }) + }) +} + +fn is_executable(p: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + p.is_file() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_agents_extend_and_override_the_builtins() { + let dir = tempfile::tempdir().unwrap(); + let write = |name: &str, text: &str| std::fs::write(dir.path().join(name), text).unwrap(); + write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n"); + write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n"); + write("broken.toml", "nope = 1\n"); + let specs = load(Some(dir.path())); + let mine = specs.iter().find(|s| s.id == "mine").unwrap(); + assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine")); + assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]); + let gemini = specs.iter().find(|s| s.id == "gemini").unwrap(); + assert_eq!((gemini.name.as_str(), gemini.args.as_slice()), ("gemini", &["--acp".to_owned()][..])); + assert!(!specs.iter().any(|s| s.id == "broken")); + assert_eq!(specs.iter().filter(|s| s.id == "claude").count(), 1); + assert!(!installed("/definitely/not/here")); + assert!(installed("sh")); + } +} diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs new file mode 100644 index 00000000..a41b17fc --- /dev/null +++ b/crates/ide-agent/src/tests.rs @@ -0,0 +1,423 @@ +//! End-to-end task flow against an in-memory ACP agent. + +use super::*; +use std::sync::OnceLock; +use tokio::io::duplex; + +/// Says "Hi", edits a file, asks to run a command and reports what it was allowed. +#[derive(Default)] +struct FakeAgent { + peer: OnceLock, + calls: Mutex>, +} + +impl FakeAgent { + fn update(&self, session: &str, update: Value) { + let peer = self.peer.get().unwrap(); + peer.notify("session/update", json!({ "sessionId": session, "update": update })).unwrap(); + } + + fn say(&self, session: &str, text: &str) { + self.update( + session, + json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}), + ); + } +} + +impl Handler for FakeAgent { + fn on_notification(&self, method: &str, _: Value) { + self.calls.lock().push(method.to_owned()); + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.calls.lock().push(method.to_owned()); + let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); + match method { + "initialize" => Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}})), + "session/new" => Ok(json!({"sessionId": "s1", + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]}, + "configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big", + "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})), + "session/load" => { + self.say(&session, "replayed history"); + Ok(json!({})) + } + "session/set_config_option" | "session/set_mode" => { + self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap())); + Ok(json!({})) + } + "session/prompt" => { + let text = params.pointer("/prompt/0/text").and_then(Value::as_str).unwrap_or_default().to_owned(); + if text == "again" { + self.say(&session, "second turn"); + return Ok(json!({"stopReason": "end_turn"})); + } + if text == "wait" { + // Works until cancelled. + while !self.calls.lock().iter().any(|c| c == "session/cancel") { + std::thread::sleep(Duration::from_millis(5)); + } + return Ok(json!({"stopReason": "cancelled"})); + } + self.update( + &session, + json!({"sessionUpdate": "available_commands_update", + "availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}), + ); + self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000})); + self.say(&session, "Hi"); + self.update( + &session, + json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Edit a.rs", + "kind": "edit", "status": "completed", + "content": [{"type": "diff", "path": "/p/a.rs", "oldText": "a\n", "newText": "b\n"}]}), + ); + let peer = self.peer.get().unwrap().clone(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"}, + "options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}, + {"optionId": "no", "name": "Reject", "kind": "reject_once"}]}), + None, + )) + .unwrap(); + let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + self.say(&session, &format!("chose {chosen}")); + Ok(json!({"stopReason": "end_turn"})) + } + _ => Err((codes::METHOD_NOT_FOUND, method.to_owned())), + } + } +} + +fn manager(dir: &Path, agent: Arc) -> AgentManager { + let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + // Keep the latest connection: a restarted manager connects again. + let _ = agent.peer.set(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }); + AgentManager::with_connector( + AgentPaths { + project_root: dir.to_path_buf(), + store_dir: dir.join("store"), + worktrees_dir: dir.join("worktrees"), + models_file: dir.join("models.json"), + custom_agents_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ) +} + +async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { + for _ in 0..500 { + if mgr.task(id).unwrap().state.lock().task.status == status { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("task never reached {status:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_task_runs_asks_for_permission_and_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let events = Arc::new(Mutex::new(Vec::::new())); + let sink = events.clone(); + mgr.subscribe(Arc::new(move |e| sink.lock().push(e))); + + let task = mgr + .create(NewAgentTask { + prompt: text("Fix the lexer\nmore detail"), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + assert_eq!(task.title, "Fix the lexer"); + assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "a new task is not settled"); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + + let permission = mgr + .entries(&task.id) + .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; + + let entries = mgr.entries(&task.id).unwrap(); + let kinds: Vec = entries + .iter() + .map(|e| match e { + AgentEntry::User { text, .. } | AgentEntry::Agent { text } => text.clone(), + AgentEntry::Tool { title, .. } => title.clone(), + AgentEntry::Permission { chosen, .. } => format!("permission: {}", chosen.clone().unwrap_or_default()), + other => format!("{other:?}"), + }) + .collect(); + assert_eq!(kinds, ["Fix the lexer\nmore detail", "Hi", "Edit a.rs", "permission: Allow", "chose yes"]); + assert!( + agent.calls.lock().contains(&"session/set_config_option \"small\"".to_owned()), + "switched to the chosen model" + ); + { + let t = mgr.task(&task.id).unwrap().state.lock().task.clone(); + assert_eq!(t.model.as_deref(), Some("small")); + let modes = t.config.iter().find(|o| o.id == "mode").unwrap(); + assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask"))); + assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::>(), ["review"]); + assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000))); + } + mgr.set_config(&task.id, "mode", "code".into()).await.unwrap(); + assert!(agent.calls.lock().contains(&"session/set_mode \"code\"".to_owned())); + assert!(mgr.set_config(&task.id, "mode", "nope".into()).await.is_err()); + + tokio::time::sleep(FLUSH_DELAY * 3).await; + { + let events = events.lock(); + assert!( + events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput)) + ); + let last_entry = events.iter().rev().find_map(|e| match e { + AgentEvent::Entry { index, entry, .. } => Some((*index, entry.clone())), + _ => None, + }); + assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() }))); + } + let providers = mgr.providers(); + let claude = providers.iter().find(|p| p.id == "claude").unwrap(); + assert_eq!(claude.config.iter().map(|o| o.id.as_str()).collect::>(), ["model", "mode"]); + assert_eq!(claude.commands.len(), 1); + + // A new manager (IDE restart) finds the task and resumes its session without duplicating history. + mgr.shutdown(); + let agent2 = Arc::new(FakeAgent::default()); + let mgr2 = manager(tmp.path(), agent2.clone()); + assert_eq!(mgr2.entries(&task.id).unwrap().len(), 5); + assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2); + mgr2.prompt(&task.id, text("again"), false).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + wait_for(&mgr2, &task.id, AgentStatus::Ready).await; + let entries = mgr2.entries(&task.id).unwrap(); + assert_eq!(entries.len(), 7); + assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() }); + assert!(agent2.calls.lock().contains(&"session/load".to_owned())); + // The chosen settings are re-applied to the reloaded session. + let calls = agent2.calls.lock().clone(); + assert!(calls.contains(&"session/set_mode \"code\"".to_owned()), "{calls:?}"); +} + +fn text(t: &str) -> AgentPrompt { + AgentPrompt { text: t.into(), ..AgentPrompt::default() } +} + +#[tokio::test(flavor = "multi_thread")] +async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + prompt: text("wait"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Running).await; + mgr.prompt(&task.id, text("again"), false).await.unwrap(); + let queue = mgr.task(&task.id).unwrap().state.lock().task.queue.clone(); + assert_eq!(queue.iter().map(|q| q.prompt.text.as_str()).collect::>(), ["again"]); + assert!(mgr.prompt(&task.id, text(" "), false).await.is_err()); + + mgr.send_queued_now(&task.id, &queue[0].id).unwrap(); + for _ in 0..500 { + if mgr.entries(&task.id).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let users: Vec = mgr + .entries(&task.id) + .unwrap() + .into_iter() + .filter_map(|e| match e { + AgentEntry::User { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(users, ["wait", "again"]); + assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty()); + + // Stopping a turn keeps the queue for later. + mgr.prompt(&task.id, text("wait"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Running).await; + agent.calls.lock().retain(|c| c != "session/cancel"); + mgr.prompt(&task.id, text("later"), false).await.unwrap(); + mgr.cancel(&task.id).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone(); + assert_eq!(queued.len(), 1); + assert_eq!(mgr.unqueue(&task.id, &queued[0].id).unwrap().prompt.text, "later"); + assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty()); +} + +#[test] +fn prompts_become_acp_content_blocks() { + let p = AgentPrompt { + text: "look".into(), + files: vec![IpcPath(PathBuf::from("/p/my file.rs"))], + images: vec![AgentImage { mime_type: "image/png".into(), data: "AAA".into(), name: None }], + }; + assert_eq!( + blocks(&p, &[]), + [ + json!({"type": "text", "text": "look"}), + json!({"type": "resource_link", "uri": "file:///p/my%20file.rs", "name": "my file.rs"}), + json!({"type": "image", "mimeType": "image/png", "data": "AAA"}), + ] + ); + assert_eq!( + blocks(&p, &[Some("fn a() {}".into())])[1], + json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}}) + ); + let old = upgrade( + json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}), + ); + assert_eq!(old["task"]["queue"], json!([])); + assert_eq!(old["entries"][0]["attachments"], json!([])); + assert!(old["entries"][1].get("attachments").is_none()); + assert_eq!(title_of(&AgentPrompt { files: p.files.clone(), ..AgentPrompt::default() }), "my file.rs"); + assert!(clean(text(" ")).is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + prompt: text("do it"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + mgr.cancel(&task.id).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let entries = mgr.entries(&task.id).unwrap(); + assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled"))); + assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() })); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default"); + + mgr.delete(&task.id, false).await.unwrap(); + assert!(mgr.entries(&task.id).is_err()); + assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists()); + assert!( + mgr.create(NewAgentTask { + prompt: text(" "), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None + }) + .await + .is_err() + ); +} + +/// Drives the real Claude Code adapter (needs `npx`, network and a signed-in `claude`): +/// `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored --nocapture`. +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn real_claude_task_in_a_worktree() { + if std::env::var_os("OPENIDEA_AGENT_E2E").is_none() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + for args in [&["init", "-q", "-b", "main"][..], &["commit", "-q", "--allow-empty", "-m", "init"]] { + let ok = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) + .args(args) + .current_dir(&repo) + .status() + .unwrap() + .success(); + assert!(ok); + } + let mgr = AgentManager::new( + AgentPaths { + project_root: repo.clone(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("wt"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: None, + }, + tokio::runtime::Handle::current(), + ); + let task = mgr + .create(NewAgentTask { + 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() }], + isolation: Isolation::Worktree, + base: None, + }) + .await + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(240); + loop { + assert!(std::time::Instant::now() < deadline, "timed out"); + let status = mgr.task(&task.id).unwrap().state.lock().task.status; + match status { + AgentStatus::NeedsInput => { + let pending = mgr.entries(&task.id).unwrap().into_iter().rev().find_map(|e| match e { + AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)), + _ => None, + }); + if let Some((id, options)) = pending { + let allow = options.iter().find(|o| o.kind == "allow_once").or(options.first()).unwrap(); + eprintln!("allowing: {}", allow.name); + let _ = mgr.respond(&task.id, id, Some(allow.id.clone())); + } + } + AgentStatus::Ready | AgentStatus::Failed => break, + _ => {} + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + for e in mgr.entries(&task.id).unwrap() { + eprintln!("{e:?}"); + } + let t = mgr.task(&task.id).unwrap().state.lock().task.clone(); + assert_eq!(t.status, AgentStatus::Ready, "{:?}", t.error); + assert_eq!(t.model.as_deref(), Some("haiku")); + let changes = mgr.changes(&task.id).await.unwrap(); + assert!(changes.iter().any(|c| c.rel_path == "hello.txt"), "{changes:?}"); + assert!(!repo.join("hello.txt").exists(), "the project checkout is untouched"); + assert!(!mgr.providers().iter().find(|p| p.id == "claude").unwrap().config.is_empty()); + mgr.shutdown(); +} diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs new file mode 100644 index 00000000..c9a00a67 --- /dev/null +++ b/crates/ide-agent/src/timeline.rs @@ -0,0 +1,304 @@ +//! Folds ACP `session/update` notifications into a task's entries. + +use crate::model::{AgentEntry, AgentPlanItem, AgentToolDiff, AgentToolStatus}; +use ide_core::IpcPath; +use serde_json::Value; +use std::collections::HashMap; + +/// Tool output kept per call; longer output keeps its tail. +const MAX_OUTPUT: usize = 64 * 1024; + +#[derive(Default)] +pub(crate) struct Timeline { + pub entries: Vec, + tools: HashMap, + plan: Option, +} + +/// What a `session/update` changed in the timeline. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Applied { + /// Indices of the entries added or changed. + pub changed: Vec, +} + +impl Timeline { + pub fn from_entries(entries: Vec) -> Self { + let mut t = Self { entries, ..Self::default() }; + for (i, e) in t.entries.iter().enumerate() { + match e { + AgentEntry::Tool { id, .. } => { + t.tools.insert(id.clone(), i); + } + AgentEntry::Plan { .. } => t.plan = Some(i), + _ => {} + } + } + t + } + + pub fn has_tool(&self, id: &str) -> bool { + self.tools.contains_key(id) + } + + pub fn push(&mut self, entry: AgentEntry) -> usize { + self.entries.push(entry); + self.entries.len() - 1 + } + + /// Applies one `session/update` `update` object. + pub fn apply(&mut self, update: &Value) -> Applied { + let mut out = Applied::default(); + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default(); + match kind { + "agent_message_chunk" | "agent_thought_chunk" => { + let Some(text) = content_text(update.get("content")) else { return out }; + out.changed.push(self.append_text(kind == "agent_thought_chunk", &text)); + } + "tool_call" => { + let id = str_field(update, "toolCallId"); + let entry = AgentEntry::Tool { + id: id.clone(), + title: str_field(update, "title"), + kind: update.get("kind").and_then(Value::as_str).unwrap_or("other").to_owned(), + status: tool_status(update.get("status")).unwrap_or(AgentToolStatus::Pending), + locations: locations(update.get("locations")), + output: None, + diffs: Vec::new(), + }; + let index = match self.tools.get(&id) { + Some(&i) => { + self.entries[i] = entry; + i + } + None => { + let i = self.push(entry); + self.tools.insert(id, i); + i + } + }; + self.merge_tool(index, update); + out.changed.push(index); + } + "tool_call_update" => { + let Some(&index) = self.tools.get(&str_field(update, "toolCallId")) else { return out }; + self.merge_tool(index, update); + out.changed.push(index); + } + "plan" => { + let items = update + .get("entries") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|e| AgentPlanItem { content: str_field(e, "content"), status: str_field(e, "status") }) + .collect() + }) + .unwrap_or_default(); + let entry = AgentEntry::Plan { items }; + let index = match self.plan { + Some(i) => { + self.entries[i] = entry; + i + } + None => self.push(entry), + }; + self.plan = Some(index); + out.changed.push(index); + } + _ => {} + } + out + } + + fn append_text(&mut self, thought: bool, text: &str) -> usize { + match self.entries.last_mut() { + Some(AgentEntry::Agent { text: t }) if !thought => t.push_str(text), + Some(AgentEntry::Thought { text: t }) if thought => t.push_str(text), + _ => { + let text = text.to_owned(); + return self.push(if thought { AgentEntry::Thought { text } } else { AgentEntry::Agent { text } }); + } + } + self.entries.len() - 1 + } + + fn merge_tool(&mut self, index: usize, update: &Value) { + let AgentEntry::Tool { title, kind, status, locations: locs, output, diffs, .. } = &mut self.entries[index] + else { + return; + }; + if let Some(t) = update.get("title").and_then(Value::as_str) { + *title = t.to_owned(); + } + if let Some(k) = update.get("kind").and_then(Value::as_str) { + *kind = k.to_owned(); + } + if let Some(s) = tool_status(update.get("status")) { + *status = s; + } + if update.get("locations").is_some_and(Value::is_array) { + *locs = locations(update.get("locations")); + } + let Some(content) = update.get("content").and_then(Value::as_array) else { return }; + let mut text = String::new(); + let mut new_diffs = Vec::new(); + for item in content { + match item.get("type").and_then(Value::as_str) { + Some("content") => { + if let Some(t) = content_text(item.get("content")) { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&t); + } + } + Some("diff") => { + let old = item.get("oldText").and_then(Value::as_str).unwrap_or_default(); + let new = item.get("newText").and_then(Value::as_str).unwrap_or_default(); + let (added, removed) = line_stats(old, new); + new_diffs.push(AgentToolDiff { path: IpcPath(str_field(item, "path").into()), added, removed }); + } + _ => {} + } + } + if !text.is_empty() { + *output = Some(tail(text)); + } + if !new_diffs.is_empty() { + *diffs = new_diffs; + } + } + + /// Settles what an ended turn left open: running tools and pending permissions. + pub fn end_turn(&mut self) -> Vec { + let mut changed = Vec::new(); + for (i, e) in self.entries.iter_mut().enumerate() { + match e { + AgentEntry::Tool { status, .. } + if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) => + { + *status = AgentToolStatus::Failed; + changed.push(i); + } + AgentEntry::Permission { chosen, .. } if chosen.is_none() => { + *chosen = Some("Cancelled".into()); + changed.push(i); + } + _ => {} + } + } + changed + } +} + +fn str_field(v: &Value, key: &str) -> String { + v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned() +} + +fn content_text(content: Option<&Value>) -> Option { + let c = content?; + match c.get("type").and_then(Value::as_str) { + Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned), + Some("resource_link") => c.get("uri").and_then(Value::as_str).map(str::to_owned), + Some("resource") => c.pointer("/resource/text").and_then(Value::as_str).map(str::to_owned), + _ => None, + } +} + +fn tool_status(v: Option<&Value>) -> Option { + Some(match v?.as_str()? { + "pending" => AgentToolStatus::Pending, + "in_progress" => AgentToolStatus::Running, + "completed" => AgentToolStatus::Completed, + "failed" => AgentToolStatus::Failed, + _ => return None, + }) +} + +fn locations(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array) + .map(|a| a.iter().filter_map(|l| l.get("path")?.as_str()).map(|p| IpcPath(p.into())).collect()) + .unwrap_or_default() +} + +fn line_stats(old: &str, new: &str) -> (u32, u32) { + ide_vcs::diff_hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.lines, r + h.old_lines)) +} + +fn tail(mut text: String) -> String { + if text.len() > MAX_OUTPUT { + let mut cut = text.len() - MAX_OUTPUT; + while !text.is_char_boundary(cut) { + cut += 1; + } + text.replace_range(..cut, "…"); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn chunks_merge_into_one_message_until_another_entry_comes() { + let mut t = Timeline::default(); + let chunk = |s: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": s}}); + assert_eq!(t.apply(&chunk("Hel")).changed, vec![0]); + assert_eq!(t.apply(&chunk("lo")).changed, vec![0]); + t.apply(&json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}})); + t.apply(&chunk("again")); + assert_eq!( + t.entries, + vec![ + AgentEntry::Agent { text: "Hello".into() }, + AgentEntry::Thought { text: "hmm".into() }, + AgentEntry::Agent { text: "again".into() }, + ] + ); + } + + #[test] + fn tool_calls_update_in_place() { + let mut t = Timeline::default(); + t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Edit a.rs", "kind": "edit", + "status": "pending", "locations": [{"path": "/p/a.rs", "line": 3}]})); + let applied = t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed", + "content": [ + {"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\n", "newText": "a\nB\nc\n"}, + {"type": "content", "content": {"type": "text", "text": "done"}} + ]})); + assert_eq!(applied.changed, vec![0]); + let AgentEntry::Tool { status, diffs, output, locations, .. } = &t.entries[0] else { panic!() }; + assert_eq!(*status, AgentToolStatus::Completed); + assert_eq!(diffs, &vec![AgentToolDiff { path: IpcPath("/p/a.rs".into()), added: 2, removed: 1 }]); + assert_eq!(output.as_deref(), Some("done")); + assert_eq!(locations, &vec![IpcPath("/p/a.rs".into())]); + // Unknown calls are ignored. + assert!(t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "zz"})).changed.is_empty()); + } + + #[test] + fn plan_is_one_entry_and_end_turn_settles_open_items() { + let mut t = Timeline::default(); + let plan = + |s: &str| json!({"sessionUpdate": "plan", "entries": [{"content": "x", "priority": "high", "status": s}]}); + t.apply(&plan("pending")); + t.push(AgentEntry::Agent { text: "between".into() }); + assert_eq!(t.apply(&plan("completed")).changed, vec![0]); + t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"})); + t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None }); + assert_eq!(t.end_turn(), vec![2, 3]); + let again = Timeline::from_entries(t.entries.clone()); + assert_eq!(again.plan, Some(0)); + assert_eq!(again.tools.get("c"), Some(&2)); + } + + #[test] + fn long_output_keeps_its_tail() { + let s = tail("é".repeat(MAX_OUTPUT)); + assert!(s.starts_with('…') && s.len() <= MAX_OUTPUT + 3); + } +} diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs new file mode 100644 index 00000000..5563cf04 --- /dev/null +++ b/crates/ide-agent/src/worktree.rs @@ -0,0 +1,156 @@ +//! Task worktrees and the changes made in them. + +use crate::model::AgentChange; +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_vcs::{ChangeKind, Repo}; +use std::path::{Path, PathBuf}; + +pub(crate) struct Created { + pub path: PathBuf, + pub branch: String, + pub base: String, + pub base_commit: String, +} + +fn repo_at(path: &Path) -> IdeResult { + Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display()))) +} + +/// The current branch (or commit when detached) and its commit. +pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> { + let info = repo_at(path)?.branch_info()?; + let commit = info.head_commit.ok_or_else(|| IdeError::unavailable("the repository has no commits yet"))?; + Ok((info.head.unwrap_or_else(|| commit.clone()), commit)) +} + +/// `git worktree add -b agent/ / `. +pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult { + let repo = repo_at(project_root)?; + let (base, base_commit) = match base.map(str::trim).filter(|b| !b.is_empty()) { + Some(b) => { + let commit = + repo.merge_base(b, b)?.ok_or_else(|| IdeError::not_found(format!("no branch or revision {b}")))?; + (b.to_owned(), commit) + } + None => head_of(project_root)?, + }; + std::fs::create_dir_all(worktrees_dir).map_err(|e| IdeError::internal(e.to_string()))?; + let branch_exists = |s: &str| { + let r = format!("refs/heads/agent/{s}"); + repo.merge_base(&r, &r).ok().flatten().is_some() + }; + let slug = unique_slug(&slugify(title), |s| worktrees_dir.join(s).exists() || branch_exists(s)); + let path = worktrees_dir.join(&slug); + let branch = format!("agent/{slug}"); + repo.add_worktree(&path, &base_commit, Some(&branch))?; + Ok(Created { path, branch, base, base_commit }) +} + +pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { + repo_at(project_root)?.remove_worktree(path, true) +} + +/// Files under `cwd` that differ from `base` (untracked files included), with line counts. +pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { + 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)); + let mut out: Vec = repo + .diff_with_worktree(base)? + .into_iter() + .map(|f| { + let (added, removed) = stat(&f.path); + AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed } + }) + .collect(); + for s in repo.status()? { + if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) { + let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32); + out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 }); + } + } + out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(out) +} + +pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult> { + repo_at(cwd)?.file_at(path, Some(rev)) +} + +/// A short ASCII branch-safe name from the task title. +pub(crate) fn slugify(title: &str) -> String { + let mut out = String::new(); + for c in title.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + if out.len() >= 32 { + break; + } + } + let out = out.trim_matches('-'); + if out.is_empty() { "task".to_owned() } else { out.to_owned() } +} + +fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_owned(); + } + (2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("unbounded") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugs_are_branch_safe() { + assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns"); + assert_eq!(slugify("修复 lexer 越界"), "lexer"); + assert_eq!(slugify("修复越界"), "task"); + assert_eq!(slugify(&"a".repeat(50)).len(), 32); + assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3"); + } + + 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"]) + .args(args) + .current_dir(dir) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?}"); + } + + #[test] + fn worktree_changes_are_listed_against_the_base() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + assert_eq!(created.branch, "agent/edit-a"); + assert_eq!(created.base, "main"); + std::fs::write(created.path.join("a.txt"), "one\nTWO\nthree\n").unwrap(); + std::fs::write(created.path.join("new.txt"), "x\ny\n").unwrap(); + + let got = changes(&created.path, &created.base_commit).unwrap(); + let summary: Vec<_> = got.iter().map(|c| (c.rel_path.as_str(), c.kind, c.added, c.removed)).collect(); + assert_eq!(summary, [("a.txt", ChangeKind::Modified, 2, 1), ("new.txt", ChangeKind::Added, 2, 0)]); + let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap(); + assert_eq!(base_text.as_deref(), Some("one\ntwo\n")); + + // A second task with the same title gets its own worktree. + assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); + remove(&repo, &created.path).unwrap(); + assert!(!created.path.exists()); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index db686b0f..9e57b8b2 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,12 +8,13 @@ pub mod compile; pub mod gradle; pub mod maven; +pub mod pom_edit; mod process; mod sync; mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; -pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject}; +pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission}; pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven}; use serde::{Deserialize, Serialize}; @@ -69,6 +70,10 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec = m.projects.iter().map(MavenProject::key).collect(); let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect(); while let Some(d) = stack.pop() { + // Omitted occurrences (conflict losers, duplicates) are not on any classpath. + if !d.resolved() { + continue; + } stack.extend(&d.children); let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else { continue; @@ -150,7 +155,19 @@ mod tests { artifact_id: "app".into(), dependencies: vec![ dep("me", "core", Some("1"), vec![]), - dep("org.b", "y", Some("2"), vec![dep("org.c", "z", Some("3"), vec![])]), + dep( + "org.b", + "y", + Some("2"), + vec![ + dep("org.c", "z", Some("3"), vec![]), + MavenDependency { + omitted: Some(Omission::Conflict), + conflict_with: Some("3".into()), + ..dep("org.c", "z", Some("1"), vec![]) + }, + ], + ), dep("org.d", "unresolved", Some("${v}"), vec![]), ], ..Default::default() diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 6a8c29c4..b5e29650 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -130,6 +130,37 @@ pub struct MavenDependency { pub optional: bool, /// Transitive dependencies (only after a sync). pub children: Vec, + /// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). + #[serde(default)] + #[specta(optional)] + pub omitted: Option, + /// The version that won when `omitted` is `Conflict`. + #[serde(default)] + #[specta(optional)] + pub conflict_with: Option, + /// The version requested before `` replaced it. + #[serde(default)] + #[specta(optional)] + pub managed_from: Option, +} + +impl MavenDependency { + /// Part of the resolved graph (not a verbose-tree placeholder for an omitted occurrence). + pub fn resolved(&self) -> bool { + self.omitted.is_none() + } +} + +/// Why a dependency occurrence is not part of the resolved graph. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum Omission { + /// Another occurrence of the same version is nearer the root. + Duplicate, + /// Another version is nearer the root (or declared first) and wins. + Conflict, + /// The artifact depends on itself through this path. + Cycle, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -871,7 +902,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM classifier: d.classifier.clone(), scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()), optional: d.optional, - children: Vec::new(), + ..MavenDependency::default() }) .collect(); @@ -968,7 +999,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec, Vec Option { let s = s.trim(); - let (coords, extra) = match s.find(" (") { - Some(i) => (&s[..i], &s[i..]), - None => (s, ""), + let (s, wrapped) = match s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) { + Some(inner) => (inner, true), + None => (s, false), }; + let (coords, notes) = s.split_once(' ').unwrap_or((s, "")); let parts: Vec<&str> = coords.split(':').collect(); let (g, a, t, c, v, scope) = match parts.as_slice() { [g, a, t, v] => (g, a, t, None, v, None), @@ -990,16 +1024,35 @@ fn parse_coords(s: &str) -> Option { [g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)), _ => return None, }; - Some(MavenDependency { + let mut dep = MavenDependency { group_id: (*g).to_owned(), artifact_id: (*a).to_owned(), version: Some((*v).to_owned()), kind: (*t).to_owned(), classifier: c.map(|c| (*c).to_owned()), scope: scope.unwrap_or("compile").to_owned(), - optional: extra.contains("optional"), - children: Vec::new(), - }) + ..MavenDependency::default() + }; + let notes = notes.replace(['(', ')'], ";").replace(" - ", ";"); + for note in notes.split(';').map(|n| n.trim().trim_start_matches("- ").trim()) { + if note == "optional" { + dep.optional = true; + } else if let Some(winner) = note.strip_prefix("omitted for conflict with ") { + dep.omitted = Some(Omission::Conflict); + dep.conflict_with = Some(winner.trim().to_owned()); + } else if note == "omitted for duplicate" { + dep.omitted = Some(Omission::Duplicate); + } else if note == "omitted for cycle" { + dep.omitted = Some(Omission::Cycle); + } else if let Some(from) = note.strip_prefix("version managed from ") { + dep.managed_from = Some(from.trim().to_owned()); + } + } + // A parenthesised line is always an omitted occurrence, even with an unknown reason. + if wrapped && dep.omitted.is_none() { + dep.omitted = Some(Omission::Duplicate); + } + Some(dep) } fn insert_at(list: &mut Vec, depth: usize, dep: MavenDependency) { @@ -1327,4 +1380,82 @@ mod tests { ); assert_eq!(trees[2].1[0].scope, "compile"); } + + /// `mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree -Dverbose` on a project + /// whose libraries ask for different commons-lang3 / commons-text / guava versions. + #[test] + fn verbose_dependency_tree_output() { + let trees = parse_dependency_tree(include_str!("../testdata/dependency-tree-verbose.txt")); + assert_eq!(trees.len(), 1); + let (key, deps) = &trees[0]; + assert_eq!(key, "demo:conflict-demo"); + let direct: Vec<&str> = deps.iter().map(|d| d.artifact_id.as_str()).collect(); + assert_eq!(direct, ["hamcrest-core", "commons-text", "commons-configuration2", "guava", "guice", "junit"]); + assert!(deps.iter().all(MavenDependency::resolved)); + + let config = &deps[2].children; + let lang3 = &config[0]; + assert_eq!( + (lang3.artifact_id.as_str(), lang3.version.as_deref(), lang3.omitted, lang3.conflict_with.as_deref()), + ("commons-lang3", Some("3.14.0"), Some(Omission::Conflict), Some("3.12.0")) + ); + assert_eq!(lang3.scope, "compile"); + let logging = &config[2]; + assert_eq!( + (logging.version.as_deref(), logging.managed_from.as_deref(), logging.omitted), + (Some("1.2"), Some("1.3.0"), None) + ); + let guava = &deps[4].children[2]; + assert_eq!((guava.version.as_deref(), guava.conflict_with.as_deref()), (Some("30.1-jre"), Some("32.1.3-jre"))); + let hamcrest = &deps[5].children[0]; + assert_eq!((hamcrest.omitted, hamcrest.scope.as_str()), (Some(Omission::Duplicate), "test")); + assert_eq!(deps[1].children[0].omitted, None, "the winning commons-lang3"); + } + + #[test] + fn verbose_annotations() { + // (line, omitted, conflict winner, managed from, optional) + type Case<'a> = (&'a str, Option, Option<&'a str>, Option<&'a str>, bool); + let cases: &[Case] = &[ + ("(g:a:jar:1:compile - omitted for cycle)", Some(Omission::Cycle), None, None, false), + ( + "(g:a:jar:1:compile - version managed from 0.9; omitted for duplicate)", + Some(Omission::Duplicate), + None, + Some("0.9"), + false, + ), + ("g:a:jar:1:compile (optional)", None, None, None, true), + ( + "(g:a:jar:1:runtime (optional) - omitted for conflict with 2)", + Some(Omission::Conflict), + Some("2"), + None, + true, + ), + ( + "(g:a:jar:1:compile - scope updated from runtime; omitted for duplicate)", + Some(Omission::Duplicate), + None, + None, + false, + ), + ( + "g:a:jar:1:compile (version managed from 0.9; scope managed from runtime)", + None, + None, + Some("0.9"), + false, + ), + ]; + for (line, omitted, winner, managed, optional) in cases { + let d = parse_coords(line).unwrap(); + assert_eq!( + (d.omitted, d.conflict_with.as_deref(), d.managed_from.as_deref(), d.optional), + (*omitted, *winner, *managed, *optional), + "{line}" + ); + assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}"); + } + } } diff --git a/crates/ide-build/src/pom_edit.rs b/crates/ide-build/src/pom_edit.rs new file mode 100644 index 00000000..56db1c9b --- /dev/null +++ b/crates/ide-build/src/pom_edit.rs @@ -0,0 +1,336 @@ +//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct +//! dependency is declared and adding an `` to it, in the style +//! (indentation, line endings, one-line elements) of the surrounding POM. + +use crate::maven::{self, MavenModel}; +use crate::xml::{self, Element}; +use ide_core::{Pos, Range, TextEdit}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{HashMap, HashSet}; + +/// `groupId:artifactId` of a dependency. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactKey { + pub group_id: String, + pub artifact_id: String, +} + +impl ArtifactKey { + pub fn new(group_id: impl Into, artifact_id: impl Into) -> Self { + Self { group_id: group_id.into(), artifact_id: artifact_id.into() } + } +} + +impl std::fmt::Display for ArtifactKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.group_id, self.artifact_id) + } +} + +/// What adding an exclusion does to a POM. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Exclusion { + /// The edit that adds it. + Edit(TextEdit), + /// The dependency already excludes it (or everything, `*:*`). + Present, +} + +/// Properties the coordinates of a POM's dependencies may refer to. +fn properties(project: &Element) -> HashMap { + let mut props: HashMap = project + .child("properties") + .map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()) + .unwrap_or_default(); + let own_or_parent = |name: &str| project.text_of(name).or_else(|| project.child("parent")?.text_of(name)); + for (key, value) in [ + ("project.groupId", own_or_parent("groupId")), + ("project.version", own_or_parent("version")), + ("project.artifactId", project.text_of("artifactId")), + ] { + if let Some(v) = value { + props.insert(key.into(), v); + } + } + props +} + +fn matches(e: &Element, key: &ArtifactKey, props: &HashMap) -> bool { + let text = |name: &str| e.text_of(name).map(|t| maven::interpolate(&t, props)); + text("groupId").as_deref() == Some(key.group_id.as_str()) + && text("artifactId").as_deref() == Some(key.artifact_id.as_str()) +} + +/// The `` declaring `key` in the project's ``, else in a +/// profile's (never ``, which declares no dependency). +fn find_dependency<'a>(project: &'a Element, key: &ArtifactKey) -> Option<&'a Element> { + let props = properties(project); + let own = project.list("dependencies", "dependency"); + let profiles = project.list("profiles", "profile").into_iter().flat_map(|p| p.list("dependencies", "dependency")); + own.into_iter().chain(profiles).find(|d| matches(d, key, &props)) +} + +/// 0-based line / UTF-16 column of byte offset `at`. +fn pos_at(text: &str, at: usize) -> Pos { + let before = &text[..at]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32) +} + +fn line_start(text: &str, at: usize) -> usize { + text[..at].rfind('\n').map_or(0, |i| i + 1) +} + +/// The whitespace before `at` on its line, or `None` when other text precedes it there. +fn indent_before(text: &str, at: usize) -> Option<&str> { + let ws = &text[line_start(text, at)..at]; + ws.chars().all(|c| c == ' ' || c == '\t').then_some(ws) +} + +/// One level of indentation: what the first child of `e` adds to `e`'s, else two spaces. +fn indent_unit(text: &str, e: &Element) -> String { + let outer = indent_before(text, e.start); + let inner = e.children.first().and_then(|c| indent_before(text, c.start)); + match (outer, inner) { + (Some(o), Some(i)) if i.len() > o.len() && i.starts_with(o) => i[o.len()..].to_owned(), + (_, Some(i)) if i.starts_with('\t') => "\t".into(), + _ => " ".into(), + } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<") +} + +/// Range of the `` of the dependency declaring `key` in `text` (a POM), or of +/// the whole `` when it has none; `None` when the POM does not declare it. +pub fn declaration(text: &str, key: &ArtifactKey) -> Option { + let project = xml::parse(text)?; + let dep = find_dependency(&project, key)?; + let target = dep.child("artifactId").unwrap_or(dep); + Some(Range::new(pos_at(text, target.start), pos_at(text, target.end))) +} + +/// Adds `exclude` to the `` of the dependency declaring `dependency` in `text` +/// (a POM); `None` when the POM does not declare it. +pub fn add_exclusion(text: &str, dependency: &ArtifactKey, exclude: &ArtifactKey) -> Option { + let project = xml::parse(text)?; + let dep = find_dependency(&project, dependency)?; + let nl = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let unit = indent_unit(text, dep); + let (g, a) = (escape(&exclude.group_id), escape(&exclude.artifact_id)); + + // Insert `block` (lines relative to `parent`'s indentation + one level) before `parent`'s + // end tag; on one line (`compact`) when `parent` is not laid out one element per line. + let insert = |parent: &Element, lines: &[(usize, String)], compact: String| { + let close_on_own_line = indent_before(text, parent.inner_end).is_some() && parent.inner_end < parent.end; + let (at, new_text) = match indent_before(text, parent.start) { + Some(base) if close_on_own_line => { + let mut out = String::new(); + for (depth, line) in lines { + out.push_str(base); + out.push_str(&unit.repeat(depth + 1)); + out.push_str(line); + out.push_str(nl); + } + (line_start(text, parent.inner_end), out) + } + _ => (parent.inner_end, compact), + }; + let p = pos_at(text, at); + Exclusion::Edit(TextEdit { range: Range::point(p), new_text }) + }; + let exclusion_lines = |depth: usize| { + vec![ + (depth, "".to_owned()), + (depth + 1, format!("{g}")), + (depth + 1, format!("{a}")), + (depth, "".to_owned()), + ] + }; + let compact = format!("{g}{a}"); + + if let Some(exclusions) = dep.child("exclusions") { + let props = properties(&project); + let wildcard = ArtifactKey::new("*", "*"); + let any_artifact = ArtifactKey::new(exclude.group_id.clone(), "*"); + if exclusions + .children_named("exclusion") + .any(|e| [exclude, &wildcard, &any_artifact].iter().any(|k| matches(e, k, &props))) + { + return Some(Exclusion::Present); + } + return Some(insert(exclusions, &exclusion_lines(0), compact)); + } + let mut lines = vec![(0, "".to_owned())]; + lines.extend(exclusion_lines(1)); + lines.push((0, "".to_owned())); + Some(insert(dep, &lines, format!("{compact}"))) +} + +/// The POMs that may declare a dependency of the module `pom` (project-relative), nearest +/// first: the module's own, then its parents that are part of the project. +pub fn declaring_poms(model: &MavenModel, pom: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut current = model.projects.iter().find(|p| p.pom == pom); + while let Some(p) = current { + if !seen.insert(p.pom.clone()) { + break; + } + out.push(p.pom.clone()); + current = p.parent.as_deref().and_then(|coords| { + model.projects.iter().find(|q| format!("{}:{}:{}", q.group_id, q.artifact_id, q.version) == coords) + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(text: &str, edit: &TextEdit) -> String { + let offset = |p: Pos| { + let start: usize = text.split_inclusive('\n').take(p.line as usize).map(str::len).sum(); + let line = &text[start..]; + start + line.char_indices().nth(p.col as usize).map_or(line.len(), |(i, _)| i) + }; + let (from, to) = (offset(edit.range.start), offset(edit.range.end)); + format!("{}{}{}", &text[..from], edit.new_text, &text[to..]) + } + + fn exclude(text: &str, dep: (&str, &str), excl: (&str, &str)) -> Option { + match add_exclusion(text, &ArtifactKey::new(dep.0, dep.1), &ArtifactKey::new(excl.0, excl.1))? { + Exclusion::Edit(e) => Some(apply(text, &e)), + Exclusion::Present => Some(text.to_owned()), + } + } + + const POM: &str = " + demo + + com.google.inject + + + + + com.google.inject + guice + 5.1.0 + + + + + + ${guice.group} + guice + + + +"; + + #[test] + fn adds_exclusions_to_the_declaration_not_the_managed_entry() { + let out = exclude(POM, ("com.google.inject", "guice"), ("com.google.guava", "guava")).unwrap(); + let expected = POM.replace( + " guice\n \n ", + " guice + + + com.google.guava + guava + + + + ", + ); + assert_eq!(out, expected); + // A second exclusion joins the first; excluding it again changes nothing. + let twice = exclude(&out, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(); + assert!(twice.contains( + " + + aopalliance + aopalliance + + " + )); + assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice); + } + + #[test] + fn keeps_tabs_crlf_and_one_line_dependencies() { + let tabs = "\r\n\t\r\n\t\t\r\n\t\t\tg\r\n\t\t\ta\r\n\t\t\r\n\t\r\n\r\n"; + let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap(); + assert!(out.contains("a\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tx\r\n")); + assert!(out.contains("\t\t\t\r\n\t\t")); + + let inline = "ga"; + assert_eq!( + exclude(inline, ("g", "a"), ("x", "y")).unwrap(), + "gaxy" + ); + } + + #[test] + fn wildcards_profiles_and_missing_declarations() { + let pom = " + + + + + ga + x* + + + + +"; + let key = ArtifactKey::new("g", "a"); + assert_eq!(add_exclusion(pom, &key, &ArtifactKey::new("x", "y")), Some(Exclusion::Present)); + let out = exclude(pom, ("g", "a"), ("z", "w")).unwrap(); + assert!(out.contains( + "zw" + )); + assert_eq!(add_exclusion(pom, &ArtifactKey::new("g", "other"), &key), None); + assert_eq!(add_exclusion("not xml", &key, &key), None); + } + + #[test] + fn declaration_ranges_use_utf16_columns() { + let pom = "\n ga\n"; + let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap(); + assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60))); + let first = "ga"; + let r = declaration(&format!("{first}"), &ArtifactKey::new("g", "a")) + .unwrap(); + let start = first.find("").unwrap(); + assert_eq!(r.start.col as usize, first[..start].encode_utf16().count()); + } + + #[test] + fn declaring_poms_follow_parents_inside_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let write = |rel: &str, text: &str| { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + }; + write( + "pom.xml", + "dparent1pomapp", + ); + write( + "app/pom.xml", + "dparent1app", + ); + let model = maven::load_with_repo(root, None); + assert_eq!(declaring_poms(&model, "app/pom.xml"), ["app/pom.xml", "pom.xml"]); + assert_eq!(declaring_poms(&model, "pom.xml"), ["pom.xml"]); + assert!(declaring_poms(&model, "missing/pom.xml").is_empty()); + } +} diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 2470c425..d29c1d19 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -1,5 +1,5 @@ //! Reading the effective build from Maven / Gradle itself ("Reload" in the tool -//! windows): `mvn help:effective-pom dependency:tree` and `gradle tasks --all`, +//! windows): `mvn help:effective-pom dependency:tree -Dverbose` and `gradle tasks --all`, //! run in the background with progress and cancellation. Results are cached //! per project (`maven.json`, `gradle.json` in the cache directory) and merged //! into the static model by [`crate::load`]; a changed build file marks them stale. @@ -49,6 +49,9 @@ struct MavenCache { at_ms: f64, error: Option, projects: Vec, + /// The trees list omitted occurrences (`-Dverbose`); older caches are reloaded. + #[serde(default)] + verbose: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -60,6 +63,21 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; + +/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven +/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. +/// Offline, the project's own plugin version is used instead, since this one may not be cached. +const DEPENDENCY_TREE: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"; + +/// The goals that read the effective POM into `effective` and the verbose dependency trees. +fn maven_sync_goals(effective: &Path, offline: bool) -> Vec { + vec![ + "help:effective-pom".into(), + format!("-Doutput={}", effective.to_string_lossy()), + if offline { "dependency:tree".into() } else { DEPENDENCY_TREE.into() }, + "-Dverbose".into(), + ] +} const GRADLE_CACHE: &str = "gradle.json"; fn read_cache(dir: &Path, name: &str) -> Option { @@ -110,7 +128,7 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; - model.stale = cache.hash != hash_files(&maven::build_files(root, model)); + model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model)); } /// Merges the cached task list into `model`. @@ -182,7 +200,8 @@ pub fn sync_maven( ) -> IdeResult<()> { let model = maven::load(root); std::fs::create_dir_all(cache_dir)?; - let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), ..MavenCache::default() }; + let mut cache = + MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -195,9 +214,7 @@ pub fn sync_maven( if !options.profiles.is_empty() { args.push(format!("-P{}", options.profiles.join(","))); } - args.push("help:effective-pom".into()); - args.push(format!("-Doutput={}", effective.to_string_lossy())); - args.push("dependency:tree".into()); + args.extend(maven_sync_goals(&effective, options.offline)); progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None }); let command = options.maven_command.as_deref().unwrap_or(&top.command); let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| { @@ -277,6 +294,7 @@ mod tests { hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, + verbose: true, projects: vec![CachedMavenProject { key: "g:a".into(), plugins: vec![maven::plugin( @@ -327,6 +345,25 @@ mod tests { assert!(!model.stale); } + #[test] + fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() { + let online = maven_sync_goals(Path::new("e.xml"), false); + assert_eq!(online, ["help:effective-pom", "-Doutput=e.xml", DEPENDENCY_TREE, "-Dverbose"]); + let offline = maven_sync_goals(Path::new("e.xml"), true); + assert_eq!(offline[2], "dependency:tree"); + assert!(offline.contains(&"-Dverbose".to_owned())); + } + + #[test] + fn cache_written_before_omissions_existed_still_loads() { + let json = r#"{"hash":"x","at_ms":1.0,"error":null,"projects":[{"key":"g:a","plugins":[], + "dependencies":[{"groupId":"g","artifactId":"d","version":"1","type":"jar","classifier":null, + "scope":"compile","optional":false,"children":[]}]}]}"#; + let cache: MavenCache = serde_json::from_str(json).unwrap(); + assert!(cache.projects[0].dependencies[0].resolved()); + assert!(!cache.verbose, "reloaded to read the omitted occurrences"); + } + #[test] fn progress_and_error_lines() { assert_eq!( diff --git a/crates/ide-build/src/xml.rs b/crates/ide-build/src/xml.rs index 38f96ed1..86dec9db 100644 --- a/crates/ide-build/src/xml.rs +++ b/crates/ide-build/src/xml.rs @@ -1,12 +1,19 @@ //! A small, forgiving XML element reader for POM files: elements, text and //! CDATA; attributes, comments, processing instructions and DOCTYPEs are //! skipped. Namespace prefixes are dropped (`` → `project`). +//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Element { pub name: String, pub text: String, pub children: Vec, + /// Byte offset of the start tag's `<`. + pub start: usize, + /// Byte offset of the end tag's `<` (`end` for an empty or unclosed element). + pub inner_end: usize, + /// Byte offset just past the end tag (past the start tag for ``). + pub end: usize, } impl Element { @@ -76,6 +83,7 @@ fn local(name: &str) -> String { pub fn parse(text: &str) -> Option { let mut stack: Vec = vec![Element::default()]; let mut rest = text; + let at = |rest: &str| text.len() - rest.len(); while !rest.is_empty() { let Some(lt) = rest.find('<') else { decode_entities(rest, &mut stack.last_mut()?.text); @@ -98,13 +106,19 @@ pub fn parse(text: &str) -> Option { } else if let Some(body) = tag.strip_prefix("').unwrap_or(body.len()); let name = local(body[..end].trim()); - if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) { - while stack.len() > at { - let done = stack.pop()?; + let close_start = at(tag); + rest = body.get(end + 1..).unwrap_or(""); + let close_end = at(rest); + if let Some(i) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) { + while stack.len() > i { + let mut done = stack.pop()?; + // Elements closed implicitly by an outer end tag end where it starts. + let matched = stack.len() == i; + done.inner_end = close_start; + done.end = if matched { close_end } else { close_start }; stack.last_mut()?.children.push(done); } } - rest = body.get(end + 1..).unwrap_or(""); } else { // Start or empty tag; skip quoted attribute values when looking for `>`. let bytes = tag.as_bytes(); @@ -124,17 +138,22 @@ pub fn parse(text: &str) -> Option { let empty = inner.ends_with('/'); let inner = inner.trim_end_matches('/'); let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len()); - let el = Element { name: local(&inner[..name_end]), ..Element::default() }; + let start = at(tag); + rest = tag.get(i + 1..).unwrap_or(""); + let after = at(rest); + let el = + Element { name: local(&inner[..name_end]), start, inner_end: after, end: after, ..Element::default() }; if empty { stack.last_mut()?.children.push(el); } else { stack.push(el); } - rest = tag.get(i + 1..).unwrap_or(""); } } while stack.len() > 1 { - let done = stack.pop()?; + let mut done = stack.pop()?; + done.inner_end = text.len(); + done.end = text.len(); stack.last_mut()?.children.push(done); } stack.pop()?.children.into_iter().next() @@ -167,6 +186,22 @@ mod tests { assert_eq!(root.children_named("name").count(), 1); } + #[test] + fn element_offsets() { + let doc = "\n x\n"; + let root = parse(doc).unwrap(); + assert_eq!((root.start, root.inner_end, root.end), (0, doc.len() - 4, doc.len())); + let b = root.child("b").unwrap(); + assert_eq!(&doc[b.start..b.end], "x"); + assert_eq!(&doc[b.start..b.inner_end], "x"); + let c = root.child("c").unwrap(); + assert_eq!(&doc[c.start..c.end], ""); + let broken = parse("x").unwrap(); + let b = broken.child("b").unwrap(); + assert_eq!((b.inner_end, b.end), (7, 7)); + assert_eq!(broken.end, 11); + } + #[test] fn tolerates_broken_documents() { let root = parse("xy").unwrap(); diff --git a/crates/ide-build/testdata/dependency-tree-verbose.txt b/crates/ide-build/testdata/dependency-tree-verbose.txt new file mode 100644 index 00000000..217b46a3 --- /dev/null +++ b/crates/ide-build/testdata/dependency-tree-verbose.txt @@ -0,0 +1,24 @@ +[INFO] --- dependency:3.8.1:tree (default-cli) @ conflict-demo --- +[INFO] demo:conflict-demo:jar:1.0-SNAPSHOT +[INFO] +- org.hamcrest:hamcrest-core:jar:1.3:compile +[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile +[INFO] | \- org.apache.commons:commons-lang3:jar:3.12.0:compile +[INFO] +- org.apache.commons:commons-configuration2:jar:2.10.1:compile +[INFO] | +- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.12.0) +[INFO] | +- (org.apache.commons:commons-text:jar:1.11.0:compile - omitted for conflict with 1.10.0) +[INFO] | \- commons-logging:commons-logging:jar:1.2:compile (version managed from 1.3.0) +[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile +[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile +[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile +[INFO] | +- com.google.code.findbugs:jsr305:jar:3.0.2:compile +[INFO] | +- org.checkerframework:checker-qual:jar:3.37.0:compile +[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.21.1:compile +[INFO] | \- com.google.j2objc:j2objc-annotations:jar:2.8:compile +[INFO] +- com.google.inject:guice:jar:5.1.0:compile +[INFO] | +- javax.inject:javax.inject:jar:1:compile +[INFO] | +- aopalliance:aopalliance:jar:1.0:compile +[INFO] | \- (com.google.guava:guava:jar:30.1-jre:compile - omitted for conflict with 32.1.3-jre) +[INFO] \- junit:junit:jar:4.13.2:test +[INFO] \- (org.hamcrest:hamcrest-core:jar:1.3:test - omitted for duplicate) +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 78182623..3ccb3853 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,6 +1,8 @@ //! Child-process helpers shared by every crate that spawns tools. +use std::ffi::{OsStr, OsString}; use std::process::Command; +use std::sync::OnceLock; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags. Prefer [`no_window`] otherwise. @@ -18,3 +20,34 @@ pub fn no_window(cmd: &mut Command) -> &mut Command { } cmd } + +static SHELL_ENV: OnceLock> = OnceLock::new(); + +/// Records the login shell's environment, read once at startup off the main thread. +pub fn set_shell_env(vars: Vec<(OsString, OsString)>) { + let _ = SHELL_ENV.set(vars); +} + +/// The login shell's variables this process lacks: an app started from Finder/Dock gets only +/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...). +/// Apply before a call site's own variables so those win. Empty until the shell has been read. +pub fn shell_env() -> impl Iterator { + SHELL_ENV + .get() + .into_iter() + .flatten() + .filter(|(k, _)| std::env::var_os(k).is_none()) + .map(|(k, v)| (k.as_os_str(), v.as_os_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_env_adds_only_what_the_process_lacks() { + set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); + let vars: Vec<_> = shell_env().collect(); + assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]); + } +} diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index aff17aea..e7277da7 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -18,6 +18,8 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; use tokio::process::{Child, Command}; pub const PORT_PLACEHOLDER: &str = "${port}"; +/// Replaced by the session's working directory (the project) in adapter arguments. +pub const ROOT_PLACEHOLDER: &str = "${root}"; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Transport { @@ -38,6 +40,9 @@ pub struct AdapterSpec { pub env: Vec<(String, String)>, /// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface. pub server_host: Option, + /// `host:port` of an adapter inside another program (the Godot editor): connected to + /// when it accepts, else `command` is started. + pub connect: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -47,6 +52,7 @@ fn builtin(adapter: &str) -> Option { command: command.to_owned(), args: args.iter().map(|a| (*a).to_owned()).collect(), transport: transport.to_owned(), + connect: None, install_hint: Some(hint.to_owned()), }; Some(match adapter { @@ -119,6 +125,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { cwd, env: Vec::new(), server_host: None, + connect: config.connect.clone(), } } @@ -127,6 +134,19 @@ pub fn substitute_port(args: &[String], port: u16) -> Vec { args.iter().map(|a| a.replace(PORT_PLACEHOLDER, &port.to_string())).collect() } +/// Replaces every `${root}` occurrence with `root`. +pub fn substitute_root(args: &[String], root: &Path) -> Vec { + let root = root.to_string_lossy(); + args.iter().map(|a| a.replace(ROOT_PLACEHOLDER, &root)).collect() +} + +/// Splits `host:port` (`[v6]:port`). +pub fn parse_address(addr: &str) -> Option<(String, u16)> { + let (host, port) = addr.trim().rsplit_once(':')?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + Some((host.to_owned(), port.parse().ok().filter(|p| *p != 0)?)) +} + /// A port that was free a moment ago on the loopback interface. pub fn free_port() -> std::io::Result { let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; @@ -306,6 +326,10 @@ pub trait AdapterLauncher: Send + Sync + 'static { pub struct ProcessLauncher; const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// How long an adapter inside another program (a headless Godot editor) may take to listen. +const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120); +/// Probe of a `connect` address before starting the command. +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); impl AdapterLauncher for ProcessLauncher { fn launch(&self, mut spec: AdapterSpec, reuse_port: Option) -> LaunchFuture { @@ -327,6 +351,28 @@ impl AdapterLauncher for ProcessLauncher { stderr, }); } + let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) { + Some(addr) => Some( + parse_address(addr) + .ok_or_else(|| IdeError::invalid(format!("invalid debug adapter address `{addr}`")))?, + ), + None => None, + }; + // An adapter inside a program the user runs (the Godot editor): use it, never stop it. + if let Some((host, port)) = &embedded + && let Ok(Ok(stream)) = + tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host.as_str(), *port))).await + { + let _ = stream.set_nodelay(true); + let (r, w) = stream.into_split(); + return Ok(AdapterIo { + reader: Box::new(r), + writer: Box::new(w), + child: None, + port: Some(*port), + stderr, + }); + } if spec.adapter == DEBUGPY_TOOL { prepare_debugpy(&mut spec).await?; } @@ -337,12 +383,17 @@ impl AdapterLauncher for ProcessLauncher { .flatten() .ok_or_else(|| missing(&spec))?; let port = match spec.transport { - Transport::Tcp => Some(free_port()?), + // An embedded adapter started without `${port}` listens on its usual address. + Transport::Tcp => match &embedded { + Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port), + _ => Some(free_port()?), + }, Transport::Stdio => None, }; + let args = substitute_root(&spec.args, &spec.cwd); let args = match port { - Some(port) => substitute_port(&spec.args, port), - None => spec.args.clone(), + Some(port) => substitute_port(&args, port), + None => args, }; let mut cmd = Command::new(&binary); ide_core::process::no_window(cmd.as_std_mut()); @@ -374,7 +425,8 @@ impl AdapterLauncher for ProcessLauncher { if let Some(out) = child.stdout.take() { pump_lines(out, spec.adapter.clone(), stderr.clone()); } - let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await { + let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT }; + let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await { Ok(s) => s, Err(e) => { let _ = child.start_kill(); diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 20f8c0cb..247049f2 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -620,6 +620,14 @@ impl DebugManager { if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() { arguments["__workspaceFolder"] = json!(cwd); } + // Godot compares `project` with its own (resolved) project path: `/tmp/game` is + // rejected by an editor that opened `/private/tmp/game`. + if config.adapter == "godot" + && let Some(project) = arguments.get("project").and_then(Value::as_str) + && let Ok(real) = std::fs::canonicalize(project) + { + arguments["project"] = json!(real); + } if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() { arguments["sourceLanguages"] = json!(["rust"]); } @@ -984,8 +992,8 @@ impl DebugManager { } pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult> { - let body = self - .session(s)? + let session = self.session(s)?; + let body = session .conn .request_timeout( "stackTrace", @@ -993,11 +1001,14 @@ impl DebugManager { REQUEST_TIMEOUT, ) .await?; - Ok(body - .get("stackFrames") - .and_then(Value::as_array) - .map(|f| f.iter().map(frame_from).collect()) - .unwrap_or_default()) + let frame = |f: &Value| { + let mut frame = frame_from(f); + if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) { + frame.path = Some(client.into()); + } + frame + }; + Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default()) } pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult> { diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 7fefb1e0..6219f4bf 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -51,9 +51,16 @@ pub(crate) struct Session { pub pending_restore: Mutex, /// Adapter breakpoint id → (file, index in the stored list). pub breakpoint_ids: Mutex>, + /// Resolved path → the path as the IDE knows it, for adapters given resolved paths. + resolved_paths: Mutex>, progress: Mutex>, } +/// Adapters that only match files by their resolved path: Godot maps a script to +/// `res://` by prefix and silently never stops at a breakpoint set through a +/// symlinked path (`/tmp/game` while the editor has `/private/tmp/game`). +const RESOLVES_PATHS: &[&str] = &["godot"]; + impl Session { #[allow(clippy::too_many_arguments)] pub fn new( @@ -86,6 +93,7 @@ impl Session { terminated: AtomicBool::new(false), pending_restore: Mutex::default(), breakpoint_ids: Mutex::new(HashMap::new()), + resolved_paths: Mutex::new(HashMap::new()), progress: Mutex::new(HashMap::new()), } } @@ -94,6 +102,25 @@ impl Session { self.owner.sink.emit(IdeEvent::Debug { session: self.id, event }); } + /// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters). + pub fn adapter_path(&self, path: &Path) -> PathBuf { + if !RESOLVES_PATHS.contains(&self.adapter.as_str()) { + return path.to_path_buf(); + } + match std::fs::canonicalize(path) { + Ok(real) if real != path => { + self.resolved_paths.lock().insert(real.clone(), path.to_path_buf()); + real + } + _ => path.to_path_buf(), + } + } + + /// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]). + pub fn client_path(&self, path: &str) -> Option { + self.resolved_paths.lock().get(Path::new(path)).cloned() + } + pub fn supports(&self, cap: &str) -> bool { self.caps.lock().get(cap).and_then(Value::as_bool).unwrap_or(false) } @@ -135,7 +162,7 @@ impl Session { .request_timeout( "setBreakpoints", json!({ - "source": { "path": path, "name": name }, + "source": { "path": self.adapter_path(path), "name": name }, "breakpoints": source_bps, "sourceModified": false, }), @@ -311,7 +338,11 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, body: s.emit(DebugEvent::BreakpointsChanged { path: path.into() }); } } - "exited" => s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }), + // Godot repeats `exited` / `terminated` when the client disconnects afterwards. + "exited" if !s.terminated.load(Ordering::Acquire) => { + s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }) + } + "exited" => {} "terminated" => { if !s.terminated.swap(true, Ordering::AcqRel) { s.emit(DebugEvent::Terminated); @@ -391,7 +422,12 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result Arc { + let lang = ide_language::LanguageConfig { + id: ide_core::LanguageId::new("gdscript"), + name: "GDScript".into(), + extensions: vec!["gd".into()], + filenames: vec![], + first_line: None, + line_comment: None, + block_comment: None, + indent: None, + ui_mode: None, + tree_sitter: None, + language_servers: vec![], + debugger: Some(ide_language::DebuggerConfig { + adapter: "godot".into(), + command: command.into(), + args: args.iter().map(|a| (*a).to_owned()).collect(), + transport: "tcp".into(), + connect: Some(format!("127.0.0.1:{port}")), + install_hint: Some("Open the project in the Godot editor".into()), + }), + }; + Arc::new(LanguageRegistry::from_configs(vec![lang])) +} + +fn godot_launch(project: &std::path::Path) -> LaunchConfig { + LaunchConfig { + name: "Main Scene".into(), + adapter: "godot".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: Some(project.to_path_buf().into()), + env: BTreeMap::new(), + extra_json: Some(json!({"project": project, "scene": "main"}).to_string()), + } +} + +/// Godot's debug adapter runs inside the editor (port 6006): an open editor is +/// used as is, nothing is started. +#[tokio::test] +async fn godot_connects_to_the_editor_adapter() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[])); + let project = std::env::temp_dir(); + let id = manager.start(godot_launch(&project), owner).await.unwrap(); + let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap(); + assert_eq!(launch["arguments"]["scene"], "main"); + assert_eq!(launch["arguments"]["project"], json!(std::fs::canonicalize(&project).unwrap())); + assert!(log.lock().iter().any(|m| m["command"] == "configurationDone")); + manager.stop(id).await.unwrap(); +} + +#[tokio::test] +async fn godot_without_editor_or_executable_says_what_to_do() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[])); + let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + assert!(err.message.contains("Open the project in the Godot editor"), "{}", err.message); +} + +/// No editor open: the command (a headless editor) is started for the project and +/// the client connects once it listens; the started process ends with the session. +#[cfg(unix)] +#[tokio::test] +async fn godot_starts_a_headless_editor_when_none_is_open() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + // `sleep` stands in for `godot --headless --editor --path ${root} …`. + let manager = DebugManager::new(godot_registry(port, "sleep", &["30"])); + let id = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap(); + assert!(log.lock().iter().any(|m| m["command"] == "launch")); + manager.stop(id).await.unwrap(); +} + +/// Godot never stops at a breakpoint given through a symlinked path (it maps scripts +/// to `res://` by its resolved project path): breakpoints and `project` go out resolved. +#[cfg(unix)] +#[tokio::test] +async fn godot_gets_resolved_paths() { + let real = std::env::temp_dir().join(format!("openidea-godot-real-{}", std::process::id())); + let link = std::env::temp_dir().join(format!("openidea-godot-link-{}", std::process::id())); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("player.gd"), "extends Node\n").unwrap(); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let real = std::fs::canonicalize(&real).unwrap(); + + let log: Log = Arc::default(); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::with_launcher( + godot_registry(1, "godot", &[]), + Arc::new(FakeLauncher { log: log.clone(), script: Script::default() }), + ); + let bp = Breakpoint { line: 0, condition: None, hit_condition: None, log_message: None, enabled: true }; + manager.set_breakpoints(link.join("player.gd"), vec![bp]).await.unwrap(); + let id = manager.start(godot_launch(&link), owner).await.unwrap(); + let sent = log.lock().iter().find(|m| m["command"] == "setBreakpoints").cloned().unwrap(); + assert_eq!(sent["arguments"]["source"]["path"], json!(real.join("player.gd"))); + let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap(); + assert_eq!(launch["arguments"]["project"], json!(real)); + manager.stop(id).await.unwrap(); + std::fs::remove_file(&link).ok(); + std::fs::remove_dir_all(&real).ok(); +} + +#[test] +fn root_and_address_placeholders() { + let args: Vec = ["--path", "${root}", "--dap-port", "${port}"].iter().map(|s| s.to_string()).collect(); + let args = adapter::substitute_port(&adapter::substitute_root(&args, std::path::Path::new("/g")), 6010); + assert_eq!(args, ["--path", "/g", "--dap-port", "6010"]); + assert_eq!(adapter::parse_address("127.0.0.1:6006"), Some(("127.0.0.1".into(), 6006))); + assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006))); + assert_eq!(adapter::parse_address("6006"), None); +} diff --git a/crates/ide-dap/tests/godot.rs b/crates/ide-dap/tests/godot.rs new file mode 100644 index 00000000..a26f590f --- /dev/null +++ b/crates/ide-dap/tests/godot.rs @@ -0,0 +1,157 @@ +//! Drives Godot's debug adapter (inside the Godot editor) with the GDScript +//! language definition: a headless editor is started for the project, the main +//! scene is played, a breakpoint hits, stack / variables / stepping work, and the +//! game and the editor end with the session. +//! +//! Ignored by default (needs Godot 4). Run with +//! `GODOT_BIN= cargo test -p ide-dap --test godot -- --ignored --nocapture`. + +use ide_core::event::DebugEvent; +use ide_core::{IdeEvent, SharedSink}; +use ide_dap::*; +use ide_language::{LanguageConfig, LanguageRegistry}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SCRIPT: &str = "extends Node + +var health: int = 3 + +func _ready() -> void: +\tvar total := add(2, 3) +\tprint(\"total = \", total) +\thealth -= 1 +\tget_tree().quit() + +func add(a: int, b: int) -> int: +\treturn a + b +"; +/// 0-based line of `return a + b`. +const BREAK_LINE: u32 = 11; + +fn project(dir: &Path) { + std::fs::write( + dir.join("project.godot"), + "config_version=5\n\n[application]\nconfig/name=\"Probe\"\nrun/main_scene=\"res://main.tscn\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("main.tscn"), + "[gd_scene load_steps=2 format=3]\n\n[ext_resource type=\"Script\" path=\"res://player.gd\" id=\"1\"]\n\n[node name=\"Main\" type=\"Node\"]\nscript = ExtResource(\"1\")\n", + ) + .unwrap(); + std::fs::write(dir.join("player.gd"), SCRIPT).unwrap(); +} + +/// The built-in GDScript definition, with the editor's usual port moved aside so an +/// open editor can't answer instead of the one this test starts. +fn registry() -> Arc { + let builtin = LanguageRegistry::load(None).unwrap(); + let mut gd: LanguageConfig = builtin.get(&ide_core::LanguageId::new("gdscript")).unwrap(); + let dbg = gd.debugger.as_mut().unwrap(); + let unused = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + dbg.connect = Some(format!("127.0.0.1:{unused}")); + Arc::new(LanguageRegistry::from_configs(vec![gd])) +} + +async fn wait_for(events: &Mutex>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent { + let start = Instant::now(); + loop { + if let Some(e) = events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()), + _ => None, + }) { + return e; + } + assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock()); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn godot_processes(project: &Path) -> usize { + let out = std::process::Command::new("pgrep").args(["-f", &project.to_string_lossy()]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).lines().count() +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn breakpoints_stepping_and_variables_in_a_real_godot() { + let Ok(godot) = std::env::var("GODOT_BIN") else { + eprintln!("GODOT_BIN not set; skipping"); + return; + }; + let dir = std::env::temp_dir().join(format!("openidea-godot-dap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let root = std::fs::canonicalize(&dir).unwrap(); + project(&root); + let manager = DebugManager::new(registry()); + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let script_as_opened = dir.join("player.gd"); + manager + .set_breakpoints( + script_as_opened.clone(), + vec![Breakpoint { + line: BREAK_LINE, + condition: None, + hit_condition: None, + log_message: None, + enabled: true, + }], + ) + .await + .unwrap(); + // As the UI builds it for "Godot Project" (`godot --path .`). + let config = LaunchConfig { + name: "Godot Project".into(), + adapter: "godot".into(), + request: "launch".into(), + program: None, + args: vec![], + // The project as opened, through a symlink on macOS (`/var` → `/private/var`). + cwd: Some(dir.clone().into()), + env: BTreeMap::new(), + extra_json: Some(serde_json::json!({ "project": dir, "scene": "main" }).to_string()), + }; + let id = manager + .start_with(config, SessionOwner::new("main", sink), |_| Some(godot.clone())) + .await + .unwrap_or_else(|e| panic!("start: {e:?}")); + + let stopped = wait_for(&events, Duration::from_secs(120), |e| matches!(e, DebugEvent::Stopped { .. })).await; + let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() }; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].name, "add"); + assert_eq!(frames[0].line, BREAK_LINE); + // Frames name the file as the IDE opened it, not Godot's resolved path. + assert_eq!(frames[0].path.as_deref().map(Path::new), Some(script_as_opened.as_path())); + assert_eq!(frames[1].name, "_ready"); + let scopes = manager.scopes(id, frames[0].id).await.unwrap(); + let locals = scopes.iter().find(|s| s.name == "Locals").expect("Locals scope"); + let vars = manager.variables(id, locals.variables_reference).await.unwrap(); + let value = |n: &str| vars.iter().find(|v| v.name == n).map(|v| v.value.clone()); + assert_eq!((value("a").as_deref(), value("b").as_deref()), (Some("2"), Some("3")), "{vars:?}"); + + events.lock().clear(); + manager.step(id, thread_id, StepKind::Over).await.unwrap(); + wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!((frames[0].name.as_str(), frames[0].line), ("_ready", 6), "stepped back into `_ready`, at the print"); + manager.resume(id, thread_id).await.unwrap(); + // (The game's last output is not always forwarded when it quits right away.) + wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Terminated)).await; + let _ = manager.stop(id).await; + // The headless editor started for the session ends with it, the game too. + let start = Instant::now(); + while godot_processes(&root) > 0 { + assert!(start.elapsed() < Duration::from_secs(10), "Godot still running for {}", root.display()); + tokio::time::sleep(Duration::from_millis(200)).await; + } + std::fs::remove_dir_all(&root).ok(); +} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index e2739703..43df9351 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -33,6 +33,7 @@ ide-db.workspace = true ide-ssh.workspace = true ide-remote.workspace = true ide-jsonrpc.workspace = true +ide-agent.workspace = true ide-tools = { path = "../ide-tools" } serde.workspace = true serde_json.workspace = true diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 7021ec6b..6a9e67a3 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -118,6 +118,7 @@ impl Host { pub fn close_project(&self, window: &str) { if let Some(project) = self.projects.write().remove(window) { + project.agents.shutdown(); let lsp = project.lsp.clone(); self.runtime.spawn(async move { lsp.shutdown_all().await }); } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index cde72997..933055e3 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -1,13 +1,14 @@ //! Maven / Gradle tool windows (the build model and its background sync) and //! Build › Build Project (compiling with the project's build system). -use crate::Host; use crate::ops::Channel; use crate::rpc::Dispatcher; +use crate::{Host, Project, apply_workspace_edit}; use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::pom_edit::{ArtifactKey, Exclusion}; use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; use ide_core::settings::Settings; -use ide_core::{IdeError, IdeEvent, IdeResult, RequestId}; +use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; use std::collections::BTreeMap; use std::path::Path; @@ -122,6 +123,66 @@ pub async fn build_sync( result? } +/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the +/// module's own, else its nearest parent in the project; its path, text and declaration. +fn declaring_pom(project: &Project, pom: &str, dependency: &ArtifactKey) -> IdeResult<(std::path::PathBuf, String, Range)> { + let model = ide_build::maven::load(&project.root); + for rel in ide_build::pom_edit::declaring_poms(&model, pom) { + let path = project.root.join(&rel); + let text = project.text_of(&path)?; + if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) { + return Ok((path, text, range)); + } + } + Err(IdeError::not_found(format!( + "{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)" + ))) +} + +/// Where the direct dependency `dependency` of the Maven module `pom` is declared. +pub async fn maven_dependency_declaration( + host: &Host, + window: &str, + pom: String, + dependency: ArtifactKey, +) -> IdeResult { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let (path, _, range) = declaring_pom(&project, &pom, &dependency)?; + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + +/// Adds an `` of `exclusion` to the declaration of the direct dependency +/// `dependency` of the Maven module `pom`, through the open document when there is one +/// (saved). Returns the declaration; nothing changes when it already excludes it. +pub async fn maven_exclude_dependency( + host: &Host, + window: &str, + pom: String, + dependency: ArtifactKey, + exclusion: ArtifactKey, +) -> IdeResult { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; + match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { + Some(Exclusion::Edit(edit)) => { + let edit = + WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + apply_workspace_edit(&project.documents, &edit)?; + } + Some(Exclusion::Present) => {} + None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))), + } + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + /// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the /// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable /// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build) @@ -177,6 +238,12 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add("maven_dependency_declaration", |c| async move { + maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await + }); + d.add("maven_exclude_dependency", |c| async move { + maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await + }); d.add("build_run", |c| async move { build_run( &c.host, diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 1a2aed0a..159723eb 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -14,7 +14,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide }; let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let custom = |command: &str| { - let path = ide_sdk::apply::setting_path(&root, settings.tool_path(command)?)?; + let path = ide_sdk::apply::tool_command(&root, &settings, command)?; Some(path.to_string_lossy().into_owned()) }; host.debugger.start_with(config, owner, custom).await diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index dad9ec85..465329e7 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -72,14 +72,23 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide host.project(window)?.lsp.virtual_document(&uri).await } +/// References of the symbol at `pos`. `project_wide` also searches project files +/// outside the language server's analysis scope; bulk scans over many symbols +/// leave it off. Cancel with `cancel_request(request)`. pub async fn lsp_references( host: &Host, window: &str, + request: RequestId, doc: DocId, pos: Pos, include_declaration: bool, -) -> IdeResult> { - host.project(window)?.lsp.references(doc, pos, include_declaration).await + project_wide: bool, +) -> IdeResult { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; + host.end_request(request); + result } pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { @@ -267,7 +276,16 @@ pub(crate) fn register(d: &mut Dispatcher) { ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_references", |c| async move { - lsp_references(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("includeDeclaration")?).await + lsp_references( + &c.host, + &c.window, + c.arg("request")?, + c.arg("doc")?, + c.arg("pos")?, + c.arg("includeDeclaration")?, + c.arg("projectWide")?, + ) + .await }); d.add("lsp_document_highlights", |c| async move { lsp_document_highlights(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 86715189..36e60775 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -35,7 +35,14 @@ 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 encoding = project.settings.effective().console.encoding; + let settings = project.settings.effective(); + // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. + let mut config = config; + ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings); + if config.command == ide_sdk::godot::COMMAND { + return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); + } + let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) } diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index c11db0eb..db0ab665 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -21,7 +21,9 @@ fn tools(host: &Host, window: &str) -> IdeResult { .iter() .filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?))) .collect(); - Ok(Tools::new(&data).with_custom_paths(custom)) + // Godot is usually an app bundle / Flatpak outside the search path. + let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); + Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) } fn join_err(e: impl std::fmt::Display) -> IdeError { diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index c83f22d9..1b40ba30 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -20,6 +20,10 @@ pub struct VcsRoot { pub root: ide_core::IpcPath, /// Merge / rebase / cherry-pick / revert in progress. pub operation: Option, + /// Current branch; `None` when HEAD is detached (or unreadable). + pub head: Option, + /// HEAD commit; `None` before the first commit. + pub head_commit: Option, } #[derive(Clone, Serialize, Deserialize, Type)] @@ -167,7 +171,10 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult> { all.sort_by_key(|r| Some(r.root()) != primary.as_deref()); Ok(all .into_iter() - .map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() }) + .map(|r| { + let (head, head_commit) = r.head().unwrap_or_default(); + VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten(), head, head_commit } + }) .collect()) } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 62104634..916d898d 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -2,7 +2,7 @@ //! watchers and the per-project services, opened by [`crate::Host::open_project`]. use crate::Host; -use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink}; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink}; use ide_index::ProjectIndex; use ide_language::{FileTypes, LanguageRegistry, SyntaxCache}; use ide_lsp::LspManager; @@ -44,6 +44,8 @@ pub struct Project { pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. pub http_history: Arc, + /// Coding agent tasks (Agents tool window). + pub agents: Arc, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -270,6 +272,21 @@ impl Host { self.ssh.clone(), )); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); + let agents = Arc::new(ide_agent::AgentManager::new( + ide_agent::AgentPaths { + project_root: root.clone(), + store_dir: self.data_dir.join("agents").join(&project_key), + // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. + worktrees_dir: dirs::home_dir() + .unwrap_or_else(|| self.data_dir.clone()) + .join(".openidea") + .join("worktrees") + .join(&project_key), + models_file: self.data_dir.join("agents").join("models.json"), + custom_agents_dir: Some(self.config_dir.join("agents")), + }, + self.runtime.clone(), + )); let project = Arc::new(Project { root, sink, @@ -287,10 +304,17 @@ impl Host { cache_dir: self.data_dir.join("cache").join(&project_key), db, http_history, + agents, watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, }); + { + let weak = Arc::downgrade(&project); + project.lsp.set_candidates_hook(Box::new(move |q, cancel| { + weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() + })); + } self.insert_project(window, project.clone()); Ok(project) } @@ -430,6 +454,33 @@ impl Project { out } + /// Project files of `q.languages` under `q.root` containing `q.word` as a whole + /// word (unsaved buffers included; excluded roots, ignored names and + /// `.gitignore`d files skipped): the files a references search opens on its server. + pub fn reference_candidates( + &self, + q: &ide_lsp::CandidateQuery, + cancel: &CancellationToken, + ) -> ide_lsp::CandidateFiles { + 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(&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); + ide_lsp::CandidateFiles::default() + } + } + } + /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). pub fn exclude_globs(&self) -> Vec { exclude_globs(&self.settings.effective(), &self.detected_roots.read()) diff --git a/crates/ide-host/src/shell_env.rs b/crates/ide-host/src/shell_env.rs index 7be13170..d00d2a67 100644 --- a/crates/ide-host/src/shell_env.rs +++ b/crates/ide-host/src/shell_env.rs @@ -5,6 +5,13 @@ //! Spawning an interactive login shell costs ~1 s with typical rc files, so the //! value is cached: a warm start uses the cached PATH immediately and refreshes //! the cache in the background for the next launch. +//! +//! The rest of the shell's environment (API keys, provider switches, SDK homes) +//! goes to child processes through `ide_core::process::shell_env`. It stays in +//! memory, never in the cache: it may hold secrets. + +#[cfg(unix)] +use std::ffi::{OsStr, OsString}; #[cfg(unix)] /// Returns a refresh to run once the environment is final (it spawns a @@ -22,7 +29,9 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option path, None => { // Slow shell rc files must not delay the first start indefinitely. - let fresh = read_login_path(Duration::from_secs(3))?; + let env = read_login_env(Duration::from_secs(3))?; + let fresh = login_path(&env)?; + ide_core::process::set_shell_env(env); if let Some(cache) = &cache { write_cache(cache, &fresh); } @@ -34,9 +43,11 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option Option { +fn login_path(env: &[(OsString, OsString)]) -> Option { + env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.to_str()).filter(|p| !p.is_empty()).map(str::to_owned) +} + +/// Variables the shell sets for itself rather than the user's configuration. +#[cfg(unix)] +const SHELL_OWN: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_"]; + +/// The login shell's environment; PATH included, the shell's own bookkeeping left out. +#[cfg(unix)] +fn read_login_env(timeout: std::time::Duration) -> Option> { use std::io::Read; + use std::os::unix::ffi::OsStrExt; use std::process::{Command, Stdio}; use std::time::Instant; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); - const MARK: &str = "__OPENIDEA_PATH__"; + const MARK: &str = "__OPENIDEA_ENV__"; let mut child = Command::new(&shell) - .args(["-ilc", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")]) + .args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + // Read while waiting: a large environment would fill the pipe and stall the shell. + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut out = Vec::new(); + stdout.read_to_end(&mut out).map(|_| out) + }); let deadline = Instant::now() + timeout; loop { match child.try_wait() { @@ -71,14 +99,24 @@ fn read_login_path(timeout: std::time::Duration) -> Option { Ok(None) if Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)), _ => { let _ = child.kill(); - tracing::warn!("timed out reading PATH from {shell}"); + tracing::warn!("timed out reading the environment from {shell}"); return None; } } } - let mut out = String::new(); - child.stdout.take()?.read_to_string(&mut out).ok()?; - out.split(MARK).nth(1).filter(|p| !p.is_empty()).map(str::to_owned) + let out = reader.join().ok()?.ok()?; + let mark = MARK.as_bytes(); + let start = out.windows(mark.len()).position(|w| w == mark)? + mark.len(); + let len = out[start..].windows(mark.len()).position(|w| w == mark)?; + let vars = out[start..start + len] + .split(|&b| b == 0) + .filter_map(|entry| { + let eq = entry.iter().position(|&b| b == b'=')?; + let (k, v) = (OsStr::from_bytes(&entry[..eq]), OsStr::from_bytes(&entry[eq + 1..])); + (!k.is_empty() && !SHELL_OWN.iter().any(|s| k == *s)).then(|| (k.to_owned(), v.to_owned())) + }) + .collect(); + Some(vars) } #[cfg(not(unix))] diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 3ff6e985..1086e892 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -1,7 +1,8 @@ //! Transport shared by the LSP and DAP clients. //! //! * [`framing`]: `Content-Length` header framing over any async byte stream -//! (used by both protocols). +//! (used by both protocols), and newline-delimited JSON ([`Framing::Lines`], +//! the Agent Client Protocol). //! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation, //! cancellation (`$/cancelRequest`) and handlers for server→client traffic. @@ -88,6 +89,34 @@ pub mod framing { writer.write_all(&buf).await?; writer.flush().await } + + /// Reads one newline-terminated message, skipping blank lines. + pub async fn read_line_message(reader: &mut R) -> std::io::Result>> { + let mut line = Vec::new(); + loop { + line.clear(); + let n = (&mut *reader).take(MAX_MESSAGE_LEN as u64 + 1).read_until(b'\n', &mut line).await?; + if n == 0 { + return Ok(None); + } + if line.len() > MAX_MESSAGE_LEN { + return Err(Error::new(ErrorKind::InvalidData, "message too large")); + } + let text = trim_line(&line); + if !text.iter().all(u8::is_ascii_whitespace) { + return Ok(Some(text.to_vec())); + } + } + } + + /// Writes one message followed by `\n` and flushes once. + pub async fn write_line_message(writer: &mut W, body: &[u8]) -> std::io::Result<()> { + let mut buf = Vec::with_capacity(body.len() + 1); + buf.extend_from_slice(body); + buf.push(b'\n'); + writer.write_all(&buf).await?; + writer.flush().await + } } use parking_lot::Mutex; @@ -210,6 +239,32 @@ impl Inner { } } +/// How messages are delimited on the wire. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Framing { + /// `Content-Length` headers (LSP, DAP). + #[default] + ContentLength, + /// One JSON message per line (Agent Client Protocol). + Lines, +} + +impl Framing { + async fn read(self, reader: &mut R) -> std::io::Result>> { + match self { + Framing::ContentLength => framing::read_message(reader).await, + Framing::Lines => framing::read_line_message(reader).await, + } + } + + async fn write(self, writer: &mut W, body: &[u8]) -> std::io::Result<()> { + match self { + Framing::ContentLength => framing::write_message(writer, body).await, + Framing::Lines => framing::write_line_message(writer, body).await, + } + } +} + /// A connected JSON-RPC peer. Cloning shares the connection. #[derive(Clone)] pub struct Peer { @@ -238,12 +293,26 @@ impl Peer { env: &[(String, String)], handler: Arc, on_stderr: Box, + ) -> std::io::Result { + Self::spawn_framed(command, args, cwd, env, handler, on_stderr, Framing::ContentLength) + } + + /// [`spawn`](Self::spawn) with the given wire framing. + pub fn spawn_framed( + command: &str, + args: &[String], + cwd: &std::path::Path, + env: &[(String, String)], + handler: Arc, + on_stderr: Box, + framing: Framing, ) -> std::io::Result { use std::process::Stdio; let mut cmd = tokio::process::Command::new(command); ide_core::process::no_window(cmd.as_std_mut()); cmd.args(args) .current_dir(cwd) + .envs(ide_core::process::shell_env()) .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -255,7 +324,7 @@ impl Peer { let stderr = child.stderr.take(); let pid = child.id(); - let peer = Self::start(stdout, stdin, handler, false, pid); + let peer = Self::start(stdout, stdin, handler, false, pid, framing); if let Some(stderr) = stderr { let name = command.to_owned(); @@ -276,7 +345,13 @@ impl Peer { }); } - let inner = peer.inner.clone(); + peer.supervise(child); + Ok(peer) + } + + /// Kills `child` when the connection is torn down; closes the connection when it exits. + fn supervise(&self, mut child: tokio::process::Child) { + let inner = self.inner.clone(); tokio::spawn(async move { let status = tokio::select! { status = child.wait() => status.ok(), @@ -287,7 +362,6 @@ impl Peer { }; inner.mark_closed(status.and_then(|s| s.code())); }); - Ok(peer) } /// Speaks JSON-RPC over arbitrary byte streams (in-memory pipes in tests, TCP sockets). @@ -299,10 +373,45 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None) + Self::start(reader, writer, handler, true, None, Framing::ContentLength) } - fn start(reader: R, mut writer: W, handler: Arc, close_on_eof: bool, pid: Option) -> Self + /// [`connect`](Self::connect) with the given wire framing. + pub fn connect_framed(reader: R, writer: W, handler: Arc, framing: Framing) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + Self::start(reader, writer, handler, true, None, framing) + } + + /// [`Peer::connect`] over streams served by `child` (a server reached over a socket + /// it listens on): the child is killed with the connection, and its exit closes it. + /// + /// Must be called from within a Tokio runtime. + pub fn connect_with_child( + reader: R, + writer: W, + handler: Arc, + child: tokio::process::Child, + ) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength); + peer.supervise(child); + peer + } + + fn start( + reader: R, + mut writer: W, + handler: Arc, + close_on_eof: bool, + pid: Option, + framing: Framing, + ) -> Self where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, @@ -330,7 +439,7 @@ impl Peer { _ = kill.cancelled() => None, }; let Some(body) = body else { break }; - if let Err(e) = framing::write_message(&mut writer, &body).await { + if let Err(e) = framing.write(&mut writer, &body).await { tracing::debug!("jsonrpc write failed: {e}"); if let Some(inner) = weak.upgrade() { inner.close_transport(); @@ -369,7 +478,7 @@ impl Peer { let mut reader = BufReader::new(reader); loop { let msg = tokio::select! { - msg = framing::read_message(&mut reader) => msg, + msg = framing.read(&mut reader) => msg, _ = inner.kill.cancelled() => break, }; match msg { diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs index b3a644c2..11050997 100644 --- a/crates/ide-jsonrpc/src/tests.rs +++ b/crates/ide-jsonrpc/src/tests.rs @@ -120,6 +120,28 @@ async fn request_response_and_errors() { assert!(matches!(err, RpcError::Protocol(_))); } +#[tokio::test] +async fn line_framing_round_trips_and_skips_blank_lines() { + let mut buf = Vec::new(); + framing::write_line_message(&mut buf, br#"{"a":1}"#).await.unwrap(); + buf.extend_from_slice(b"\r\n\n{\"b\":2}\r\n"); + let mut reader = tokio::io::BufReader::new(&buf[..]); + assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"a":1}"#); + assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"b":2}"#); + assert!(framing::read_line_message(&mut reader).await.unwrap().is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn line_framed_peers_talk() { + let (a_io, b_io) = duplex(64 * 1024); + let (ar, aw) = tokio::io::split(a_io); + let (br, bw) = tokio::io::split(b_io); + let client = Peer::connect_framed(ar, aw, Arc::new(NoopHandler), Framing::Lines); + let _server = Peer::connect_framed(br, bw, Arc::new(Recorder::default()), Framing::Lines); + let v: Value = client.request("echo", json!({"x": "line\nbreak"}), None).await.unwrap(); + assert_eq!(v, json!({"x": "line\nbreak"})); +} + #[tokio::test(flavor = "multi_thread")] async fn notifications_arrive_in_order() { let rec = Arc::new(Recorder::default()); diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index 91bf1beb..39207b94 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -53,6 +53,7 @@ tree-sitter-xml = "0.7.0" tree-sitter-haskell = "0.23.1" tree-sitter-dart = "0.2.0" tree-sitter-graphql = "0.3.0" +tree-sitter-gdscript = "6.1.0" [dev-dependencies] criterion = { version = "0.5", default-features = false } diff --git a/crates/ide-language/queries/gdscript/highlights.scm b/crates/ide-language/queries/gdscript/highlights.scm new file mode 100644 index 00000000..0177e521 --- /dev/null +++ b/crates/ide-language/queries/gdscript/highlights.scm @@ -0,0 +1,60 @@ +; GDScript highlighting (the grammar crate ships no query). Later patterns win. + +(identifier) @variable +(name) @variable + +(comment) @comment +[(region_start) (region_end)] @comment +(string) @string +(escape_sequence) @string.escape +[(string_name) (node_path) (get_node)] @string.special +(integer) @number +(float) @number +[(true) (false) (null)] @constant.builtin + +(type) @type +(type (identifier) @type) +(extends_statement (type) @type) +(class_name_statement name: (name) @type) +(class_definition name: (name) @type) +(enum_definition name: (name) @type) +(enumerator left: (identifier) @constant) + +(function_definition name: (name) @function.definition) +(constructor_definition "_init" @function.definition) +(lambda name: (name) @function.definition) +(signal_statement name: (name) @function.definition) +(call (identifier) @function) +(attribute_call (identifier) @function) +(base_call (identifier) @function) + +(attribute (identifier) (identifier) @variable.member) + +((identifier) @constant + (#lua-match? @constant "^[A-Z][A-Z_0-9]*$")) +((identifier) @variable.builtin + (#lua-match? @variable.builtin "^self$")) +(const_statement name: (name) @constant) + +(typed_parameter (identifier) @variable.parameter) +(default_parameter (identifier) @variable.parameter) +(typed_default_parameter (identifier) @variable.parameter) + +(annotation "@" @attribute (identifier) @attribute) +[(remote_keyword) (static_keyword)] @keyword +[(breakpoint_statement) (pass_statement) (break_statement) (continue_statement)] @keyword + +[ + "if" "elif" "else" "for" "while" "match" "when" "return" "await" + "func" "class" "class_name" "extends" "signal" "enum" "const" "var" + "set" "get" "setget" "onready" "export" "in" "is" "as" "and" "or" "not" +] @keyword + +[ + "+" "-" "*" "/" "%" "**" "<<" ">>" "&" "|" "^" "~" "!" "&&" "||" + "==" "!=" "<" "<=" ">" ">=" "=" ":=" "+=" "-=" "*=" "/=" "%=" "**=" + "<<=" ">>=" "&=" "|=" "^=" "->" +] @operator + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +["," "." ":" ";"] @punctuation.delimiter diff --git a/crates/ide-language/queries/gdscript/outline.scm b/crates/ide-language/queries/gdscript/outline.scm new file mode 100644 index 00000000..e3b471b8 --- /dev/null +++ b/crates/ide-language/queries/gdscript/outline.scm @@ -0,0 +1,14 @@ +(class_name_statement name: (name) @name) @def.class +(class_definition name: (name) @name) @def.class +(function_definition name: (name) @name parameters: (parameters) @detail) @def.function +(constructor_definition "_init" @name parameters: (parameters) @detail) @def.constructor +(signal_statement name: (name) @name) @def.event +(enum_definition name: (name) @name) @def.enum +(enumerator left: (identifier) @name) @def.enum_member +(const_statement name: (name) @name) @def.constant +(source (variable_statement name: (name) @name) @def.field) +(source (export_variable_statement name: (name) @name) @def.field) +(source (onready_variable_statement name: (name) @name) @def.field) +(class_body (variable_statement name: (name) @name) @def.field) +(class_body (export_variable_statement name: (name) @name) @def.field) +(class_body (onready_variable_statement name: (name) @name) @def.field) diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index ef476297..6af20c66 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -75,6 +75,14 @@ pub struct LanguageServerConfig { /// Human hint shown when the binary is missing. #[serde(default)] pub install_hint: Option, + /// `host:port` of a server that speaks LSP over TCP (Godot's editor). Connects + /// there when something listens; otherwise starts `command` (when installed) + /// and connects once it accepts: to `127.0.0.1:${port}` when the arguments + /// contain `${port}` (a free port), else to this address. `${root}` in the + /// arguments is the workspace root. A server reached without starting it is + /// never shut down or killed by the IDE. + #[serde(default)] + pub connect: Option, /// An alternative to the servers listed before it (pyright after /// basedpyright): only used while none of those is installed. #[serde(default)] @@ -96,6 +104,10 @@ pub struct DebuggerConfig { /// `stdio` or `tcp`. #[serde(default = "default_transport")] pub transport: String, + /// `tcp` only: `host:port` of an adapter running inside another program (the + /// Godot editor). Used when it accepts connections; otherwise `command` is started. + #[serde(default)] + pub connect: Option, #[serde(default)] pub install_hint: Option, } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 29cd289c..f3103be8 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -22,9 +22,12 @@ const BUILTIN: &[(&str, &str)] = &[ ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), + ("gdscript", include_str!("../../../languages/gdscript.toml")), + ("gdshader", include_str!("../../../languages/gdshader.toml")), ("go", include_str!("../../../languages/go.toml")), ("gomod", include_str!("../../../languages/gomod.toml")), ("gowork", include_str!("../../../languages/gowork.toml")), + ("godot-resource", include_str!("../../../languages/godot-resource.toml")), ("graphql", include_str!("../../../languages/graphql.toml")), ("groovy", include_str!("../../../languages/groovy.toml")), ("haskell", include_str!("../../../languages/haskell.toml")), @@ -542,10 +545,17 @@ mod tests { } for ls in &cfg.language_servers { assert!(!ls.command.is_empty() && ls.install_hint.is_some(), "{name}: {} incomplete", ls.name); + if let Some(addr) = &ls.connect { + assert!(addr.parse::().is_ok(), "{name}: bad connect address {addr}"); + } } if let Some(d) = &cfg.debugger { assert!(matches!(d.transport.as_str(), "stdio" | "tcp"), "{name}: bad transport"); assert!(d.install_hint.is_some(), "{name}: debugger without install hint"); + if let Some(addr) = &d.connect { + assert_eq!(d.transport, "tcp", "{name}: `connect` needs the tcp transport"); + assert!(addr.parse::().is_ok(), "{name}: bad connect address {addr}"); + } } for ext in &cfg.extensions { assert_eq!(ext, &ext.to_ascii_lowercase(), "{name}: extensions must be lower case"); @@ -679,6 +689,13 @@ mod tests { (".gitignore", "ignore"), (".gitattributes", "gitattributes"), ("server.log", "log"), + ("player.gd", "gdscript"), + ("main.tscn", "godot-resource"), + ("theme.tres", "godot-resource"), + ("project.godot", "godot-resource"), + ("icon.svg.import", "godot-resource"), + ("export_presets.cfg", "godot-resource"), + ("water.gdshader", "gdshader"), ("notes.txt", "plaintext"), ]; for (path, id) in cases { @@ -686,6 +703,27 @@ mod tests { } } + #[test] + fn gdscript_definition() { + let reg = reg(); + let gd = reg.get(&LanguageId::new("gdscript")).unwrap(); + assert_eq!(gd.line_comment.as_deref(), Some("#")); + let indent = gd.indent.unwrap(); + assert!(!indent.insert_spaces, "Godot indents with tabs"); + assert_eq!(gd.tree_sitter.as_deref(), Some("gdscript")); + let ls = &gd.language_servers[0]; + assert_eq!((ls.name.as_str(), ls.command.as_str()), ("godot", "godot")); + assert_eq!(ls.connect.as_deref(), Some("127.0.0.1:6005")); + assert_eq!(ls.root_markers, ["project.godot"]); + assert!(ls.args.iter().any(|a| a == "${port}") && ls.args.iter().any(|a| a == "${root}")); + let dbg = gd.debugger.as_ref().unwrap(); + assert_eq!((dbg.adapter.as_str(), dbg.transport.as_str()), ("godot", "tcp")); + assert_eq!(dbg.connect.as_deref(), Some("127.0.0.1:6006")); + assert!(dbg.args.iter().any(|a| a == "--dap-port")); + let res = reg.get(&LanguageId::new("godot-resource")).unwrap(); + assert_eq!(res.line_comment.as_deref(), Some(";")); + } + #[test] fn detects_shebangs() { let reg = reg(); diff --git a/crates/ide-language/src/syntax/grammars.rs b/crates/ide-language/src/syntax/grammars.rs index 2045bde4..b6bdfe45 100644 --- a/crates/ide-language/src/syntax/grammars.rs +++ b/crates/ide-language/src/syntax/grammars.rs @@ -450,4 +450,14 @@ pub(crate) static SPECS: &[Spec] = &[ imports: NONE, constructors: NONE, }, + Spec { + name: "gdscript", + language: tree_sitter_gdscript::LANGUAGE, + outline: outline!("gdscript"), + highlights: Some(Highlights { query: highlights!("gdscript"), first_wins: false }), + fold_nodes: &["string"], + fold_bodies: &["body", "class_body", "match_body"], + imports: NONE, + constructors: &["_init"], + }, ]; diff --git a/crates/ide-language/src/syntax/highlight.rs b/crates/ide-language/src/syntax/highlight.rs index 2daab82f..07c94249 100644 --- a/crates/ide-language/src/syntax/highlight.rs +++ b/crates/ide-language/src/syntax/highlight.rs @@ -440,6 +440,31 @@ mod tests { ], ), ("make", "# hi\nCC := gcc\nall: main.o\n\t$(CC) -o app main.o\n", &[("# hi", K::Comment)]), + ( + "gdscript", + "extends Node2D\nclass_name Player\n# hi\n@export var speed: float = 1.5\nconst MAX_HP = 10\nsignal died(cause)\n\nfunc _ready() -> void:\n\tvar label = $Label\n\tself.position.x += speed\n\tprint(\"ready\", &\"name\", 3)\n", + &[ + ("extends", K::Keyword), + ("Node2D", K::Type), + ("class_name", K::Keyword), + ("Player", K::Type), + ("# hi", K::Comment), + ("@export", K::Attribute), + ("var", K::Keyword), + ("float", K::Type), + ("1.5", K::Number), + ("MAX_HP", K::Constant), + ("signal", K::Keyword), + ("func", K::Keyword), + ("_ready", K::Function), + ("$Label", K::StringSpecial), + ("self", K::SelfVariable), + ("print", K::FunctionCall), + ("\"ready\"", K::String), + ("&\"name\"", K::StringSpecial), + ("3", K::Number), + ], + ), ]; for (lang, src, expect) in cases { let got = spans(lang, src); diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index 80614378..4f4be704 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -902,6 +902,24 @@ fn outline_more_languages() { Function build ", ); + check_outline( + "gdscript", + "class_name Player\nextends Node\n\nsignal died\nenum State { IDLE, RUN }\nconst SPEED = 2\n@export var hp := 3\n\nfunc _init(a):\n\tpass\n\nfunc jump(height: float) -> void:\n\tvar local = 1\n\nclass Inner:\n\tvar x = 0\n\tfunc go():\n\t\tpass\n", + " + Class Player + Event died + Enum State + EnumMember IDLE + EnumMember RUN + Constant SPEED + Field hp + Constructor _init + Function jump + Class Inner + Field x + Method go + ", + ); check_outline( "cmake", "project(Foo)\nfunction(my_fn arg)\n message(${arg})\nendfunction()\nadd_executable(app main.c)\n", diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 07bcefa9..317f9319 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -14,7 +14,7 @@ lsp-types.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["net"] } parking_lot.workspace = true tracing.workspace = true futures.workspace = true diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 3e79608d..c5df7398 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -75,8 +75,12 @@ impl ClientHandler { _ => return, } }; + let ended = matches!(&event, IdeEvent::Task { done: true, .. }); mgr.sink().emit(event); self.server.refresh_busy_status(mgr.sink()); + if ended { + self.server.progress_ended(); + } } fn publish_diagnostics(&self, mgr: &LspManager, params: Value) { @@ -88,6 +92,10 @@ impl ClientHandler { } }; let Some(path) = uri_to_path(params.uri.as_str()) else { return }; + // Files opened only for a request (references search) are not the user's problems. + if self.server.suppresses_diagnostics(&path) { + return; + } let conv = mgr.conv(self.encoding()); let items = params .diagnostics diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 8e4c24b5..b42273a8 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -1,5 +1,6 @@ //! Starting server processes: binary resolution and the [`Launcher`] seam. +use ide_core::CancellationToken; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; use std::ffi::OsString; @@ -27,13 +28,28 @@ pub trait Launcher: Send + Sync + 'static { on_stderr: Box, ) -> Result; + /// [`Launcher::launch`] that gives up once `cancel` fires (the server was stopped + /// while still starting). Launchers that return at once need not override it. + fn launch_cancellable( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result { + let _ = cancel; + self.launch(config, root, handler, on_stderr) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true } } -/// Resolves `command` on an augmented `PATH` and spawns it with stdio transport. +/// Resolves `command` on an augmented `PATH` and spawns it with stdio transport, +/// or for a `connect` server reaches it over TCP (see [`crate::tcp`]). pub struct ProcessLauncher; impl Launcher for ProcessLauncher { @@ -43,12 +59,37 @@ impl Launcher for ProcessLauncher { root: &Path, handler: Arc, on_stderr: Box, + ) -> Result { + self.launch_cancellable(config, root, handler, on_stderr, &CancellationToken::new()) + } + + fn launch_cancellable( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, ) -> Result { let search = server_search_path(config); - let binary = which::which_in(&config.command, Some(&search), root).map_err(|_| LaunchError::Missing)?; - let binary = resolve_rustup_proxy(&config.command, binary); + let binary = which::which_in(&config.command, Some(&search), root) + .ok() + .map(|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()))); + if let Some(addr) = &config.connect { + return crate::tcp::connect_or_start( + config, + addr, + binary.as_deref(), + &env, + root, + handler, + on_stderr, + cancel, + ); + } + let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index bb46369d..3ba57aa2 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -13,7 +13,9 @@ mod handler; mod inspect; pub mod jdtls; mod launch; +mod scope; mod server; +mod tcp; mod text; mod tsserver_bridge; pub mod types; @@ -22,6 +24,7 @@ mod virtual_doc; pub use inspect::{FileInspection, InspectHost, InspectOptions}; 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}; use caps::{ServerCaps, SyncKind}; @@ -142,6 +145,7 @@ pub struct LspManager { docs: Mutex>, apply_edit_hook: RwLock>, config_hook: RwLock>, + candidates_hook: RwLock>>, shutting_down: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, @@ -182,6 +186,19 @@ fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError { } } +/// Sends the user's `didOpen` for `path`, closing a transient copy of the file +/// first (one a references search opened). +fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, params: Value) { + if server.take_transient(path, run.generation) + && let Err(e) = run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })) + { + tracing::debug!("{}: didClose: {e}", server.name); + } + if let Err(e) = run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", server.name); + } +} + fn unsupported(feature: &str) -> IdeError { IdeError::unavailable(format!("no running language server supports {feature}")) } @@ -234,6 +251,7 @@ impl LspManager { docs: Mutex::new(HashMap::new()), apply_edit_hook: RwLock::new(None), config_hook: RwLock::new(None), + candidates_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), @@ -298,10 +316,8 @@ impl LspManager { } let mut attached = Attached { server: server.clone(), opened_gen: None }; if let Some(run) = server.running() { - if run.caps.read().open_close - && let Err(e) = run.peer.notify("textDocument/didOpen", d.did_open_params()) - { - tracing::debug!("{}: didOpen: {e}", server.name); + if run.caps.read().open_close { + user_did_open(&server, &run, &d.path, &d.uri, d.did_open_params()); } attached.opened_gen = Some(run.generation); } @@ -332,7 +348,9 @@ impl LspManager { let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation)); let me = self.clone(); let tail = server.clone(); - // The settings hook may touch the filesystem (e.g. scanning for JDKs). + let cancel = server.start_token(); + // The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP + // servers may take a while to start listening. let launched = tokio::task::spawn_blocking(move || { let cfg = me.configured(&tail); tail.set_effective_config(cfg.clone()); @@ -342,7 +360,7 @@ impl LspManager { let missing = !me.launcher.available(&cfg, &root); return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } - me.launcher.launch(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line))) + me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); @@ -444,9 +462,11 @@ impl LspManager { return; } let tail = server.stderr_tail(5); - let mut message = match code { - Some(c) => format!("{} exited with code {c}", server.name), - None => format!("{} terminated unexpectedly", server.name), + let cfg = server.effective_config(); + let mut message = match (code, &cfg.connect) { + (Some(c), _) => format!("{} exited with code {c}", server.name), + (None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", server.name), + (None, None) => format!("{} terminated unexpectedly", server.name), }; if !tail.is_empty() { message.push_str(":\n"); @@ -476,6 +496,7 @@ impl LspManager { for (id, title) in server.take_progress() { self.sink.emit(ide_core::IdeEvent::Task { id, title, message: None, fraction: None, done: true }); } + server.progress_ended(); for path in server.take_diagnostic_paths() { self.sink.emit(ide_core::IdeEvent::Diagnostics { path: path.into(), @@ -591,10 +612,8 @@ impl LspManager { let params = state.did_open_params(); for a in &mut state.servers { if let Some(run) = a.server.running() { - if run.caps.read().open_close - && let Err(e) = run.peer.notify("textDocument/didOpen", params.clone()) - { - tracing::debug!("{}: didOpen: {e}", a.server.name); + if run.caps.read().open_close { + user_did_open(&a.server, &run, &state.path, &state.uri, params.clone()); } a.opened_gen = Some(run.generation); } @@ -940,40 +959,61 @@ impl LspManager { out.finish(Vec::new()) } - pub async fn references(&self, doc: DocId, pos: Pos, include_declaration: bool) -> IdeResult> { + /// References of the symbol at `pos`. With `project_wide`, files outside + /// the servers' analysis scope that mention the name are opened for the + /// search (see [`scope`]); without, only what the servers analyse counts + /// (cheap, for bulk scans over many symbols). + pub async fn references( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + cancel: CancellationToken, + ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; let requests = targets.iter().map(|t| { - let mut params = snap.pos_params(t, pos); - params["context"] = json!({ "includeDeclaration": include_declaration }); + let (snap, cancel) = (&snap, &cancel); async move { - ( - t, - self.call::<_, Option>>( - t, - "textDocument/references", - params, - None, - LONG_TIMEOUT, - ) - .await, - ) + let query = || async move { + let mut params = snap.pos_params(t, pos); + params["context"] = json!({ "includeDeclaration": include_declaration }); + let locs: Option> = + self.call(t, "textDocument/references", params, Some(cancel), LONG_TIMEOUT).await?; + let conv = self.conv(t.enc()); + Ok(locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect::>()) + }; + let files = + |v: &Vec| v.iter().filter(|l| l.uri.is_none()).map(|l| l.path.to_path_buf()).collect(); + let result = if project_wide { + self.widened(snap, t, pos, cancel, query, files).await + } else { + query().await.map(|first| { + let empty = first.is_empty(); + scope::Widened::only(first, empty, &t.server) + }) + }; + (t, result) } }); let mut out = Outcome::new(); for (t, r) in join_all(requests).await { match r { - Ok(locs) => { - let conv = self.conv(t.enc()); - let locs: Vec = - locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect(); + Ok(w) => { + let acc = out.value.get_or_insert_with(References::default); + let locs: Vec = w.first.into_iter().chain(w.second.into_iter().flatten()).collect(); self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); - out.value.get_or_insert_with(Vec::new).extend(locs); + acc.locations.extend(locs); + acc.incomplete = acc.incomplete.or(w.incomplete); } Err(e) => out.fail(e), } } - let mut v = out.finish(Vec::new())?; - convert::dedup_locations(&mut v); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut v = out.finish(References::default())?; + convert::dedup_locations(&mut v.locations); Ok(v) } @@ -1203,13 +1243,29 @@ impl LspManager { }) } + /// Renames across the project: like [`Self::references`], files outside the + /// server's analysis scope that mention the name are opened for the request. pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?; let t = &targets[0]; - let mut params = snap.pos_params(t, pos); - params["newName"] = json!(new_name); - let r: Option = 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()) + let query = || async { + let mut params = snap.pos_params(t, pos); + params["newName"] = json!(new_name); + let r: Option = 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()) + }; + let files = |e: &WorkspaceEdit| { + e.files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(path.to_path_buf()), + _ => None, + }) + .collect() + }; + let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?; + // The second edit covers the first: its files stay analysed. + Ok(w.second.unwrap_or(w.first)) } // ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ---- @@ -1258,16 +1314,17 @@ impl LspManager { /// Some servers (tsserver) only answer for files of a loaded project: when /// `t` has no document open, one moved file of its languages is opened for the request. - fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> Option { + fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> scope::TransientFiles<'_> { + let mut opened = scope::TransientFiles::empty(self, t); if !t.caps().open_close { - return None; + return opened; } - let docs = self.docs.lock(); - let busy = docs - .values() - .any(|d| d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation))); + let busy = + self.docs.lock().values().any(|d| { + d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation)) + }); if busy { - return None; + return opened; } let languages = t.server.languages(); let candidate = @@ -1282,14 +1339,10 @@ impl LspManager { continue; } let Ok(text) = std::fs::read_to_string(&file) else { continue }; - let uri = uri::path_to_uri(&file); - let params = - json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } }); - if t.run.peer.notify("textDocument/didOpen", params).is_ok() { - return Some(uri); - } + opened.hold(&file, &language, &text); + break; } - None + opened } /// Asks the servers for the edits that keep references intact when files or @@ -1315,9 +1368,7 @@ impl LspManager { let opened = self.open_for_file_op(&t, &moves); let r: IdeResult> = self.call(&t, "workspace/willRenameFiles", params, Some(&cancel), LONG_TIMEOUT).await; - if let Some(uri) = opened { - let _ = t.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })); - } + drop(opened); match r { Ok(Some(edit)) => out.edit.files.extend(convert::workspace_edit(&self.conv(t.enc()), edit).files), Ok(None) => {} @@ -1514,32 +1565,46 @@ impl LspManager { let items: Option> = 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 mut out = Vec::new(); - for item in items { - let params = json!({ "item": item }); - match direction { - HierarchyDirection::Incoming => { - let calls: Option> = - 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()); - let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect(); - out.extend(convert::call_item(&conv, &c.from, ranges)); + let query = || async { + let mut out = Vec::new(); + for item in &items { + let params = json!({ "item": item }); + match direction { + HierarchyDirection::Incoming => { + let calls: Option> = + 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()); + let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect(); + out.extend(convert::call_item(&conv, &c.from, ranges)); + } } - } - HierarchyDirection::Outgoing => { - let calls: Option> = - 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() { - let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect(); - out.extend(convert::call_item(&conv, &c.to, ranges)); + HierarchyDirection::Outgoing => { + let calls: Option> = + 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() { + let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect(); + out.extend(convert::call_item(&conv, &c.to, ranges)); + } } } } - } + Ok(out) + }; + let out = match direction { + // Callers are references: they may sit in files outside the server's scope. + HierarchyDirection::Incoming => { + let files = |v: &Vec| { + v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect() + }; + let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?; + w.second.unwrap_or(w.first) + } + HierarchyDirection::Outgoing => query().await?, + }; self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref())); Ok(out) } @@ -1753,6 +1818,15 @@ impl LspManager { return; }; let peer = run.peer.clone(); + // A server we connected to without starting it (an open Godot editor) is not + // ours to shut down: only the connection closes. + if peer.pid().is_none() && server.effective_config().connect.is_some() { + peer.kill(); + server.end_stop(run.generation); + self.clear_server_state(server); + server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None); + return; + } let graceful = async { peer.request::<_, Value>("shutdown", (), None).await.ok(); peer.notify("exit", ()).ok(); diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs new file mode 100644 index 00000000..003b1758 --- /dev/null +++ b/crates/ide-lsp/src/scope.rs @@ -0,0 +1,248 @@ +//! Reference searches beyond a server's analysis scope. +//! +//! Servers answer `textDocument/references` (and rename, incoming calls) from +//! the files they analyse: their configured scope (Pyright's `include`, a +//! `tsconfig.json`, a compilation database) plus the open documents. Usages in +//! other project files are silently missing. So, as a word index would, the +//! project files of the server's languages that contain the symbol's name as a +//! whole word (found through the host's [`CandidateFn`]) and that the first +//! answer did not cover are opened on the server for a second request, then +//! closed again. Diagnostics the server publishes for them are dropped. + +use crate::server::{Running, Server}; +use crate::types::ReferencesIncomplete; +use crate::{DocSnap, LspManager, Target}; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos}; +use lsp_types as lsp; +use serde_json::json; +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +/// Files opened for one search at most. +pub const MAX_CANDIDATES: usize = 300; +/// Larger files (generated code, bundles) are not opened. +const MAX_CANDIDATE_BYTES: u64 = 1 << 20; +/// How long a search that found nothing waits for a server to finish indexing. +const INDEX_WAIT: Duration = Duration::from_secs(10); + +/// Project files that may mention a symbol. +#[derive(Clone, Debug)] +pub struct CandidateQuery { + /// Matched as a whole word, case-sensitively. + pub word: String, + /// Only files under this directory (the server's workspace root). + pub root: PathBuf, + /// Only files of these languages. + pub languages: Vec, + pub limit: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct CandidateFiles { + pub files: Vec, + /// More files matched than `limit`. + pub limit_hit: bool, +} + +/// Finds [`CandidateFiles`] (honouring the project's ignore rules); runs on a +/// blocking thread and should stop early when cancelled. +pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync; + +/// A reference-based answer and, when files were opened for it, the second one. +pub(crate) struct Widened { + pub first: R, + pub second: Option, + pub incomplete: Option, +} + +impl Widened { + /// An answer from the server's analysis scope alone; `empty`: it found nothing. + pub fn only(first: R, empty: bool, server: &Server) -> Self { + let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing); + Self { first, second: None, incomplete } + } +} + +/// The name a search is about: the word at `pos` (a PHP `$` sigil dropped). +fn search_word(snap: &DocSnap, pos: Pos) -> Option { + let r = snap.text.word_range_at(pos)?; + let text = snap.text.as_str(); + let word = text.get(snap.text.byte_offset(r.start)..snap.text.byte_offset(r.end))?.trim_start_matches('$'); + (!word.is_empty() && !word.chars().all(|c| c.is_ascii_digit())).then(|| word.to_owned()) +} + +impl LspManager { + /// Finds the project files a references search may open (see the module docs). + pub fn set_candidates_hook(&self, hook: Box) { + *self.candidates_hook.write() = Some(Arc::from(hook)); + } + + async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option { + let hook = self.candidates_hook.read().clone()?; + let query = CandidateQuery { + word, + root: t.server.root.clone(), + languages: t.server.languages(), + limit: MAX_CANDIDATES, + }; + let cancel = cancel.clone(); + tokio::task::spawn_blocking(move || hook(&query, &cancel)).await.ok() + } + + /// Runs `query` on `t`; then again with the project files that mention the + /// symbol at `pos` and are not in `covered(first)` opened on the server. + /// A first answer that is empty while the server indexes is retried once + /// indexing ends. + pub(crate) async fn widened( + &self, + snap: &DocSnap, + t: &Target, + pos: Pos, + cancel: &CancellationToken, + query: F, + covered: impl Fn(&R) -> Vec, + ) -> IdeResult> + where + F: Fn() -> Fut, + Fut: Future>, + { + let busy = t.server.is_busy(); + let word = search_word(snap, pos).filter(|_| t.caps().open_close); + let candidates = async { + match word { + Some(w) => self.candidates(t, w, cancel).await, + None => None, + } + }; + let (first, candidates) = futures::join!(query(), candidates); + let mut first = first?; + if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await { + first = query().await?; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + // Servers report analysis progress often; only "nothing found" while it lasts is suspect. + let mut incomplete = + (t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing); + let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) }; + if candidates.limit_hit { + incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles)); + } + let known: HashSet = covered(&first).into_iter().chain([snap.path.clone()]).collect(); + let files: Vec = candidates.files.into_iter().filter(|f| !known.contains(f)).collect(); + if files.is_empty() { + return Ok(Widened { first, second: None, incomplete }); + } + let opened = self.open_transient(t, files, cancel).await; + if opened.files.is_empty() { + return Ok(Widened { first, second: None, incomplete }); + } + tracing::debug!("{}: opened {} more files for a references search", t.server.name, opened.files.len()); + let second = query().await; + drop(opened); + match second { + Ok(second) => { + if !covered(&second).is_empty() && incomplete == Some(ReferencesIncomplete::Indexing) { + incomplete = None; + } + Ok(Widened { first, second: Some(second), incomplete }) + } + Err(e) if cancel.is_cancelled() => Err(e), + Err(e) => { + tracing::debug!("{}: widened references search: {}", t.server.name, e.message); + Ok(Widened { first, second: None, incomplete }) + } + } + } + + /// Opens `files` on `t` (read from disk) unless the user has them open; + /// they are closed when the returned guard drops. + async fn open_transient(&self, t: &Target, files: Vec, cancel: &CancellationToken) -> TransientFiles<'_> { + let read = { + let cancel = cancel.clone(); + tokio::task::spawn_blocking(move || { + files + .into_iter() + .take_while(|_| !cancel.is_cancelled()) + .filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_CANDIDATE_BYTES)) + .filter_map(|f| std::fs::read_to_string(&f).ok().map(|text| (f, text))) + .collect::>() + }) + .await + .unwrap_or_default() + }; + let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }; + if cancel.is_cancelled() { + return guard; + } + let docs = self.docs.lock(); + for (path, text) in read { + if docs.values().any(|d| d.path == path) { + continue; + } + let uri = crate::uri::path_to_uri(&path); + if t.server.hold_transient(&path, t.run.generation) { + let language = self.languages.detect(&path, text.lines().next()); + let params = json!({ + "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } + }); + if let Err(e) = t.run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", t.server.name); + } + } + guard.files.push((path, uri)); + } + guard + } +} + +/// Files opened by [`LspManager::open_transient`]; closed on drop. +pub(crate) struct TransientFiles<'a> { + mgr: &'a LspManager, + server: Arc, + run: Running, + pub files: Vec<(PathBuf, lsp::Uri)>, +} + +impl TransientFiles<'_> { + /// Opens one file outside a references search (a file operation needs a loaded project). + pub(crate) fn hold(&mut self, path: &Path, language: &LanguageId, text: &str) { + let _docs = self.mgr.docs.lock(); + let uri = crate::uri::path_to_uri(path); + if self.server.hold_transient(path, self.run.generation) { + let params = + json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } }); + if let Err(e) = self.run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", self.server.name); + } + } + self.files.push((path.to_owned(), uri)); + } +} + +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() } + } +} + +impl Drop for TransientFiles<'_> { + fn drop(&mut self) { + if self.files.is_empty() { + return; + } + // Under the documents lock, so a user `didOpen` of the same file can't interleave. + let _docs = self.mgr.docs.lock(); + for (path, uri) in self.files.drain(..) { + if self.server.release_transient(&path, self.run.generation) + && let Err(e) = self.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })) + { + tracing::debug!("{}: didClose: {e}", self.server.name); + } + } + } +} diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 43d5ba0b..04cbb9f8 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -2,7 +2,7 @@ use crate::caps::ServerCaps; use ide_core::event::ServerStatus; -use ide_core::{IdeEvent, LanguageId, ServerId, SharedSink, TaskId}; +use ide_core::{CancellationToken, IdeEvent, LanguageId, ServerId, SharedSink, TaskId}; use ide_jsonrpc::Peer; use ide_language::LanguageServerConfig; use lsp_types as lsp; @@ -17,6 +17,9 @@ use tokio::sync::{Notify, watch}; const STABLE_UPTIME: Duration = Duration::from_secs(180); const MAX_RESTARTS: usize = 3; const STDERR_LINES: usize = 50; +/// Diagnostics a server publishes for a transiently opened file shortly after +/// its `didClose` (analysis that was already running) are dropped too. +const TRANSIENT_GRACE: Duration = Duration::from_secs(3); /// A live, initialized instance. #[derive(Clone)] @@ -49,6 +52,17 @@ struct State { conn: Conn, } +/// A file opened on the server by the client itself (not by the user) for the +/// duration of a request, e.g. to widen a references search. +struct Transient { + /// Requests holding the file open. + holders: usize, + generation: u64, + /// The user opened the file meanwhile: its `didOpen` replaced ours. + taken: bool, + closed_at: Option, +} + pub struct Progress { pub task: TaskId, pub title: String, @@ -65,6 +79,8 @@ pub struct Server { effective: Mutex>, languages: Mutex>, state: Mutex, + /// Fired when the instance being started is abandoned (stop / restart while starting). + start_cancel: Mutex, phase: watch::Sender, status: Mutex<(ServerStatus, Option)>, /// Consecutive crashes of short-lived instances. @@ -78,6 +94,9 @@ pub struct Server { published: Mutex>, /// Woken on every `publishDiagnostics` (Inspect Code waits on it). pub published_notify: Notify, + /// Woken when the last `$/progress` task ends. + idle_notify: Notify, + transient: Mutex>, } impl Server { @@ -90,6 +109,7 @@ impl Server { effective: Mutex::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), + start_cancel: Mutex::new(CancellationToken::new()), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), crashes: Mutex::new(0), @@ -99,6 +119,8 @@ impl Server { diagnostics: Mutex::new(HashMap::new()), published: Mutex::new(HashMap::new()), published_notify: Notify::new(), + idle_notify: Notify::new(), + transient: Mutex::new(HashMap::new()), } } @@ -132,6 +154,7 @@ impl Server { } st.generation += 1; st.conn = Conn::Starting; + *self.start_cancel.lock() = CancellationToken::new(); *self.started.lock() = Some(Instant::now()); self.phase.send_replace(Phase::Starting); Some(st.generation) @@ -197,6 +220,7 @@ impl Server { } Conn::Starting => { st.generation += 1; // invalidates the in-flight start + self.start_cancel.lock().cancel(); self.phase.send_replace(Phase::Down); None } @@ -207,6 +231,11 @@ impl Server { } } + /// Cancelled when the current start is abandoned. + pub fn start_token(&self) -> CancellationToken { + self.start_cancel.lock().clone() + } + pub fn end_stop(&self, generation: u64) { let mut st = self.state.lock(); if st.generation == generation && matches!(st.conn, Conn::Stopping) { @@ -295,6 +324,95 @@ impl Server { self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None); } + /// Whether the server reports work in progress (indexing, analysis). + pub fn is_busy(&self) -> bool { + !self.progress.lock().is_empty() + } + + /// Called after a `$/progress` task ended. + pub fn progress_ended(&self) { + if !self.is_busy() { + self.idle_notify.notify_waiters(); + } + } + + /// Waits until no `$/progress` task is active; false on timeout or cancellation. + pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let notified = self.idle_notify.notified(); + if !self.is_busy() || self.running().is_none() { + return !self.is_busy(); + } + tokio::select! { + () = notified => {} + () = tokio::time::sleep_until(deadline) => return !self.is_busy(), + () = cancel.cancelled() => return false, + } + } + } + + // ---- transiently opened files ---- + + /// Registers a holder of the transient file `path` on instance `generation`; + /// true when the caller must send `didOpen` (first holder). + pub fn hold_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + map.retain(|_, t| t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE)); + let t = + map.entry(path.to_owned()).or_insert(Transient { holders: 0, generation, taken: false, closed_at: None }); + if t.holders == 0 || t.generation != generation { + *t = Transient { holders: 1, generation, taken: false, closed_at: None }; + return true; + } + t.holders += 1; + false + } + + /// Drops a holder of `path`; true when the caller must send `didClose` + /// (last holder, same instance, and the user did not open the file meanwhile). + pub fn release_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + let Some(t) = map.get_mut(path) else { return false }; + if t.holders == 0 { + return false; + } + t.holders -= 1; + if t.holders > 0 { + return false; + } + let close = !t.taken && t.generation == generation; + if close { + t.closed_at = Some(Instant::now()); + } else { + map.remove(path); + } + close + } + + /// The user opens `path` on instance `generation`: true when a transient + /// copy is open there and must be closed before the user's `didOpen`. + pub fn take_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + let Some(t) = map.get_mut(path) else { return false }; + let open = t.holders > 0 && !t.taken && t.generation == generation; + if t.holders > 0 { + t.taken = true; + } else { + map.remove(path); + } + open + } + + /// Whether diagnostics published for `path` belong to a transient copy + /// and must not reach the Problems view. + pub fn suppresses_diagnostics(&self, path: &Path) -> bool { + self.transient + .lock() + .get(path) + .is_some_and(|t| !t.taken && (t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE))) + } + // ---- bookkeeping ---- pub fn push_stderr(&self, line: String) { diff --git a/crates/ide-lsp/src/tcp.rs b/crates/ide-lsp/src/tcp.rs new file mode 100644 index 00000000..c3992d21 --- /dev/null +++ b/crates/ide-lsp/src/tcp.rs @@ -0,0 +1,188 @@ +//! LSP over TCP, for servers living inside another program (the Godot editor's +//! GDScript server). Mirrors `ide-dap`'s `tcp` transport: connect to the +//! configured address when something listens there, else start the configured +//! command (`${port}` → a free port, `${root}` → the workspace root) and connect +//! once it accepts. The framing and session are the same as over stdio. + +use crate::launch::LaunchError; +use ide_core::CancellationToken; +use ide_jsonrpc::{Handler, Peer}; +use ide_language::LanguageServerConfig; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; + +pub(crate) const PORT_PLACEHOLDER: &str = "${port}"; +pub(crate) const ROOT_PLACEHOLDER: &str = "${root}"; + +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); +/// A headless Godot editor imports the project before it listens. +const START_TIMEOUT: Duration = Duration::from_secs(120); +const POLL: Duration = Duration::from_millis(200); +const TAIL_LINES: usize = 20; + +/// `${port}` / `${root}` substituted in every argument. +pub(crate) fn substitute_args(args: &[String], port: Option, root: &Path) -> Vec { + let root = root.to_string_lossy(); + args.iter() + .map(|a| { + let a = a.replace(ROOT_PLACEHOLDER, &root); + match port { + Some(p) => a.replace(PORT_PLACEHOLDER, &p.to_string()), + None => a, + } + }) + .collect() +} + +pub(crate) fn resolve_addr(addr: &str) -> Result { + addr.to_socket_addrs() + .ok() + .and_then(|mut a| a.next()) + .ok_or_else(|| LaunchError::Config(format!("invalid language server address `{addr}`"))) +} + +/// A connection to `addr` if something accepts there right now. +pub(crate) fn probe(addr: &SocketAddr) -> Option { + let stream = TcpStream::connect_timeout(addr, PROBE_TIMEOUT).ok()?; + let _ = stream.set_nodelay(true); + Some(stream) +} + +/// A port that was free a moment ago on the loopback interface. +pub(crate) fn free_port() -> std::io::Result { + Ok(std::net::TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +fn into_tokio(stream: TcpStream) -> std::io::Result { + stream.set_nonblocking(true)?; + tokio::net::TcpStream::from_std(stream) +} + +/// Connects to the server of `config` (whose `connect` is set), starting `binary` when +/// nothing listens yet. Blocking; must run on a thread inside a Tokio runtime. +#[allow(clippy::too_many_arguments)] +pub(crate) fn connect_or_start( + config: &LanguageServerConfig, + addr: &str, + binary: Option<&Path>, + env: &[(String, String)], + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, +) -> Result { + let addr = resolve_addr(addr)?; + if let Some(stream) = probe(&addr) { + tracing::info!("{}: connected to the server running at {addr}", config.name); + let (r, w) = into_tokio(stream).map_err(|e| LaunchError::Failed(e.to_string()))?.into_split(); + return Ok(Peer::connect(r, w, handler)); + } + let binary = binary.ok_or(LaunchError::Missing)?; + let port = config + .args + .iter() + .any(|a| a.contains(PORT_PLACEHOLDER)) + .then(free_port) + .transpose() + .map_err(|e| LaunchError::Failed(format!("no free port: {e}")))?; + let target = port.map_or(addr, |p| SocketAddr::from(([127, 0, 0, 1], p))); + let args = substitute_args(&config.args, port, root); + + let mut cmd = tokio::process::Command::new(binary); + ide_core::process::no_window(cmd.as_std_mut()); + cmd.args(&args) + .current_dir(root) + .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = + cmd.spawn().map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))?; + tracing::info!("{}: started {} {}, waiting for {target}", config.name, binary.display(), args.join(" ")); + + let tail: Arc>> = Arc::default(); + let on_line: Arc = { + let tail = tail.clone(); + Arc::new(move |line: String| { + { + let mut t = tail.lock(); + if t.len() == TAIL_LINES { + t.pop_front(); + } + t.push_back(line.clone()); + } + on_stderr(line); + }) + }; + if let Some(out) = child.stdout.take() { + pump_lines(out, on_line.clone()); + } + if let Some(err) = child.stderr.take() { + pump_lines(err, on_line); + } + let failed = |message: String| { + let tail: Vec = tail.lock().iter().cloned().collect(); + LaunchError::Failed(if tail.is_empty() { message } else { format!("{message}:\n{}", tail.join("\n")) }) + }; + + let deadline = Instant::now() + START_TIMEOUT; + loop { + if cancel.is_cancelled() { + let _ = child.start_kill(); + return Err(LaunchError::Failed(format!("{}: start cancelled", config.name))); + } + if let Some(stream) = probe(&target) { + let (r, w) = into_tokio(stream).map_err(|e| LaunchError::Failed(e.to_string()))?.into_split(); + return Ok(Peer::connect_with_child(r, w, handler, child)); + } + if let Ok(Some(status)) = child.try_wait() { + return Err(failed(format!("{} exited before accepting connections on {target} ({status})", config.name))); + } + if Instant::now() >= deadline { + let _ = child.start_kill(); + return Err(failed(format!( + "{} did not accept connections on {target} within {}s", + config.name, + START_TIMEOUT.as_secs() + ))); + } + std::thread::sleep(POLL); + } +} + +fn pump_lines(stream: R, on_line: Arc) { + tokio::spawn(async move { + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(line); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_substituted() { + let args: Vec = + ["--path", "${root}", "--lsp-port", "${port}", "--x=${port}"].iter().map(|s| s.to_string()).collect(); + assert_eq!( + substitute_args(&args, Some(6010), Path::new("/p/game")), + ["--path", "/p/game", "--lsp-port", "6010", "--x=6010"] + ); + assert_eq!(substitute_args(&args, None, Path::new("/p"))[3], "${port}"); + } + + #[test] + fn addresses_resolve() { + assert_eq!(resolve_addr("127.0.0.1:6005").unwrap().port(), 6005); + assert!(matches!(resolve_addr("nonsense"), Err(LaunchError::Config(_)))); + } +} diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 0499d7c7..4fb820e7 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -322,3 +322,20 @@ pub struct FileRenameEdits { /// Servers that failed; their references are not updated. pub errors: Vec, } + +/// Why a references search may have missed usages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ReferencesIncomplete { + /// A language server was still indexing. + Indexing, + /// More project files mention the name than were checked. + TooManyFiles, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct References { + pub locations: Vec, + pub incomplete: Option, +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 5ba8b065..0b26e084 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -27,9 +27,13 @@ struct MockState { /// uri → text as reconstructed by the mock from didOpen/didChange. docs: Mutex>, responses: Mutex>, + /// Computes a response from the request and the open documents (before `responses`). + responder: Mutex>, peer: Mutex>, } +type Responder = Arc Option + Send + Sync>; + impl MockState { fn notified(&self, method: &str) -> Vec { self.notifications.lock().iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect() @@ -111,11 +115,17 @@ impl Handler for MockHandler { fn on_request(&self, method: &str, params: Value) -> Result { let s = &self.0; - s.requests.lock().push((method.to_owned(), params)); + s.requests.lock().push((method.to_owned(), params.clone())); match method { "initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })), "shutdown" => Ok(Value::Null), - _ => Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null)), + _ => { + let responder = s.responder.lock().clone(); + if let Some(v) = responder.and_then(|f| f(s, method, ¶ms)) { + return Ok(v); + } + Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null)) + } } } } @@ -192,6 +202,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { root_markers: vec!["mock.toml".into()], initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })), install_hint: Some(format!("install {name}")), + connect: None, fallback: false, launch_error: None, } @@ -800,7 +811,7 @@ async fn navigation_rename_format_and_decorations() { assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); s.respond("textDocument/references", json!([{ "uri": uri, "range": r(0,3,0,6) }, { "uri": uri, "range": r(1,0,1,3) }, { "uri": uri, "range": r(1,0,1,3) }])); - let refs = h.mgr.references(doc, Pos::new(1, 1), true).await.unwrap(); + let refs = h.mgr.references(doc, Pos::new(1, 1), true, true, Default::default()).await.unwrap().locations; assert_eq!(refs.len(), 2); assert_eq!(s.requested("textDocument/references")[0]["context"]["includeDeclaration"], true); @@ -986,7 +997,7 @@ async fn virtual_locations_and_text_document_content() { "textDocument/references", json!([{ "uri": uri_of(&path), "range": r(0, 4, 0, 9) }, { "uri": lib, "range": r(2, 4, 2, 9) }]), ); - let refs = h.mgr.references(doc, Pos::new(0, 5), true).await.unwrap(); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap().locations; assert_eq!(refs.iter().map(|l| l.uri.is_some()).collect::>(), vec![false, true]); s.respond( "textDocument/prepareTypeHierarchy", @@ -1300,3 +1311,338 @@ async fn css_family_configuration_returns_default_objects() { assert_eq!(state.requested("initialize")[0]["initializationOptions"]["provideFormatter"], true); } } + +// ------------------------------------------------------------------ references beyond the analysis scope + +const WORD: &str = "target"; + +/// Locations of [`WORD`] in `text` (ASCII, so UTF-16 columns are byte offsets). +fn occurrences(uri: &str, text: &str) -> Vec { + let mut out = Vec::new(); + for (line, l) in text.lines().enumerate() { + for (col, _) in l.match_indices(WORD) { + let end = col + WORD.len(); + out.push(json!({ "uri": uri, "range": { + "start": { "line": line, "character": col }, "end": { "line": line, "character": end } } })); + } + } + out +} + +/// A server that only analyses its open documents (minus `blind` URIs), like +/// Pyright for files outside its `include`. +fn open_files_only(blind: Vec) -> Responder { + 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)); + match method { + "textDocument/references" => Some(Value::Array(locs().collect())), + "textDocument/rename" => { + let mut changes = serde_json::Map::new(); + for loc in locs() { + let edits = changes.entry(loc["uri"].as_str().unwrap()).or_insert_with(|| json!([])); + edits.as_array_mut().unwrap().push(json!({ "range": loc["range"], "newText": "renamed" })); + } + Some(json!({ "changes": changes })) + } + _ => None, + } + }) +} + +/// Candidate files: those directly under the root that contain the word; records the queries. +fn grep_candidates(h: &Harness) -> Arc>> { + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + h.mgr.set_candidates_hook(Box::new(move |q, _| { + log.lock().push(q.clone()); + let mut files: Vec = std::fs::read_dir(&q.root) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(&q.word))) + .collect(); + files.sort(); + ide_lsp::CandidateFiles { files, limit_hit: false } + })); + seen +} + +fn uris(params: &[Value]) -> Vec { + params.iter().map(|p| p["textDocument"]["uri"].as_str().unwrap().to_owned()).collect() +} + +fn diagnostics_for(h: &Harness, path: &Path) -> usize { + h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Diagnostics { path: p, .. } if p == path)).count() +} + +fn scope_harness() -> (Harness, Arc) { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + *s.responder.lock() = Some(open_files_only(vec![])); + (h, s) +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_open_unopened_files_that_mention_the_name() { + let (h, s) = scope_harness(); + let seen = grep_candidates(&h); + let b = h.root.join("b.mk"); + let c = h.root.join("c.mk"); + std::fs::write(&b, "call target\n").unwrap(); + std::fs::write(&c, "nothing here\n").unwrap(); + let (doc, a) = h.open("a.mk", "def target\n"); + let (_, d) = h.open("d.mk", "use target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 2).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + let mut paths: Vec = refs.locations.iter().map(|l| l.path.to_path_buf()).collect(); + paths.sort(); + assert_eq!(paths, vec![a.clone(), b.clone(), d.clone()]); + assert_eq!(refs.incomplete, None); + + let q = seen.lock()[0].clone(); + assert_eq!((q.word.as_str(), q.root.as_path(), q.limit), (WORD, h.root.as_path(), ide_lsp::MAX_CANDIDATES)); + assert_eq!(q.languages, vec![h.lang.clone()]); + + // Only the unopened candidate was opened (from disk), then closed again. + let opens = s.notified("textDocument/didOpen"); + let temp: Vec<&Value> = opens.iter().filter(|p| p["textDocument"]["uri"] == uri_of(&b)).collect(); + assert_eq!(temp.len(), 1); + assert_eq!(temp[0]["textDocument"]["version"], 0); + assert_eq!(temp[0]["textDocument"]["text"], "call target\n"); + assert!(!uris(&opens).contains(&uri_of(&c))); + assert_eq!(uris(&opens).iter().filter(|u| **u == uri_of(&d)).count(), 1); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; + assert_eq!(s.requested("textDocument/references").len(), 2); + assert_eq!(s.docs.lock().len(), 2); + + // When every candidate file is already in the answer, no second request is made. + std::fs::remove_file(&b).unwrap(); + h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(s.requested("textDocument/references").len(), 3); + + // Without project_wide only the server's scope counts: no project search. + std::fs::write(&b, "call target\n").unwrap(); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert_eq!(seen.lock().len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_never_reopen_or_close_the_users_files() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let (doc, _) = h.open("a.mk", "def target\n"); + // Open, mentions the name, but the server does not report it. + let (_, d) = h.open("d.mk", "use target\n"); + *s.responder.lock() = Some(open_files_only(vec![uri_of(&d)])); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 2).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert_eq!(uris(&s.notified("textDocument/didOpen")).iter().filter(|u| **u == uri_of(&d)).count(), 1); + assert!(s.notified("textDocument/didClose").is_empty()); + assert_eq!(s.requested("textDocument/references").len(), 1); + assert!(s.docs.lock().contains_key(&uri_of(&d))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_report_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; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() { + 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; + let cancel = ide_core::CancellationToken::new(); + let later = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + later.cancel(); + }); + let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + + // Cancelled while the server answers with a file opened for the search. + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + grep_candidates(&h); + let slow = uri_of(&b); + let inner = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().contains_key(&slow) { + std::thread::sleep(Duration::from_millis(600)); + } + inner(s, method, params) + })); + let cancel = ide_core::CancellationToken::new(); + let later = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + later.cancel(); + }); + let started = std::time::Instant::now(); + let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(started.elapsed() < Duration::from_millis(550)); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn diagnostics_of_files_opened_for_a_search_stay_out_of_problems() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + let (doc, a) = h.open("a.mk", "def target\n"); + let diag = json!([{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message": "e" }]); + let inner = open_files_only(vec![]); + let (temp, user, d) = (uri_of(&b), uri_of(&a), diag.clone()); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().contains_key(&temp) { + for uri in [&temp, &user] { + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": d })).unwrap(); + } + } + inner(s, method, params) + })); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + // Published right after the didClose (analysis still running): dropped too. + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": diag })).unwrap(); + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&a), "diagnostics": [] })).unwrap(); + eventually(|| diagnostics_for(&h, &a) == 2).await; + assert_eq!(diagnostics_for(&h, &b), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_a_file_during_a_search_takes_it_over() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + let (doc, _) = h.open("a.mk", "def target\n"); + let slow = uri_of(&b); + let inner = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().get(&slow).is_some_and(|t| t.starts_with("call")) { + std::thread::sleep(Duration::from_millis(400)); + } + inner(s, method, params) + })); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + + let mgr = h.mgr.clone(); + let search = tokio::spawn(async move { mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await }); + eventually(|| s.docs.lock().contains_key(&uri_of(&b))).await; + let user_doc = DocId::next(); + h.mgr.did_open(user_doc, &b, &h.lang, 7, "edited target\n"); + search.await.unwrap().unwrap(); + + // The temporary copy was closed before the user's didOpen, and the user's stays open. + let events: Vec<(String, Value)> = s + .notifications + .lock() + .iter() + .filter(|(m, p)| m.starts_with("textDocument/did") && p["textDocument"]["uri"] == uri_of(&b)) + .map(|(m, p)| (m.clone(), p["textDocument"]["version"].clone())) + .collect(); + assert_eq!( + events, + vec![ + ("textDocument/didOpen".to_owned(), json!(0)), + ("textDocument/didClose".to_owned(), Value::Null), + ("textDocument/didOpen".to_owned(), json!(7)), + ] + ); + assert_eq!(s.docs.lock().get(&uri_of(&b)).map(String::as_str), Some("edited target\n")); + // Its diagnostics are the user's again. + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": [] })).unwrap(); + eventually(|| diagnostics_for(&h, &b) == 1).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_empty_answer_while_indexing_is_retried_when_indexing_ends() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + let (doc, path) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let peer = s.peer(); + peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "begin", "title": "Indexing" } })).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let (state, uri) = (s.clone(), uri_of(&path)); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + state.respond("textDocument/references", Value::Array(occurrences(&uri, "def target\n"))); + peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "end" } })).unwrap(); + }); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert_eq!(refs.incomplete, None); + assert_eq!(s.requested("textDocument/references").len(), 2); + + // While the server works, results are taken as they are; finding nothing is flagged. + s.peer() + .notify("$/progress", json!({ "token": "again", "value": { "kind": "begin", "title": "Indexing" } })) + .unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!((refs.locations.len(), refs.incomplete), (1, None)); + s.respond("textDocument/references", json!([])); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 0); + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rename_edits_files_outside_the_analysis_scope() { + let (h, s) = scope_harness(); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + let (doc, a) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into()).await.unwrap(); + let mut paths: Vec = edit + .files + .iter() + .map(|f| match f { + FileEdit::Edit { path, .. } => path.to_path_buf(), + _ => panic!("unexpected {f:?}"), + }) + .collect(); + paths.sort(); + assert_eq!(paths, vec![a, b.clone()]); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 5c42f7fa..e0bba001 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -71,6 +71,7 @@ async fn missing_binary_is_reported_with_install_hint() { root_markers: vec![], initialization_options: None, install_hint: Some("npm i -g nope-ls".into()), + connect: None, fallback: false, launch_error: None, }; @@ -198,6 +199,7 @@ async fn typescript_language_server_end_to_end() { root_markers: vec!["tsconfig.json".into()], initialization_options: None, install_hint: None, + connect: None, fallback: false, launch_error: None, }; @@ -239,6 +241,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option>, + notifications: Mutex>, + /// Server ends of the accepted connections. + peers: Mutex>, +} + +struct MockHandler(Arc); + +impl Handler for MockHandler { + fn on_notification(&self, method: &str, _params: Value) { + self.0.notifications.lock().push(method.to_owned()); + } + + fn on_request(&self, method: &str, _params: Value) -> Result { + self.0.requests.lock().push(method.to_owned()); + match method { + "initialize" => Ok(json!({ + "capabilities": { "textDocumentSync": 1, "hoverProvider": true }, + "serverInfo": { "name": "mock-godot" } + })), + "textDocument/hover" => Ok(json!({ "contents": { "kind": "markdown", "value": "**Node2D**" } })), + _ => Ok(Value::Null), + } + } +} + +/// Accepts LSP connections on a local port, like the Godot editor on 6005. +async fn mock_server() -> (u16, Arc, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(ServerLog::default()); + let accept = { + let log = log.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (r, w) = stream.into_split(); + let peer = Peer::connect(r, w, Arc::new(MockHandler(log.clone()))); + log.peers.lock().push(peer); + } + }) + }; + (port, log, accept) +} + +fn godot_cfg(port: u16, command: &str) -> LanguageServerConfig { + toml::from_str(&format!( + "name = \"godot\"\ncommand = \"{command}\"\nconnect = \"127.0.0.1:{port}\"\nroot-markers = [\"project.godot\"]\ninstall-hint = \"Open the project in the Godot editor\"" + )) + .unwrap() +} + +struct Harness { + _dir: tempfile::TempDir, + root: PathBuf, + mgr: Arc, + events: Arc>>, +} + +fn harness(server: LanguageServerConfig) -> Harness { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap(); + let lang: LanguageConfig = toml::from_str("id = \"gdscript\"\nname = \"GDScript\"\nextensions = [\"gd\"]").unwrap(); + let lang = LanguageConfig { language_servers: vec![server], ..lang }; + let registry = Arc::new(LanguageRegistry::from_configs(vec![lang])); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher)); + Harness { _dir: dir, root, mgr, events } +} + +impl Harness { + fn open(&self, name: &str, text: &str) -> DocId { + let path = self.root.join(name); + std::fs::write(&path, text).unwrap(); + let doc = DocId::next(); + self.mgr.did_open(doc, &path, &LanguageId::new("gdscript"), 1, text); + doc + } + + fn statuses(&self) -> Vec<(ServerStatus, Option)> { + self.events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServer { status, message, .. } => Some((*status, message.clone())), + _ => None, + }) + .collect() + } + + async fn wait_status(&self, status: ServerStatus, count: usize) { + eventually(|| self.statuses().iter().filter(|(s, _)| *s == status).count() >= count).await; + } +} + +async fn eventually(mut f: impl FnMut() -> bool) { + for _ in 0..1000 { + if f() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("condition not reached in time"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn connects_to_a_running_server_requests_and_reconnects() { + let (port, log, _accept) = mock_server().await; + let h = harness(godot_cfg(port, "openidea-no-such-godot")); + let doc = h.open("player.gd", "extends Node2D\n"); + h.wait_status(ServerStatus::Ready, 1).await; + assert_eq!(log.requests.lock().first().map(String::as_str), Some("initialize")); + eventually(|| log.notifications.lock().iter().any(|m| m == "textDocument/didOpen")).await; + + let hover = h.mgr.hover(doc, Pos::new(0, 9), CancellationToken::new()).await.unwrap().unwrap(); + assert!(format!("{hover:?}").contains("Node2D"), "{hover:?}"); + // Attached to a server it didn't start: no process id. + assert_eq!(h.mgr.servers()[0].pid, None); + + // The editor drops the connection (closed or restarted): the client reconnects and reopens. + log.peers.lock().remove(0).kill(); + h.wait_status(ServerStatus::Crashed, 1).await; + let (_, message) = h.statuses().into_iter().rfind(|(s, _)| *s == ServerStatus::Crashed).unwrap(); + assert!(message.unwrap().contains(&format!("127.0.0.1:{port}"))); + h.wait_status(ServerStatus::Ready, 2).await; + eventually(|| log.notifications.lock().iter().filter(|m| *m == "textDocument/didOpen").count() == 2).await; + assert_eq!(log.requests.lock().iter().filter(|m| *m == "initialize").count(), 2); + + // Shutting down leaves a server it didn't start running: no `shutdown` / `exit`. + h.mgr.shutdown_all().await; + eventually(|| log.peers.lock().iter().all(Peer::is_closed)).await; + assert!(!log.requests.lock().iter().any(|m| m == "shutdown")); + assert!(!log.notifications.lock().iter().any(|m| m == "exit")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nothing_listening_and_no_executable_reports_how_to_fix_it() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let h = harness(godot_cfg(port, "openidea-no-such-godot")); + h.open("player.gd", "extends Node\n"); + h.wait_status(ServerStatus::Missing, 1).await; + let (_, message) = h.statuses().into_iter().rfind(|(s, _)| *s == ServerStatus::Missing).unwrap(); + assert_eq!(message.as_deref(), Some("Open the project in the Godot editor")); +} + +/// The command is started when nothing listens yet, the client waits until the +/// port accepts, and the started process belongs to the connection. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn starts_the_command_and_waits_until_it_listens() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let cfg = LanguageServerConfig { args: vec!["30".into()], ..godot_cfg(port, "sleep") }; + let root = tempfile::tempdir().unwrap(); + // The "server" (`sleep`) never listens itself; a listener comes up a moment later. + let log = Arc::new(ServerLog::default()); + let server = { + let log = log.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(400)).await; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let (r, w) = stream.into_split(); + log.peers.lock().push(Peer::connect(r, w, Arc::new(MockHandler(log.clone())))); + }) + }; + let path = root.path().to_path_buf(); + let peer = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {})) + }) + .await + .unwrap() + .unwrap_or_else(|e| panic!("{e:?}")); + server.await.unwrap(); + let pid = peer.pid().expect("owns the started process"); + let init: Value = peer.request("initialize", json!({}), None).await.unwrap(); + assert_eq!(init["serverInfo"]["name"], "mock-godot"); + + peer.kill(); + peer.closed().await; + eventually(|| !process_alive(pid)).await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn a_command_that_exits_without_listening_fails_and_cancel_stops_waiting() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().to_path_buf(); + let cfg = godot_cfg(port, "false"); + let err = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {})) + }) + .await + .unwrap() + .err() + .expect("fails"); + assert!(matches!(&err, LaunchError::Failed(m) if m.contains("exited before accepting connections")), "{err:?}"); + + let cfg = LanguageServerConfig { args: vec!["30".into()], ..godot_cfg(port, "sleep") }; + let path = root.path().to_path_buf(); + let cancel = CancellationToken::new(); + let token = cancel.clone(); + let started = std::time::Instant::now(); + let waiting = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch_cancellable(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {}), &token) + }); + tokio::time::sleep(Duration::from_millis(300)).await; + cancel.cancel(); + let err = waiting.await.unwrap().err().expect("cancelled"); + assert!(matches!(&err, LaunchError::Failed(m) if m.contains("cancelled")), "{err:?}"); + assert!(started.elapsed() < Duration::from_secs(10)); +} + +#[cfg(unix)] +fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index af60d2be..093ff88d 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -29,14 +29,18 @@ pub use types::*; use ide_core::{IdeError, IdeResult}; use ide_language::LanguageConfig; -use parking_lot::{Mutex, RwLock}; +use parking_lot::{Condvar, Mutex, RwLock}; use runtime::{CallEffects, CallError, PluginEngine, Registrations, Runtime, RuntimeSetup}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; const STATE_FILE: &str = "state.json"; +/// Top-level files copied with a package on install besides those the manifest references. +const LICENSE_FILES: &[&str] = + &["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "NOTICE.txt", "COPYING"]; /// Dev plugins live here, relative to a project root. pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins"; @@ -52,6 +56,10 @@ struct PersistedState { struct ThemeRef { plugin: String, theme: String, + /// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so + /// new windows open in it before the plugin is loaded. + #[serde(default, skip_serializing_if = "Option::is_none")] + frame: Option, } struct Meta { @@ -95,6 +103,8 @@ pub struct PluginHost { active_files: Arc>>, /// Serializes loading / unloading so concurrent windows can't race. lifecycle: Mutex<()>, + /// Set once the first [`load_all`](Self::load_all) has finished. + initial_load: (Mutex, Condvar), } impl PluginHost { @@ -114,6 +124,7 @@ impl PluginHost { state: Mutex::new(state), active_files: Arc::default(), lifecycle: Mutex::new(()), + initial_load: (Mutex::new(false), Condvar::new()), }) } @@ -129,9 +140,24 @@ impl PluginHost { self.load_dir(&dir, PluginSource::Installed, None); } } + let (done, cv) = &self.initial_load; + *done.lock() = true; + cv.notify_all(); self.emit(None, PluginEvent::CommandsChanged); } + /// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a + /// listing made at startup already holds the installed plugins and the + /// selected theme. Returns whether loading has finished. + pub fn wait_initial_load(&self, timeout: Duration) -> bool { + let (done, cv) = &self.initial_load; + let mut done = done.lock(); + if !*done { + cv.wait_while_for(&mut done, |d| !*d, timeout); + } + *done + } + /// Loads new dev plugins from `/.openidea/plugins/` and unloads vanished ones. pub fn sync_project(&self, root: &Path) { let _guard = self.lifecycle.lock(); @@ -251,6 +277,11 @@ impl PluginHost { } std::fs::copy(src.join(file), to)?; } + for name in LICENSE_FILES { + if src.join(name).is_file() { + std::fs::copy(src.join(name), staging.join(name))?; + } + } if target.exists() { std::fs::remove_dir_all(&target)?; } @@ -345,13 +376,45 @@ impl PluginHost { } } self.state.lock().theme = - selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into() }); + selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None }); self.save_state(); let active = self.active_theme(); self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() }); Ok(active) } + /// Records the selected theme's resolved window frame colour (`#rgb`, + /// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme. + pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> { + if let Some(f) = frame + && !is_hex_color(f) + { + return Err(IdeError::invalid(format!("not a hex colour: {f:?}"))); + } + let changed = match self.state.lock().theme.as_mut() { + Some(t) if t.frame.as_deref() != frame => { + t.frame = frame.map(str::to_owned); + true + } + _ => false, + }; + if changed { + self.save_state(); + } + Ok(()) + } + + /// Frame colour for a new window: the recorded one of the selected theme, + /// unless its plugin is disabled. Available before plugins are loaded. + pub fn startup_frame(&self) -> Option { + let state = self.state.lock(); + let t = state.theme.as_ref()?; + if state.disabled.contains(&t.plugin) { + return None; + } + t.frame.clone() + } + /// The selected plugin theme, when its plugin is enabled and running. pub fn active_theme(&self) -> Option { let sel = self.state.lock().theme.clone()?; @@ -568,6 +631,10 @@ impl Drop for PluginHost { } } +fn is_hex_color(s: &str) -> bool { + s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit())) +} + /// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid /// manifests are skipped; packages with missing files carry a `problem`. pub fn scan_catalog(dirs: &[PathBuf]) -> Vec { diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index c56414ef..573851cd 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -22,6 +22,33 @@ fn sample(name: &str) -> PathBuf { repo().join("plugins").join(name) } +fn theme_template() -> PathBuf { + repo().join("templates/theme-plugin") +} + +/// A theme package with a light and a dark theme, plus licence files, in `dir`. +fn write_theme_pair(dir: &Path) { + std::fs::create_dir_all(dir.join("t")).unwrap(); + std::fs::write( + dir.join("plugin.toml"), + "id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\ + [[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("t/day.json"), + r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##, + ) + .unwrap(); + std::fs::write( + dir.join("t/night.json"), + r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##, + ) + .unwrap(); + std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap(); + std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap(); +} + fn misbehave_pkg() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/misbehave") } @@ -217,10 +244,11 @@ fn runtime_language_and_theme_registration() { #[test] fn contribution_only_plugins() { let f = fixture(Limits::default()); - let theme = f.host.install_from_dir(&sample("theme-solarized-dark")).unwrap(); + let theme = f.host.install_from_dir(&theme_template()).unwrap(); assert_eq!(theme.status, PluginStatus::Static); assert!(!theme.has_code); - assert_eq!(theme.themes[0].id, "solarized-dark"); + let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, ["harbor-night"]); let lang = f.host.install_from_dir(&sample("language-toy")).unwrap(); assert_eq!(lang.status, PluginStatus::Static); assert_eq!(lang.languages[0].id, "toy"); @@ -228,13 +256,14 @@ fn contribution_only_plugins() { assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code")); f.take_events(); - let active = f.host.set_theme(Some(("theme-solarized-dark", "solarized-dark"))).unwrap().unwrap(); - assert_eq!(active.theme.tokens["--bg-app"], "#002b36"); + let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap(); + assert_eq!(active.theme.tokens["--bg-app"], "#14171c"); + assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a"); assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) }))); assert_eq!(f.host.list(None).active_theme, Some(active)); // Disabling the plugin withdraws its theme and language. - f.host.set_enabled("theme-solarized-dark", false).unwrap(); + f.host.set_enabled("theme-harbor-night", false).unwrap(); assert!(f.host.active_theme().is_none()); assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None })); f.host.set_enabled("language-toy", false).unwrap(); @@ -245,7 +274,7 @@ fn contribution_only_plugins() { #[test] fn theme_template_installs_with_its_editor_scheme() { let f = fixture(Limits::default()); - let info = f.host.install_from_dir(&repo().join("templates/theme-plugin")).unwrap(); + let info = f.host.install_from_dir(&theme_template()).unwrap(); assert_eq!(info.status, PluginStatus::Static); assert!(!info.has_code); assert_eq!(info.themes[0].id, "harbor-night"); @@ -260,6 +289,47 @@ fn theme_template_installs_with_its_editor_scheme() { assert!(tokens.len() > 150); } +#[test] +fn theme_packages_install_with_their_licence() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + write_theme_pair(dir.path()); + let info = f.host.install_from_dir(dir.path()).unwrap(); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let installed = f.plugins_dir.join("theme-pair"); + assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License")); + assert!(installed.join("NOTICE").is_file()); + for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] { + let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap(); + assert_eq!(active.theme.kind, kind); + } +} + +#[test] +fn theme_frame_follows_the_selection() { + let f = fixture(Limits::default()); + let dir = tempfile::tempdir().unwrap(); + write_theme_pair(dir.path()); + f.host.install_from_dir(dir.path()).unwrap(); + // Nothing to attach it to without a selected theme. + f.host.set_theme_frame(Some("#111111")).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_theme(Some(("theme-pair", "night"))).unwrap(); + assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err()); + f.host.set_theme_frame(Some("#111111")).unwrap(); + assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); + f.host.set_enabled("theme-pair", false).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_enabled("theme-pair", true).unwrap(); + assert_eq!(f.host.startup_frame().as_deref(), Some("#111111")); + // A new selection drops the old theme's colour until the UI reports the new one. + f.host.set_theme(Some(("theme-pair", "day"))).unwrap(); + assert_eq!(f.host.startup_frame(), None); + f.host.set_theme_frame(Some("#eeeeee")).unwrap(); + f.host.set_theme(None).unwrap(); + assert_eq!(f.host.startup_frame(), None); +} + #[test] fn theme_with_broken_editor_scheme_fails_to_load() { let f = fixture(Limits::default()); @@ -282,17 +352,22 @@ fn theme_with_broken_editor_scheme_fails_to_load() { fn state_persists_across_restarts() { let f = fixture(Limits::default()); f.host.install_from_dir(&sample("hello")).unwrap(); - f.host.install_from_dir(&sample("theme-solarized-dark")).unwrap(); + f.host.install_from_dir(&theme_template()).unwrap(); f.host.set_enabled("hello", false).unwrap(); - f.host.set_theme(Some(("theme-solarized-dark", "solarized-dark"))).unwrap(); + f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap(); + f.host.set_theme_frame(Some("#1f252e")).unwrap(); let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + // Known before the plugins are loaded: new windows open in the theme's frame colour. + assert_eq!(again.startup_frame().as_deref(), Some("#1f252e")); + assert!(!again.wait_initial_load(Duration::from_millis(1))); again.load_all(); + assert!(again.wait_initial_load(Duration::ZERO)); let list = again.list(None); let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap(); assert!(!hello.enabled); assert_eq!(hello.status, PluginStatus::Disabled); - assert_eq!(list.active_theme.unwrap().theme.id, "solarized-dark"); + assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night"); assert!( again .run_command("hello", "insert-date", None, Invocation::default()) @@ -386,15 +461,15 @@ fn catalog_lists_sample_packages() { std::fs::create_dir_all(tmp.path().join("junk")).unwrap(); std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap(); - let catalog = scan_catalog(&[repo().join("plugins"), tmp.path().to_path_buf()]); + let catalog = scan_catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]); let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect(); - for id in ["hello", "theme-solarized-dark", "language-toy", "x"] { + for id in ["hello", "theme-harbor-night", "language-toy", "x"] { assert!(ids.contains(&id), "{id} missing from {ids:?}"); } let hello = catalog.iter().find(|c| c.id == "hello").unwrap(); assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some()); assert_eq!(hello.commands.len(), 2); - let theme = catalog.iter().find(|c| c.id == "theme-solarized-dark").unwrap(); + let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap(); assert_eq!((theme.theme_count, theme.has_code), (1, false)); let x = catalog.iter().find(|c| c.id == "x").unwrap(); assert!(x.problem.as_deref().unwrap().contains("plugin.wasm")); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 63becad6..d2ba05e1 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -19,6 +19,7 @@ pub(crate) fn detect(root: &Path) -> Vec { detect_python(root, &mut out); detect_make(root, &mut out); detect_just(root, &mut out); + detect_godot(root, &mut out); let mut seen = HashSet::new(); out.retain(|c| seen.insert(c.id.clone())); @@ -799,6 +800,24 @@ pub(crate) fn just_recipes(src: &str) -> Vec { recipes } +// ---------------------------------------------------------------- godot + +/// Godot projects (`project.godot`) at the root or one level down: "Godot Project" +/// runs the main scene (`godot --path .`), debuggable through the editor's adapter. +fn detect_godot(root: &Path, out: &mut Vec) { + let dirs = std::iter::once(root.to_path_buf()).chain(subdirs(root)); + for dir in dirs.filter(|d| d.join("project.godot").is_file()) { + let rel = relative(root, &dir); + let (id, name, cwd) = if rel.as_os_str().is_empty() { + ("godot:project".to_owned(), "Godot Project".to_owned(), None) + } else { + let r = rel.to_string_lossy(); + (format!("godot:project:{r}"), format!("Godot Project ({r})"), Some(rel.clone())) + }; + out.push(config(id, name, "godot", "godot", &["--path", "."]).debug("godot").in_dir(cwd)); + } +} + fn detect_just(root: &Path, out: &mut Vec) { let Some(path) = first_existing(root, &["justfile", "Justfile", ".justfile"]) else { return }; let Some(src) = read(&path) else { return }; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 40b03834..246d3dbf 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -406,6 +406,10 @@ impl ProcessManager { cmd.cwd(cwd); if env.clear { cmd.env_clear(); + } else { + for (k, v) in ide_core::process::shell_env() { + cmd.env(k, v); + } } cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); @@ -610,6 +614,8 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { fn apply_env(cmd: &mut Command, env: &LaunchEnv) { if env.clear { cmd.env_clear(); + } else { + cmd.envs(ide_core::process::shell_env()); } cmd.envs(&env.vars); } diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 8104bdbf..9a69ddfe 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -294,6 +294,21 @@ fn deno_tasks() { assert!(c[1].is_test); } +#[test] +fn godot_projects_run_their_main_scene() { + let dir = project(&[("project.godot", "config_version=5\n"), ("demo/project.godot", "config_version=5\n")]); + let c = detect(dir.path()); + let root = find(&c, "godot:project"); + assert_eq!((root.name.as_str(), root.kind.as_str()), ("Godot Project", "godot")); + assert_eq!(cmdline(root), "godot --path ."); + assert_eq!(root.cwd, None); + assert_eq!(root.debug_adapter.as_deref(), Some("godot")); + let demo = find(&c, "godot:project:demo"); + assert_eq!(demo.name, "Godot Project (demo)"); + assert_eq!(demo.cwd, Some(std::path::PathBuf::from("demo").into())); + assert!(detect(project(&[("main.gd", "extends Node\n")]).path()).is_empty()); +} + #[test] fn just_recipes() { let justfile = "\ diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 93e0ce64..bca1225f 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -3,8 +3,8 @@ use crate::discover::{project_venv_python, workspace_tsdk}; use crate::exec::{path_with, search_path}; -use crate::vue; use crate::{bin_dir, exe, venv_of}; +use crate::{godot, vue}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; use ide_run::RunConfig; @@ -169,8 +169,28 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) { /// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and /// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`). /// `root` is the project root, `server_root` the server's workspace root. +/// The executable to launch for `command`: the one chosen in settings, else for +/// commands with well-known install locations (Godot) the one found there. +pub fn tool_command(root: &Path, s: &Settings, command: &str) -> Option { + let custom = s.tool_path(command).and_then(|p| setting_path(root, p)); + if command == godot::COMMAND { + return godot::find(custom); + } + custom +} + +/// Points a run configuration launching a bare tool name at the executable from +/// [`tool_command`] (Godot apps live outside the search path). +pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) { + if config.command == godot::COMMAND + && let Some(path) = tool_command(root, s, &config.command) + { + config.command = path.to_string_lossy().into_owned(); + } +} + pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { - if let Some(custom) = s.tool_path(&cfg.command).and_then(|p| setting_path(root, p)) { + if let Some(custom) = tool_command(root, s, &cfg.command) { cfg.command = custom.to_string_lossy().into_owned(); } let mut path_dirs: Vec = Vec::new(); @@ -329,6 +349,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let mvn_options = maven_options(root, s); for c in configs.iter_mut() { + resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { "python" | "pytest" => { diff --git a/crates/ide-sdk/src/godot.rs b/crates/ide-sdk/src/godot.rs new file mode 100644 index 00000000..31999cbe --- /dev/null +++ b/crates/ide-sdk/src/godot.rs @@ -0,0 +1,109 @@ +//! Locating the Godot editor executable: the GDScript language server and debug +//! adapter run inside it, and run configurations launch games with it. +//! +//! Order: the executable chosen in settings (`tools.godot`), the search path +//! (`godot`, `godot4`, distro names), then the usual install locations per OS +//! (app bundles, Flatpak / Snap exports, Steam, Scoop / WinGet). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// The command language definitions and run configurations launch. +pub const COMMAND: &str = "godot"; + +pub const NOT_FOUND: &str = "Godot was not found. Install Godot 4 (https://godotengine.org/download), or set \ + the Godot executable in Settings › Languages & Frameworks › Language Servers › godot."; + +/// Names Godot is installed under on the search path. +const PATH_NAMES: &[&str] = &["godot", "godot4", "godot-4", "Godot", "godot-mono", "godot4-mono"]; + +/// The Godot executable: `custom` (from settings) when set, else the search path, +/// else a well-known install location. +pub fn find(custom: Option) -> Option { + if let Some(p) = custom { + return Some(p); + } + find_in(&crate::search_path(), &install_locations(crate::home_dir().as_deref())) +} + +pub(crate) fn find_in(search: &OsStr, locations: &[PathBuf]) -> Option { + PATH_NAMES + .iter() + .find_map(|n| ide_run::which::find_executable(n, search)) + .or_else(|| locations.iter().find(|p| p.is_file()).cloned()) +} + +/// Well-known install locations, most specific first. +pub(crate) fn install_locations(home: Option<&Path>) -> Vec { + let mut out: Vec = Vec::new(); + let home_join = |rel: &str| home.map(|h| h.join(rel)); + if cfg!(target_os = "macos") { + for app in ["Godot.app", "Godot_mono.app", "Godot 4.app"] { + let rel = format!("{app}/Contents/MacOS/Godot"); + out.push(Path::new("/Applications").join(&rel)); + out.extend(home_join(&format!("Applications/{rel}"))); + } + out.extend(home_join( + "Library/Application Support/Steam/steamapps/common/Godot Engine/Godot.app/Contents/MacOS/Godot", + )); + } else if cfg!(windows) { + out.extend(home_join("scoop/shims/godot.exe")); + if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) { + out.push(local.join("Microsoft/WinGet/Links/godot.exe")); + out.push(local.join("Programs/Godot/godot.exe")); + } + for base in ["C:/Program Files", "C:/Program Files (x86)"] { + out.push(Path::new(base).join("Godot/godot.exe")); + out.push(Path::new(base).join("Steam/steamapps/common/Godot Engine/godot.windows.opt.tools.64.exe")); + } + } else { + out.extend(home_join(".local/share/flatpak/exports/bin/org.godotengine.Godot")); + out.push("/var/lib/flatpak/exports/bin/org.godotengine.Godot".into()); + out.push("/snap/bin/godot-4".into()); + out.push("/snap/bin/godot".into()); + out.extend(home_join(".local/share/Steam/steamapps/common/Godot Engine/godot.x11.opt.tools.64")); + } + out +} + +/// A Godot project directory: it holds `project.godot`. +pub fn is_project(dir: &Path) -> bool { + dir.join("project.godot").is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_path_wins_over_install_locations() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + let app = tmp.path().join("Godot.app/Contents/MacOS"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&app).unwrap(); + let installed = app.join("Godot"); + std::fs::write(&installed, "").unwrap(); + let search = std::env::join_paths([&bin]).unwrap(); + assert_eq!(find_in(&search, std::slice::from_ref(&installed)), Some(installed.clone())); + let on_path = bin.join(if cfg!(windows) { "godot4.exe" } else { "godot4" }); + std::fs::write(&on_path, "").unwrap(); + assert_eq!(find_in(&search, std::slice::from_ref(&installed)), Some(on_path)); + assert_eq!(find_in(OsStr::new(""), &[tmp.path().join("missing")]), None); + } + + #[test] + fn custom_path_is_used_as_is() { + assert_eq!(find(Some("/opt/godot/bin/godot".into())), Some(PathBuf::from("/opt/godot/bin/godot"))); + } + + #[test] + fn install_locations_cover_the_platform() { + let locations = install_locations(Some(Path::new("/home/u"))); + assert!(!locations.is_empty()); + if cfg!(target_os = "macos") { + assert!(locations.contains(&PathBuf::from("/Applications/Godot.app/Contents/MacOS/Godot"))); + assert!(locations.contains(&PathBuf::from("/home/u/Applications/Godot.app/Contents/MacOS/Godot"))); + } + } +} diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 8660e9d5..e0a9df62 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -8,6 +8,7 @@ pub mod apply; mod discover; mod exec; +pub mod godot; pub mod java; pub mod libraries; pub mod locate; diff --git a/crates/ide-sdk/src/locate.rs b/crates/ide-sdk/src/locate.rs index 22717b59..0e54904b 100644 --- a/crates/ide-sdk/src/locate.rs +++ b/crates/ide-sdk/src/locate.rs @@ -89,6 +89,7 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: near.and_then(|n| local_bin(n, name)) .or_else(|| on_path(name, search)) .or_else(|| jdk_tool(name, &crate::java::installed_jdks())) + .or_else(|| (name == crate::godot::COMMAND).then(|| crate::godot::find(None)).flatten()) }) .flatten(); LocatedTool { name: name.clone(), path: path.map(Into::into), version: None } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 5bcc70e4..57b899f1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -29,6 +29,7 @@ fn server(name: &str) -> LanguageServerConfig { root_markers: vec![], initialization_options: None, install_hint: None, + connect: None, fallback: false, launch_error: None, } @@ -292,6 +293,36 @@ fn custom_tool_path_replaces_the_command() { assert_eq!(cfg.command, "zls"); } +#[test] +fn the_godot_setting_applies_to_server_debugger_and_run_configurations() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: "~/Godot/godot".into() }); + let expected = crate::home_dir().unwrap().join("Godot/godot"); + assert_eq!(crate::apply::tool_command(tmp.path(), &s, "godot"), Some(expected.clone())); + let mut cfg = LanguageServerConfig { command: "godot".into(), ..server("godot") }; + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(Path::new(&cfg.command), expected); + let mut run = RunConfig { + id: "godot:project".into(), + name: "Godot Project".into(), + kind: "godot".into(), + command: "godot".into(), + args: vec!["--path".into(), ".".into()], + cwd: None, + env: BTreeMap::new(), + detected: true, + is_test: false, + debug_adapter: Some("godot".into()), + allow_parallel: false, + extras: Default::default(), + }; + apply_to_run_configs(std::slice::from_mut(&mut run), tmp.path(), &s); + assert_eq!(Path::new(&run.command), expected); + // Other commands keep their name without a setting. + assert_eq!(crate::apply::tool_command(tmp.path(), &s, "zls"), None); +} + #[test] fn python_library_paths_are_parsed_and_deduplicated() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-search/src/lib.rs b/crates/ide-search/src/lib.rs index 8147272f..97515510 100644 --- a/crates/ide-search/src/lib.rs +++ b/crates/ide-search/src/lib.rs @@ -14,6 +14,7 @@ mod mask; mod pattern; mod query; mod replace; +mod words; pub use files::{ScopeFile, scope_files}; pub use mask::FileMask; @@ -22,6 +23,7 @@ pub use replace::{ ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceSummary, ReplaceTarget, apply_edits, preserve_case, preview, replace_files, replacement_edits, }; +pub use words::{WordFiles, WordQuery, files_with_word}; use grep_matcher::{Captures, Matcher}; use ide_core::{CancellationToken, IdeError, IdeResult}; diff --git a/crates/ide-search/src/words.rs b/crates/ide-search/src/words.rs new file mode 100644 index 00000000..ceb94a25 --- /dev/null +++ b/crates/ide-search/src/words.rs @@ -0,0 +1,118 @@ +//! Files that mention an identifier: the candidates a reference search checks. + +use crate::{Overlay, PathFilter, SearchContext, SearchQuery, SearchScope}; +use ide_core::{CancellationToken, IdeResult}; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Clone, Debug)] +pub struct WordQuery { + /// Matched as a whole word, case-sensitively. + pub word: String, + /// Directory searched (absolute, or relative to the search root). + pub dir: PathBuf, + /// File masks never searched (excluded roots, ignored names). + pub exclude: Vec, + /// Files returned at most. + pub limit: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WordFiles { + pub files: Vec, + /// More files matched than `limit`; the search stopped early. + pub limit_hit: bool, +} + +/// Files under `query.dir` containing `query.word` as a whole word, in walk +/// order (unsaved buffers through `overlay`, `.gitignore`d and hidden files +/// skipped like Find in Files). Stops at `query.limit` files or when cancelled. +pub fn files_with_word( + root: &std::path::Path, + query: &WordQuery, + filter: Option>, + overlay: Overlay<'_>, + cancel: &CancellationToken, +) -> IdeResult { + let search = SearchQuery { + pattern: query.word.clone(), + is_regex: false, + case_sensitive: true, + whole_word: true, + include: Vec::new(), + exclude: query.exclude.clone(), + scope: SearchScope::Directory { path: query.dir.clone().into() }, + max_matches: u32::MAX, + context: SearchContext::Any, + }; + let stop = cancel.child_token(); + let found = Mutex::new(WordFiles::default()); + crate::search_filtered(root, &search, filter, overlay, &stop, &|file| { + let mut found = found.lock().unwrap_or_else(|e| e.into_inner()); + if found.files.len() < query.limit { + found.files.push(file.path.into_path_buf()); + } else { + found.limit_hit = true; + stop.cancel(); + } + })?; + Ok(found.into_inner().unwrap_or_else(|e| e.into_inner())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn query(word: &str, limit: usize) -> WordQuery { + WordQuery { word: word.into(), dir: PathBuf::from("."), exclude: vec!["build/**".into()], limit } + } + + #[test] + fn whole_word_matches_in_kept_files_only() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("pkg/ignored")).unwrap(); + fs::create_dir_all(root.join("build")).unwrap(); + fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); + fs::write(root.join("pkg/use.py"), "x.get_value(1)\n").unwrap(); + fs::write(root.join("pkg/longer.py"), "get_values()\n").unwrap(); + fs::write(root.join("pkg/case.py"), "Get_Value()\n").unwrap(); + fs::write(root.join("pkg/notes.txt"), "get_value\n").unwrap(); + fs::write(root.join("pkg/ignored/gen.py"), "get_value\n").unwrap(); + fs::write(root.join("build/out.py"), "get_value\n").unwrap(); + fs::write(root.join("pkg/open.py"), "nothing on disk\n").unwrap(); + let unsaved = root.join("pkg/open.py"); + let overlay = |p: &std::path::Path| (p == unsaved).then(|| "get_value()".to_owned()); + let py = |rel: &str| rel.ends_with(".py"); + let mut found = + files_with_word(root, &query("get_value", 10), Some(&py), &overlay, &CancellationToken::new()).unwrap(); + found.files.sort(); + assert_eq!( + found, + WordFiles { files: vec![root.join("pkg/open.py"), root.join("pkg/use.py")], limit_hit: false } + ); + } + + #[test] + fn stops_at_the_limit() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..5 { + fs::write(dir.path().join(format!("f{i}.py")), "name\n").unwrap(); + } + let found = files_with_word(dir.path(), &query("name", 2), None, &|_| None, &CancellationToken::new()).unwrap(); + assert_eq!((found.files.len(), found.limit_hit), (2, true)); + let all = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &CancellationToken::new()).unwrap(); + assert_eq!((all.files.len(), all.limit_hit), (5, false)); + } + + #[test] + fn a_cancelled_search_finds_nothing() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a.py"), "name\n").unwrap(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let found = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &cancel).unwrap(); + assert!(found.files.is_empty()); + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 38b69931..bd0e52ba 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -285,6 +285,8 @@ pub struct Tools { platform: Platform, /// Executables chosen in settings, by command. custom: BTreeMap, + /// Executables found outside the search path (app bundles), by command. + found: BTreeMap, } impl Tools { @@ -298,7 +300,7 @@ impl Tools { let system: Vec = std::env::split_paths(&search_path).filter(|d| !ours.contains(d)).collect(); let system_path = std::env::join_paths(&system).unwrap_or_default(); let child_path = std::env::join_paths(ours.iter().chain(system.iter())).unwrap_or_default(); - Self { root, system_path, child_path, platform, custom: BTreeMap::new() } + Self { root, system_path, child_path, platform, custom: BTreeMap::new(), found: BTreeMap::new() } } /// Reports the executables chosen in settings (command → path) as the tools' location. @@ -307,6 +309,12 @@ impl Tools { self } + /// Reports executables located outside the search path (command → path) as on the PATH. + pub fn with_found_paths(mut self, found: BTreeMap) -> Self { + self.found = found; + self + } + pub fn root(&self) -> &Path { &self.root } @@ -430,7 +438,7 @@ impl Tools { let Some(spec) = spec else { let u = usage?; let command = u.command.clone()?; - let path = self.which(&command); + let path = self.which(&command).or_else(|| self.found.get(&command).cloned()); return Some(ToolInfo { name: name.to_string(), display_name: name.to_string(), diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 77af90a7..246f8d85 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -507,6 +507,11 @@ impl Repo { pub fn branch_info(&self) -> IdeResult { refs::branch_info(self) } + /// Current branch (short name; `None` when detached) and HEAD commit, without + /// upstream tracking (cheap enough to call for every repository on each refresh). + pub fn head(&self) -> IdeResult<(Option, Option)> { + refs::head(&self.gix()?) + } /// Merge / rebase / cherry-pick… in progress (cheap: no ref walking). pub fn operation(&self) -> IdeResult> { Ok(refs::operation(&self.gix()?)) @@ -821,6 +826,11 @@ impl Repo { pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { ops::diff_with_worktree(self, rev) } + /// `(repo-relative path, added lines, deleted lines)` of the tracked files whose + /// working-tree content differs from `rev`. + pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult> { + ops::numstat_with_worktree(self, rev) + } // ---- patches and shelf ---- diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 24f25b0b..3795d0ef 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -349,6 +349,26 @@ pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult IdeResult> { + check_commits(&[rev.to_string()])?; + let out = run(r.root(), &["diff", "--numstat", "-z", "--no-renames", "--no-ext-diff", rev, "--"], None)?; + Ok(parse_numstat(&out.stdout_str())) +} + +pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> { + text.split('\0') + .filter_map(|rec| { + let mut parts = rec.splitn(3, '\t'); + let added = parts.next()?.parse().unwrap_or(0); + let deleted = parts.next()?.parse().unwrap_or(0); + let path = parts.next().filter(|p| !p.is_empty())?; + Some((path.to_owned(), added, deleted)) + }) + .collect() +} + /// Files that differ between two revisions (Update Project's "N files updated"). pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult> { check_commits(&[from.to_string(), to.to_string()])?; @@ -402,4 +422,13 @@ mod tests { ] ); } + + #[test] + fn numstat_parsing() { + let got = parse_numstat("3\t1\tsrc/a.rs\0-\t-\timg.png\0\x30\t7\tdir/b c.txt\0"); + assert_eq!( + got, + vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)] + ); + } } diff --git a/crates/ide-vcs/src/refs.rs b/crates/ide-vcs/src/refs.rs index 5b790305..1be20430 100644 --- a/crates/ide-vcs/src/refs.rs +++ b/crates/ide-vcs/src/refs.rs @@ -30,6 +30,11 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult { Ok(info) } +pub(crate) fn head(repo: &gix::Repository) -> IdeResult<(Option, Option)> { + let name = repo.head_name().map_err(gx)?.map(|n| to_string(n.as_ref().shorten())); + Ok((name, repo.head_id().ok().map(|id| id.to_string()))) +} + pub(crate) fn operation(repo: &gix::Repository) -> Option { use gix::state::InProgress::*; let op = match repo.state()? { diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 64f8a64e..b202cd78 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -865,6 +865,7 @@ fn checkout_create_detached_delete_rebase() { repo.checkout("topic", true).unwrap(); assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic")); + assert_eq!(repo.head().unwrap(), (Some("topic".to_string()), Some(c1.clone()))); fx.write("t", "topic\n"); fx.commit_all("topic work"); repo.checkout("main", false).unwrap(); @@ -879,6 +880,7 @@ fn checkout_create_detached_delete_rebase() { let info = repo.branch_info().unwrap(); assert_eq!(info.head, None); assert_eq!(info.head_commit.as_deref(), Some(c1.as_str())); + assert_eq!(repo.head().unwrap(), (None, Some(c1.clone()))); assert!( repo.log(&LogQuery { branch: Some("HEAD".into()), ..Default::default() }).unwrap()[0] .refs diff --git a/docs/media/themes/one-dark-popup.png b/docs/media/themes/one-dark-popup.png new file mode 100644 index 00000000..a9775461 Binary files /dev/null and b/docs/media/themes/one-dark-popup.png differ diff --git a/docs/media/themes/one-dark-workbench.png b/docs/media/themes/one-dark-workbench.png new file mode 100644 index 00000000..0fff44ae Binary files /dev/null and b/docs/media/themes/one-dark-workbench.png differ diff --git a/docs/media/themes/one-light-popup.png b/docs/media/themes/one-light-popup.png new file mode 100644 index 00000000..c9cba35e Binary files /dev/null and b/docs/media/themes/one-light-popup.png differ diff --git a/docs/media/themes/one-light-workbench.png b/docs/media/themes/one-light-workbench.png new file mode 100644 index 00000000..ee908575 Binary files /dev/null and b/docs/media/themes/one-light-workbench.png differ diff --git a/docs/media/themes/solarized-dark-popup.png b/docs/media/themes/solarized-dark-popup.png new file mode 100644 index 00000000..6ff01a14 Binary files /dev/null and b/docs/media/themes/solarized-dark-popup.png differ diff --git a/docs/media/themes/solarized-dark-workbench.png b/docs/media/themes/solarized-dark-workbench.png new file mode 100644 index 00000000..e702358d Binary files /dev/null and b/docs/media/themes/solarized-dark-workbench.png differ diff --git a/docs/media/themes/solarized-light-popup.png b/docs/media/themes/solarized-light-popup.png new file mode 100644 index 00000000..e931171e Binary files /dev/null and b/docs/media/themes/solarized-light-popup.png differ diff --git a/docs/media/themes/solarized-light-workbench.png b/docs/media/themes/solarized-light-workbench.png new file mode 100644 index 00000000..8cdae4c6 Binary files /dev/null and b/docs/media/themes/solarized-light-workbench.png differ diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index ea7caa48..33e03ca2 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -1,7 +1,8 @@ # Theme plugins -OpenIDEA ships three built-in themes (Islands Light, Islands Dark, High -Contrast). Everything else comes from **theme plugins**, the same model as +OpenIDEA ships seven [built-in themes](../themes.md): Islands Light, Islands Dark, +High Contrast, Solarized Light, Solarized Dark, One Light and One Dark. Additional +themes come from **theme plugins**, the same model as IntelliJ IDEA, where most themes are plugins rather than part of the IDE. A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 00000000..55b0531b --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,100 @@ +# Built-in theme sources + +OpenIDEA includes Islands Light, Islands Dark, High Contrast, Solarized Light, +Solarized Dark, One Light and One Dark. Select them in Settings › Appearance, +Welcome › Customize or the existing quick theme menu. The same themes are +available as explicit editor schemes; “Theme default” follows the UI theme. +“Sync with OS” uses the existing preferred light/dark theme settings. Existing +ids (`light`, `dark`, `dark-hc`, `system`) and attribute overrides remain valid. + +## Upstream palettes and licenses + +All sources below are the original authors’ repositories, pinned for reproducibility. +Full MIT notices are preserved in [THIRD_PARTY_LICENSES.md](../THIRD_PARTY_LICENSES.md). + +| Theme | Original source revision | Palette / mapping source | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Solarized Light / Dark | [Ethan Schoonover, 62f656a](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3) | README’s sixteen exact sRGB hex colours; `vim-colors-solarized/colors/solarized.vim` semantic roles; iTerm2 ANSI slots | +| One Light syntax | [GitHub / Atom, d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438) | `styles/colors.less`, `styles/syntax-variables.less` | +| One Dark syntax | [GitHub / Atom, 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1) | `styles/colors.less`, `styles/syntax-variables.less` | +| One Light UI | [GitHub / Atom, a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` | +| One Dark UI | [GitHub / Atom, 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` | + +## Mapping into OpenIDEA + +`src/platform/themes.ts` registers the stable ids. `data-theme` retains the +light/dark kind for icons, native controls and existing selectors; +`data-color-theme` selects the additional palette. High Contrast continues to +use `data-contrast="high"`. Plugin themes clear the built-in palette attribute +and retain their existing light/dark fallback; removing a plugin restores the +chosen built-in palette. + +`src/styles/tokens.css` contains the shared mapping for all colour tokens and +four palette blocks. No new dependency is required. Solarized uses base3/base2 +for light backgrounds and base03/base02 for dark backgrounds, base00/base0 for +text, and the original eight accents. Vim’s statements are green, constants +and strings cyan, identifiers blue, types yellow, and comments the original +subdued base1/base01. Solarized’s original ANSI slot order is preserved (the +bright slots intentionally include orange, violet and neutrals). + +One retains the exact HSL definitions from Atom, avoiding rounded palette +approximations: 230° / 1% / 98% light and 220° / 13% / 18% dark editor backgrounds; +purple keywords, green strings, blue functions, red variables/tags, orange +numbers and attributes, yellow classes, and the original mono-3 comments. +UI frame/popup levels use Atom’s relative background lightness formulas with +the matching syntax theme. OpenIDEA’s shared UI text uses the syntax foreground +and mono-2 for readable secondary labels. Selection and cursor-line values +follow Atom’s syntax variables. Atom does not define a terminal ANSI palette: +One’s ANSI slots reuse its exact semantic syntax hues, repeating them for bright +colours rather than inventing a different palette. One Light maps ANSI white +and bright white to its mono-2 and mono-1 text colours so they remain visible +on the light terminal background. + +Components without an upstream equivalent (feedback banners, tool window states, +search fills and focus decoration) use the original semantic hues, neutrals, +or explicit `color-mix()` tints. This adapts colours to the existing layout; +it does not recreate Atom’s or Vim’s UI. Platform traffic-light colours stay +platform colours. Original subdued comments and some syntax accents have lower +contrast than a high-contrast accessibility theme; High Contrast remains available. + +Native windows open in the theme's frame colour before their page loads: +`src-tauri/src/theme_frame.rs` reads `--bg-frame` of each built-in theme from +`tokens.css` itself (resolving `var()` and `hsl()`), so there is no second +colour table to keep in sync. Elsewhere the frontend resolves HSL and +`color-mix()` tokens to RGB (`src/platform/cssColor.ts`) for Tauri's window +API, the native Settings window, Mermaid and the terminal. + +`public/boot.js` restores the palette (`data-color-theme`) and the splash's +resolved frame, text and accent colours before the application stylesheet +loads, so a restart in One Dark or Solarized shows no flash of another theme. +The backend's settings persistence remains authoritative. Terminal, Mermaid +and editor-scheme observers also watch palette changes within the same +light/dark kind. Custom accents, explicit editor schemes, per-scheme attribute +overrides, compact mode and accessibility options retain their precedence. + +`src/features/settings/__tests__/contrast.test.ts` requires every palette theme +to define every colour token of the base themes (in the shared mapping or its +own block), so a token added to `tokens.css` later can't silently fall back to +an Islands colour. + +## Browser evidence + +These approved screenshots are reused from the original theme implementation on +0.1.15; regression checks are rerun against the upstream contribution base. +Captured at 1400 × 900 in Linux Chromium 154 using OpenIDEA's public in-memory +`acme-api` project / Kotlin fixture and synthetic ANSI terminal output. The +terminal is a real xterm renderer, without a native PTY or shell. These images +cover the shell, sidebar, editor, gutter, find matches, terminal, Settings, +focused controls, disabled settings and the selected theme popup. + +| Theme | Workbench, editor and terminal | Settings and theme popup | +|---|---|---| +| Solarized Light | [Screenshot](media/themes/solarized-light-workbench.png) | [Screenshot](media/themes/solarized-light-popup.png) | +| Solarized Dark | [Screenshot](media/themes/solarized-dark-workbench.png) | [Screenshot](media/themes/solarized-dark-popup.png) | +| One Light | [Screenshot](media/themes/one-light-workbench.png) | [Screenshot](media/themes/one-light-popup.png) | +| One Dark | [Screenshot](media/themes/one-dark-workbench.png) | [Screenshot](media/themes/one-dark-popup.png) | + +The browser regression also checks refresh / startup cache restoration, +selection visibility, same-kind switches, the quick menu, existing themes, +explicit editor schemes and preferred OS themes. Native startup colours are +covered by Rust unit tests. diff --git a/e2e/themes.spec.ts b/e2e/themes.spec.ts new file mode 100644 index 00000000..74ee13e4 --- /dev/null +++ b/e2e/themes.spec.ts @@ -0,0 +1,188 @@ +import { expect, openFromTree, test } from "./fixtures"; +import type { Page } from "@playwright/test"; + +test.setTimeout(60_000); + +const palettes = [ + { + id: "solarized-light", + label: "Solarized Light", + kind: "light", + bg: "rgb(253, 246, 227)", + fg: "rgb(101, 123, 131)", + frame: [238, 232, 213, 255], + }, + { + id: "solarized-dark", + label: "Solarized Dark", + kind: "dark", + bg: "rgb(0, 43, 54)", + fg: "rgb(131, 148, 150)", + frame: [7, 54, 66, 255], + }, + { + id: "one-light", + label: "One Light", + kind: "light", + bg: "rgb(250, 250, 250)", + fg: "rgb(56, 58, 66)", + frame: [234, 234, 235, 255], + }, + { + id: "one-dark", + label: "One Dark", + kind: "dark", + bg: "rgb(40, 44, 52)", + fg: "rgb(171, 178, 191)", + frame: [33, 37, 43, 255], + }, +]; + +async function choose(page: Page, control: string, label: string) { + await page.getByRole("button", { name: control, exact: true }).click(); + await page.getByRole("option", { name: label, exact: true }).click(); +} +async function settings(page: Page) { + await page.keyboard.press("Meta+,"); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await expect(dialog).toBeVisible(); + await dialog + .locator('[role="treeitem"][aria-level="2"]') + .filter({ hasText: /^Appearance$/ }) + .click(); + return dialog; +} + +for (const palette of palettes) { + test(`${palette.label}: settings, editor, terminal, popup and reload`, async ({ workbench: page }, info) => { + const editor = await openFromTree(page, "build.gradle.kts"); + await page.keyboard.press("Alt+F12"); + await expect(page.locator(".xterm")).toBeVisible(); + // Use only synthetic ANSI output; no shell, network or native PTY is involved. + await page.evaluate(async () => { + const path = "/src/features/terminal/store.ts"; + const { activeSession } = await import(/* @vite-ignore */ path); + const session = activeSession(); + session.term.clear(); + session.term.writeln("Synthetic theme regression — no PTY"); + session.term.writeln("\x1b[31merror\x1b[0m \x1b[33mwarning\x1b[0m \x1b[32msuccess\x1b[0m \x1b[34minfo\x1b[0m"); + }); + const dialog = await settings(page); + await choose(page, "Theme", palette.label); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id); + await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind); + expect( + await page.evaluate(async () => { + const path = "/src/workbench/theme.ts"; + const { windowBackgroundColor, windowBackgroundHex } = await import(/* @vite-ignore */ path); + return { rgba: windowBackgroundColor(), hex: windowBackgroundHex() }; + }), + ).toEqual({ + rgba: palette.frame, + hex: + "#" + + palette.frame + .slice(0, 3) + .map((n) => n.toString(16).padStart(2, "0")) + .join(""), + }); + await page.screenshot({ path: info.outputPath(`${palette.id}-settings.png`), animations: "disabled" }); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCSS("background-color", palette.bg); + await expect(editor).toHaveCSS("color", palette.fg); + await expect(editor.locator(".cm-gutters")).toHaveCSS("background-color", palette.bg); + await expect + .poll(() => + page.evaluate(async () => { + const path = "/src/features/terminal/store.ts"; + const { activeSession } = await import(/* @vite-ignore */ path); + return activeSession().term.options.theme.background; + }), + ) + .toBe( + palette.id === "solarized-light" + ? "#fdf6e3" + : palette.id === "solarized-dark" + ? "#002b36" + : palette.id === "one-light" + ? "#fafafa" + : "#282c34", + ); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+a"); + await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Meta+f"); + await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order"); + await expect(editor.locator(".cm-openidea-findMatch").first()).toBeVisible(); + await page.screenshot({ path: info.outputPath(`${palette.id}-workbench.png`), animations: "disabled" }); + await page.keyboard.press("Escape"); + await settings(page); + await page.getByRole("button", { name: "Theme", exact: true }).click(); + await expect(page.getByRole("listbox", { name: "Theme", exact: true })).toBeVisible(); + await page.screenshot({ path: info.outputPath(`${palette.id}-popup.png`), animations: "disabled" }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await page.reload(); + await expect(page.getByRole("tree", { name: "Project" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id); + const cached = await page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme")!)); + expect(cached).toMatchObject({ theme: palette.kind, colorTheme: palette.id }); + }); +} + +test("same-kind switching, legacy themes, system mode and explicit editor schemes", async ({ workbench: page }) => { + await openFromTree(page, "build.gradle.kts"); + const dialog = await settings(page); + for (const label of [ + "Solarized Light", + "One Light", + "Solarized Dark", + "One Dark", + "Islands Light", + "Islands Dark", + "High Contrast", + ]) { + await choose(page, "Theme", label); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + if (label === "High Contrast") await expect(page.locator("html")).toHaveAttribute("data-contrast", "high"); + else await expect(page.locator("html")).not.toHaveAttribute("data-contrast"); + const added = palettes.find((p) => p.label === label); + if (added) await expect(page.locator("html")).toHaveAttribute("data-color-theme", added.id); + else await expect(page.locator("html")).not.toHaveAttribute("data-color-theme"); + const bg = + added?.bg ?? + (label === "Islands Light" + ? "rgb(255, 255, 255)" + : label === "Islands Dark" + ? "rgb(25, 26, 28)" + : "rgb(0, 0, 0)"); + await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", bg); + } + await choose(page, "Theme", "One Light"); + await choose(page, "Editor color scheme", "Solarized Dark"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", "rgb(0, 43, 54)"); + await choose(page, "Editor color scheme", "Theme default"); + await dialog.getByLabel("Sync with OS", { exact: true }).check(); + await dialog.getByRole("button", { name: "Preferred themes", exact: true }).click(); + await choose(page, "Preferred light theme", "Solarized Light"); + await choose(page, "Preferred dark theme", "One Dark"); + await page.keyboard.press("Escape"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await page.emulateMedia({ colorScheme: "light" }); + await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(page.locator("html")).toHaveAttribute("data-color-theme", "one-dark"); +}); + +test("quick theme menu offers all four palettes and applies their kind", async ({ workbench: page }) => { + for (const palette of palettes) { + await page.getByRole("button", { name: "IDE and Project Settings", exact: true }).click(); + await page.getByText("Theme", { exact: true }).click(); + await page.getByText(palette.label, { exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id); + await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind); + } +}); diff --git a/index.html b/index.html index 4ccd1bda..43471baa 100644 --- a/index.html +++ b/index.html @@ -10,12 +10,15 @@
- OpenIDEA + + + +
diff --git a/languages/gdscript.toml b/languages/gdscript.toml new file mode 100644 index 00000000..be6292be --- /dev/null +++ b/languages/gdscript.toml @@ -0,0 +1,26 @@ +id = "gdscript" +name = "GDScript" +extensions = ["gd"] +line-comment = "#" +# Godot's default (Editor Settings › Text Editor › Behavior › Indent › Type). +indent = { tab-size = 4, insert-spaces = false } +tree-sitter = "gdscript" + +# The language server runs inside the Godot editor and speaks LSP over TCP only: +# connect to an open editor, else start a headless editor for the project on a free port. +[[language-servers]] +name = "godot" +command = "godot" +args = ["--headless", "--editor", "--path", "${root}", "--lsp-port", "${port}"] +connect = "127.0.0.1:6005" +root-markers = ["project.godot"] +install-hint = "Open the project in the Godot editor, or install Godot 4 and set the Godot executable in Settings › Languages & Frameworks › Language Servers › godot" + +# Godot's debug adapter also lives in the editor (DAP over TCP, port 6006). +[debugger] +adapter = "godot" +command = "godot" +args = ["--headless", "--editor", "--path", "${root}", "--dap-port", "${port}"] +transport = "tcp" +connect = "127.0.0.1:6006" +install-hint = "Open the project in the Godot editor, or install Godot 4 and set the Godot executable in Settings › Languages & Frameworks › Language Servers › godot" diff --git a/languages/gdshader.toml b/languages/gdshader.toml new file mode 100644 index 00000000..2595ecbf --- /dev/null +++ b/languages/gdshader.toml @@ -0,0 +1,8 @@ +id = "gdshader" +name = "Godot Shader" +extensions = ["gdshader", "gdshaderinc"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = false } +# GLSL-like: the C mode colours keywords, types, numbers and comments. +ui-mode = "C" diff --git a/languages/godot-resource.toml b/languages/godot-resource.toml new file mode 100644 index 00000000..c9e9a250 --- /dev/null +++ b/languages/godot-resource.toml @@ -0,0 +1,8 @@ +id = "godot-resource" +name = "Godot Resource" +# Text scenes / resources, `project.godot`, import metadata and export presets (INI-like). +extensions = ["tscn", "tres", "godot", "import"] +filenames = ["export_presets.cfg", "override.cfg", "extension_list.cfg"] +line-comment = ";" +indent = { tab-size = 4, insert-spaces = false } +ui-mode = "Properties files" diff --git a/package.json b/package.json index 72fad620..d0fccf50 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.15", + "version": "0.1.17", "license": "Apache-2.0", "type": "module", "scripts": { @@ -52,6 +52,7 @@ "emmet": "2.4.11", "immer": "^11.1.21", "marked": "^18.1.1", + "mermaid": "12.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-resizable-panels": "^4.14.6", diff --git a/plugins/README.md b/plugins/README.md index 101f802a..530f116d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -11,7 +11,6 @@ code at all. plugins/ ├─ build.sh # builds Rust plugins into components ├─ hello/ # Rust plugin: two commands -├─ theme-solarized-dark/ # manifest-only: a theme └─ language-toy/ # manifest-only: a language definition ``` @@ -170,10 +169,10 @@ never affected. ```json { - "id": "solarized-dark", - "name": "Solarized Dark", + "id": "harbor-night", + "name": "Harbor Night", "kind": "dark", - "tokens": { "--bg-app": "#002b36", "--accent": "#268bd2", "--syn-keyword": "#859900" } + "tokens": { "--bg-app": "#14171c", "--accent": "#2b9e93", "--syn-keyword": "#e0a46e" } } ``` diff --git a/plugins/theme-solarized-dark/plugin.toml b/plugins/theme-solarized-dark/plugin.toml deleted file mode 100644 index f37fc649..00000000 --- a/plugins/theme-solarized-dark/plugin.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Contribution-only plugin: no code, just a theme file. -id = "theme-solarized-dark" -name = "Solarized Dark Theme" -version = "1.0.0" -description = "Ethan Schoonover's Solarized palette, dark variant." -authors = ["OpenIDEA contributors"] -change-notes = "1.0.0 — Solarized Dark UI and syntax colours." -openidea-api = "0.1" - -[[contributes.themes]] -path = "themes/solarized-dark.json" diff --git a/plugins/theme-solarized-dark/themes/solarized-dark.json b/plugins/theme-solarized-dark/themes/solarized-dark.json deleted file mode 100644 index 87e56a2b..00000000 --- a/plugins/theme-solarized-dark/themes/solarized-dark.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "id": "solarized-dark", - "name": "Solarized Dark", - "kind": "dark", - "tokens": { - "--bg-app": "#002b36", - "--bg-elev": "#03313c", - "--bg-popup": "#073642", - "--bg-hover": "#0a3d4a", - "--bg-selected": "#0d4554", - "--bg-selected-strong": "#104d5d", - "--bg-current-line": "#04323d", - "--bg-debug-line": "#0b3a50", - "--bg-backdrop": "#00212a", - "--bg-tooltip": "#073642", - "--bg-control": "#073642", - "--bg-control-hover": "#0a3d4a", - "--bg-active": "#104d5d", - "--border": "#06323d", - "--border-popup": "#0a3d4a", - "--border-strong": "#124b59", - "--border-window": "#0a3d4a", - "--control-off": "#2c5662", - "--fg-strong": "#eee8d5", - "--fg-emphasis": "#d9d4c1", - "--fg": "#93a1a1", - "--fg-secondary": "#839496", - "--fg-muted": "#839496", - "--fg-subtle": "#657b83", - "--fg-faint": "#586e75", - "--fg-gutter": "#4c636b", - "--fg-tooltip": "#eee8d5", - "--fg-on-accent": "#002b36", - "--accent": "#268bd2", - "--link": "#268bd2", - "--link-hover": "#5fa9e0", - "--danger": "#dc322f", - "--warning": "#b58900", - "--success": "#859900", - "--info": "#268bd2", - "--vcs-modified": "#b58900", - "--vcs-added": "#859900", - "--vcs-deleted": "#657b83", - "--vcs-untracked": "#cb4b16", - "--vcs-ignored": "#586e75", - "--vcs-conflict": "#d33682", - "--gutter-modified": "#268bd2", - "--gutter-added": "#859900", - "--gutter-deleted": "#dc322f", - "--syn-keyword": "#859900", - "--syn-string": "#2aa198", - "--syn-type": "#b58900", - "--syn-function": "#268bd2", - "--syn-comment": "#586e75", - "--syn-annotation": "#6c71c4", - "--syn-number": "#d33682", - "--syn-punct": "#839496", - "--syn-variable": "#93a1a1", - "--syn-property": "#93a1a1", - "--syn-constant": "#cb4b16", - "--syn-invalid": "#dc322f", - "--syn-inline-value": "#657b83" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06ab3552..1c51fd36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: marked: specifier: ^18.1.1 version: 18.1.1 + mermaid: + specifier: 12.1.0 + version: 12.1.0 react: specifier: ^19.1.0 version: 19.3.1 @@ -168,6 +171,9 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@asamuzakjp/css-color@7.1.7': resolution: {integrity: sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -247,6 +253,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.3': resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} hasBin: true @@ -257,6 +266,21 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@chevrotain/cst-dts-gen@13.2.0': + resolution: {integrity: sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==} + + '@chevrotain/gast@13.2.0': + resolution: {integrity: sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==} + + '@chevrotain/regexp-to-ast@13.2.0': + resolution: {integrity: sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==} + + '@chevrotain/types@13.2.0': + resolution: {integrity: sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==} + + '@chevrotain/utils@13.2.0': + resolution: {integrity: sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==} + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -487,6 +511,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -566,6 +596,10 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@mermaid-js/parser@2.0.1': + resolution: {integrity: sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==} + engines: {node: '>=22.12.0'} + '@oxc-project/types@0.154.0': resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} @@ -820,6 +854,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -833,6 +960,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -909,6 +1039,9 @@ packages: resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-react@6.1.2': resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1014,6 +1147,10 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chevrotain@13.2.0: + resolution: {integrity: sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==} + engines: {node: '>=22.0.0'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1021,9 +1158,23 @@ packages: codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} @@ -1041,10 +1192,169 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.4: + resolution: {integrity: sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@8.0.0: resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} engines: {node: ^22.14.0 || >=24.0.0} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1060,6 +1370,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1080,6 +1393,9 @@ packages: electron-to-chromium@1.5.459: resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} @@ -1090,6 +1406,9 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-toolkit@1.53.0: + resolution: {integrity: sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1197,6 +1516,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + hashery@1.5.1: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} @@ -1217,6 +1539,10 @@ packages: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1228,6 +1554,9 @@ packages: immer@11.1.21: resolution: {integrity: sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1236,6 +1565,13 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1278,9 +1614,22 @@ packages: engines: {node: '>=6'} hasBin: true + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1363,6 +1712,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lru-cache@11.5.3: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} @@ -1377,6 +1729,11 @@ packages: magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marked@18.1.1: resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==} engines: {node: '>= 20'} @@ -1385,6 +1742,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mermaid@12.1.0: + resolution: {integrity: sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==} + engines: {node: '>=22.12.0'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1424,9 +1785,15 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1452,6 +1819,12 @@ packages: engines: {node: '>=20'} hasBin: true + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.31: resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} @@ -1503,11 +1876,23 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.2.14: resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1546,6 +1931,9 @@ packages: style-mod@4.1.4: resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + tinybench@6.2.2: resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} engines: {node: '>=20.0.0'} @@ -1579,6 +1967,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1611,6 +2003,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} + hasBin: true + vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1773,6 +2169,11 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 + '@asamuzakjp/css-color@7.1.7': dependencies: '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) @@ -1890,6 +2291,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.3': dependencies: css-tree: 3.2.1 @@ -1906,6 +2309,21 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@chevrotain/cst-dts-gen@13.2.0': + dependencies: + '@chevrotain/gast': 13.2.0 + '@chevrotain/types': 13.2.0 + + '@chevrotain/gast@13.2.0': + dependencies: + '@chevrotain/types': 13.2.0 + + '@chevrotain/regexp-to-ast@13.2.0': {} + + '@chevrotain/types@13.2.0': {} + + '@chevrotain/utils@13.2.0': {} + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.13.1 @@ -2275,6 +2693,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2397,6 +2823,10 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} + '@mermaid-js/parser@2.0.1': + dependencies: + '@chevrotain/types': 13.2.0 + '@oxc-project/types@0.154.0': {} '@playwright/test@1.64.1': @@ -2570,6 +3000,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + '@types/deep-eql@4.0.2': {} '@types/dompurify@3.2.0': @@ -2580,6 +3127,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/node@26.6.5': @@ -2688,6 +3237,11 @@ snapshots: '@typescript-eslint/types': 8.71.2 eslint-visitor-keys: 5.0.1 + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -2769,6 +3323,14 @@ snapshots: chai@6.3.0: {} + chevrotain@13.2.0: + dependencies: + '@chevrotain/cst-dts-gen': 13.2.0 + '@chevrotain/gast': 13.2.0 + '@chevrotain/regexp-to-ast': 13.2.0 + '@chevrotain/types': 13.2.0 + '@chevrotain/utils': 13.2.0 + clsx@2.1.1: {} codemirror@6.0.2: @@ -2781,8 +3343,20 @@ snapshots: '@codemirror/state': 6.7.6 '@codemirror/view': 6.43.14 + commander@7.2.0: {} + + commander@8.3.0: {} + convert-source-map@2.0.0: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crelt@1.0.7: {} cross-spawn@7.0.6: @@ -2800,6 +3374,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.4): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.4 + + cytoscape-fcose@2.2.0(cytoscape@3.34.4): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.4 + + cytoscape@3.34.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@8.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -2807,6 +3565,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + dayjs@1.11.23: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2815,6 +3575,10 @@ snapshots: deep-is@0.1.4: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -2829,6 +3593,8 @@ snapshots: electron-to-chromium@1.5.459: {} + elkjs@0.9.3: {} + emmet@2.4.11: dependencies: '@emmetio/abbreviation': 2.3.3 @@ -2838,6 +3604,8 @@ snapshots: es-module-lexer@2.3.2: {} + es-toolkit@1.53.0: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -2959,6 +3727,8 @@ snapshots: dependencies: is-glob: 4.0.3 + hachure-fill@0.5.2: {} + hashery@1.5.1: dependencies: hookified: 1.15.1 @@ -2979,16 +3749,26 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.12: {} immer@11.1.21: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3034,10 +3814,20 @@ snapshots: json5@2.2.3: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3096,6 +3886,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lru-cache@11.5.3: {} lru-cache@5.1.1: @@ -3108,10 +3900,38 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + marked@16.4.2: {} + marked@18.1.1: {} mdn-data@2.27.1: {} + mermaid@12.1.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 2.0.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + chevrotain: 13.2.0 + cytoscape: 3.34.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.4) + cytoscape-fcose: 2.2.0(cytoscape@3.34.4) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.17 + elkjs: 0.9.3 + es-toolkit: 1.53.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.3 + min-indent@1.0.1: {} minimatch@10.2.6: @@ -3145,10 +3965,14 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.9.0: {} + parse5@8.0.1: dependencies: entities: 8.1.0 + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3163,6 +3987,13 @@ snapshots: dependencies: playwright-core: 1.64.1 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.31: dependencies: nanoid: 3.3.20 @@ -3206,6 +4037,8 @@ snapshots: require-from-string@2.0.2: {} + robust-predicates@3.0.3: {} + rolldown@1.2.14: dependencies: '@oxc-project/types': 0.154.0 @@ -3227,6 +4060,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.14 '@rolldown/binding-win32-x64-msvc': 1.2.14 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -3253,6 +4097,8 @@ snapshots: style-mod@4.1.4: {} + stylis@4.4.0: {} + tinybench@6.2.2: {} tinyexec@1.3.1: {} @@ -3280,6 +4126,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-dedent@2.3.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3311,6 +4159,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@14.0.3: {} + vite@8.3.4(@types/node@26.6.5): dependencies: lightningcss: 1.33.0 diff --git a/public/boot.js b/public/boot.js index 60076f18..6f81c4e8 100644 --- a/public/boot.js +++ b/public/boot.js @@ -3,9 +3,36 @@ // so a new window starts in it instead of flashing the light default. // An external file because the CSP forbids inline scripts. try { + const root = document.documentElement; + const addStyle = (id, css) => { + const style = document.createElement("style"); + style.id = id; + style.textContent = css; + document.head.appendChild(style); + }; const last = JSON.parse(localStorage.getItem("openidea.theme") ?? "null"); - if (last?.theme) document.documentElement.dataset.theme = last.theme; - if (last?.contrast) document.documentElement.dataset.contrast = last.contrast; + if (last?.theme) root.dataset.theme = last.theme; + if (last?.contrast) root.dataset.contrast = last.contrast; + // A built-in palette theme (Solarized, One) over its light / dark base (tokens.css). + if (typeof last?.colorTheme === "string" && /^[a-z][a-z0-9-]*$/.test(last.colorTheme)) + root.dataset.colorTheme = last.colorTheme; + // A plugin theme's stylesheet (plugins/bridge.ts), so the workbench doesn't + // show the built-in theme until the plugin host answers. + const plugin = JSON.parse(localStorage.getItem("openidea.pluginTheme") ?? "null"); + if (typeof plugin?.id === "string" && typeof plugin.css === "string" && !/[<\\]|url\(/i.test(plugin.css)) { + addStyle("openidea-plugin-theme", plugin.css); + root.dataset.pluginTheme = plugin.id; + root.dataset.pluginThemeKind = root.dataset.theme = plugin.kind === "light" ? "light" : "dark"; + delete root.dataset.contrast; + delete root.dataset.colorTheme; + } + // The splash's frame, text and accent colours, resolved by theme.ts (splash.css). + const colors = [ + ["--boot-frame", last?.frame], + ["--boot-fg", last?.fg], + ["--boot-accent", last?.accent], + ].filter(([, v]) => typeof v === "string" && /^#[0-9a-f]{3,8}$/i.test(v)); + if (colors.length) addStyle("openidea-boot-colors", `:root { ${colors.map(([k, v]) => `${k}: ${v};`).join(" ")} }`); } catch { /* storage unavailable */ } diff --git a/public/splash.css b/public/splash.css index 0a8e561e..3b311174 100644 --- a/public/splash.css +++ b/public/splash.css @@ -1,11 +1,14 @@ /* Linked, not inlined in index.html: release builds hash inline