Merge remote-tracking branch 'origin/main' into feat/remote-development

# Conflicts:
#	src-tauri/src/commands/build.rs
#	src-tauri/src/commands/debug.rs
#	src-tauri/src/commands/lsp.rs
#	src-tauri/src/commands/run.rs
#	src-tauri/src/commands/tools.rs
#	src-tauri/src/commands/vcs.rs
#	src-tauri/src/state.rs
#	src/bindings.ts
#	src/features/database/SourceDialog.tsx
#	src/features/index.ts
#	src/platform/locales/text/zh.json
#	src/styles/tokens.css
This commit is contained in:
l0ng-ai
2026-10-27 19:33:02 +08:00
381 changed files with 18610 additions and 1111 deletions
Generated
+63 -32
View File
@@ -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"
+2 -1
View File
@@ -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" }
+83
View File
@@ -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.
```
+22
View File
@@ -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
+224
View File
@@ -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<AgentConfigOption> {
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<AgentConfigOption> {
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<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.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<AgentCommand> {
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<AgentUsage> {
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<AgentConfigValue> {
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<String> {
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::<Vec<_>>(), ["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());
}
}
File diff suppressed because it is too large Load Diff
+285
View File
@@ -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<String>,
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<String>,
/// Branch (or revision) the worktree was created from.
pub base: Option<String>,
/// Commit the task's changes are compared against.
pub base_commit: Option<String>,
/// Why the task failed, when it did.
pub error: Option<String>,
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<AgentConfigOption>,
/// Slash commands the agent offers.
pub commands: Vec<AgentCommand>,
/// Context window use, when the agent reports it.
pub usage: Option<AgentUsage>,
/// Messages waiting for the current turn to end, oldest first.
pub queue: Vec<AgentQueued>,
}
/// 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<String>,
}
/// 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<String>,
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
pub category: Option<String>,
pub current: Option<String>,
pub values: Vec<AgentConfigValue>,
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<String>,
}
#[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<f64>,
pub currency: Option<String>,
}
#[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<String>,
}
/// 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<IpcPath>,
pub images: Vec<AgentImage>,
}
#[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<String>,
},
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<IpcPath>,
/// Text the tool reported (command output…), truncated.
output: Option<String>,
diffs: Vec<AgentToolDiff>,
},
Plan {
items: Vec<AgentPlanItem>,
},
Permission {
id: u32,
title: String,
options: Vec<AgentPermissionOption>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
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<AgentConfigOption>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
}
#[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<AgentConfigChoice>,
pub isolation: Isolation,
/// Branch to start the worktree from; the current branch when `None`.
pub base: Option<String>,
}
/// 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,
},
}
+158
View File
@@ -0,0 +1,158 @@
//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined
//! agents from `<config>/agents/<id>.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<String>,
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<ProviderSpec> {
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<String>,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
/// 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<ProviderSpec> {
let mut specs = builtins();
let mut files: Vec<PathBuf> = 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::<CustomAgent>(&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"));
}
}
+423
View File
@@ -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<Peer>,
calls: Mutex<Vec<String>>,
}
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<Value, (i64, String)> {
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<FakeAgent>) -> 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::<AgentEvent>::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<String> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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<String> = 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();
}
+304
View File
@@ -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<AgentEntry>,
tools: HashMap<String, usize>,
plan: Option<usize>,
}
/// 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<usize>,
}
impl Timeline {
pub fn from_entries(entries: Vec<AgentEntry>) -> 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<usize> {
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<String> {
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<AgentToolStatus> {
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<IpcPath> {
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);
}
}
+156
View File
@@ -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> {
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/<slug> <dir>/<slug> <base>`.
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
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<Vec<AgentChange>> {
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<AgentChange> = 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<Option<String>> {
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());
}
}
+19 -2
View File
@@ -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<Dependency
let modules: std::collections::HashSet<String> = 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()
+141 -10
View File
@@ -130,6 +130,37 @@ pub struct MavenDependency {
pub optional: bool,
/// Transitive dependencies (only after a sync).
pub children: Vec<MavenDependency>,
/// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`).
#[serde(default)]
#[specta(optional)]
pub omitted: Option<Omission>,
/// The version that won when `omitted` is `Conflict`.
#[serde(default)]
#[specta(optional)]
pub conflict_with: Option<String>,
/// The version requested before `<dependencyManagement>` replaced it.
#[serde(default)]
#[specta(optional)]
pub managed_from: Option<String>,
}
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<PathBuf>) -> 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<MavenPlugin>, Vec<Mav
classifier: d.classifier.clone(),
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
(format!("{group}:{}", raw.artifact_id), plugins, deps)
@@ -976,13 +1007,16 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
.collect()
}
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line.
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line, with the
/// `-Dverbose` annotations: `(g:a:jar:1:compile - omitted for conflict with 2)`,
/// `g:a:jar:1:compile (version managed from 0.9)`, `(optional)`.
fn parse_coords(s: &str) -> Option<MavenDependency> {
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<MavenDependency> {
[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<MavenDependency>, 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<Omission>, 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}");
}
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct
//! dependency is declared and adding an `<exclusion>` 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<String>, artifact_id: impl Into<String>) -> 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<String, String> {
let mut props: HashMap<String, String> = 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<String, String>) -> 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 `<dependency>` declaring `key` in the project's `<dependencies>`, else in a
/// profile's (never `<dependencyManagement>`, 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('&', "&amp;").replace('<', "&lt;")
}
/// Range of the `<artifactId>` of the dependency declaring `key` in `text` (a POM), or of
/// the whole `<dependency>` when it has none; `None` when the POM does not declare it.
pub fn declaration(text: &str, key: &ArtifactKey) -> Option<Range> {
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 `<exclusions>` 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<Exclusion> {
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, "<exclusion>".to_owned()),
(depth + 1, format!("<groupId>{g}</groupId>")),
(depth + 1, format!("<artifactId>{a}</artifactId>")),
(depth, "</exclusion>".to_owned()),
]
};
let compact = format!("<exclusion><groupId>{g}</groupId><artifactId>{a}</artifactId></exclusion>");
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, "<exclusions>".to_owned())];
lines.extend(exclusion_lines(1));
lines.push((0, "</exclusions>".to_owned()));
Some(insert(dep, &lines, format!("<exclusions>{compact}</exclusions>")))
}
/// 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<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = 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<String> {
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 = "<project>
<groupId>demo</groupId>
<properties>
<guice.group>com.google.inject</guice.group>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>${guice.group}</groupId>
<artifactId>guice</artifactId>
</dependency>
</dependencies>
</project>
";
#[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(
" <artifactId>guice</artifactId>\n </dependency>\n </dependencies>",
" <artifactId>guice</artifactId>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>",
);
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(
" </exclusion>
<exclusion>
<groupId>aopalliance</groupId>
<artifactId>aopalliance</artifactId>
</exclusion>
</exclusions>"
));
assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice);
}
#[test]
fn keeps_tabs_crlf_and_one_line_dependencies() {
let tabs = "<project>\r\n\t<dependencies>\r\n\t\t<dependency>\r\n\t\t\t<groupId>g</groupId>\r\n\t\t\t<artifactId>a</artifactId>\r\n\t\t</dependency>\r\n\t</dependencies>\r\n</project>\r\n";
let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap();
assert!(out.contains("<artifactId>a</artifactId>\r\n\t\t\t<exclusions>\r\n\t\t\t\t<exclusion>\r\n\t\t\t\t\t<groupId>x</groupId>\r\n"));
assert!(out.contains("\t\t\t</exclusions>\r\n\t\t</dependency>"));
let inline = "<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId></dependency></dependencies></project>";
assert_eq!(
exclude(inline, ("g", "a"), ("x", "y")).unwrap(),
"<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId><exclusions><exclusion><groupId>x</groupId><artifactId>y</artifactId></exclusion></exclusions></dependency></dependencies></project>"
);
}
#[test]
fn wildcards_profiles_and_missing_declarations() {
let pom = "<project>
<profiles>
<profile>
<dependencies>
<dependency>
<groupId>g</groupId><artifactId>a</artifactId>
<exclusions><exclusion><groupId>x</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
</dependencies>
</profile>
</profiles>
</project>";
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(
"</exclusion><exclusion><groupId>z</groupId><artifactId>w</artifactId></exclusion></exclusions>"
));
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 = "<project><!-- é😀 --><dependencies>\n <dependency><groupId>g</groupId><artifactId>a</artifactId></dependency>\n</dependencies></project>";
let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap();
assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60)));
let first = "<project><!-- é😀 --><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId>";
let r = declaration(&format!("{first}</dependency></dependencies></project>"), &ArtifactKey::new("g", "a"))
.unwrap();
let start = first.find("<artifactId>").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",
"<project><groupId>d</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
"app/pom.xml",
"<project><parent><groupId>d</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>app</artifactId></project>",
);
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());
}
}
+43 -6
View File
@@ -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<String>,
projects: Vec<CachedMavenProject>,
/// 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<String> {
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<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
@@ -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<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -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!(
+42 -7
View File
@@ -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 (`<x:project>` → `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<Element>,
/// 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 `<empty/>`).
pub end: usize,
}
impl Element {
@@ -76,6 +83,7 @@ fn local(name: &str) -> String {
pub fn parse(text: &str) -> Option<Element> {
let mut stack: Vec<Element> = 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<Element> {
} else if let Some(body) = tag.strip_prefix("</") {
let end = body.find('>').unwrap_or(body.len());
let name = local(body[..end].trim());
if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
while stack.len() > at {
let done = stack.pop()?;
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<Element> {
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 = "<a>\n <b>x</b><c/>\n</a>";
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], "<b>x</b>");
assert_eq!(&doc[b.start..b.inner_end], "<b>x");
let c = root.child("c").unwrap();
assert_eq!(&doc[c.start..c.end], "<c/>");
let broken = parse("<a><b>x</a>").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("<a><b>x</c><d>y").unwrap();
+24
View File
@@ -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
+33
View File
@@ -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<Vec<(OsString, OsString)>> = 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<Item = (&'static OsStr, &'static OsStr)> {
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"))]);
}
}
+56 -4
View File
@@ -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<String>,
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -47,6 +52,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
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<String> {
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<String> {
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<u16> {
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<u16>) -> 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();
+18 -7
View File
@@ -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<Vec<StackFrame>> {
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<Vec<Scope>> {
+39 -3
View File
@@ -51,9 +51,16 @@ pub(crate) struct Session {
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
pub breakpoint_ids: Mutex<HashMap<i64, (PathBuf, usize)>>,
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
}
/// 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<PathBuf> {
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<DebugManager>, s: &Arc<Session>, 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<Session>, args: &Value) -> Result<Value, S
};
let mut cmd = tokio::process::Command::new(program);
ide_core::process::no_window(cmd.as_std_mut());
cmd.args(rest).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
+134
View File
@@ -881,3 +881,137 @@ async fn hot_swap_only_for_java_debug() {
let failed = hot_swap_result(&json!({"changedClasses": [], "errorMessage": "boom"}));
assert_eq!(failed.error_message.as_deref(), Some("boom"));
}
fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegistry> {
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<String> = ["--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);
}
+157
View File
@@ -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=<godot executable> 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<LanguageRegistry> {
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<Vec<IdeEvent>>, 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<Mutex<Vec<IdeEvent>>> = 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();
}
+1
View File
@@ -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
+1
View File
@@ -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 });
}
+69 -2
View File
@@ -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<Location> {
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 `<exclusion>` 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<Location> {
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,
+1 -1
View File
@@ -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
+21 -3
View File
@@ -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<Vec<Location>> {
host.project(window)?.lsp.references(doc, pos, include_declaration).await
project_wide: bool,
) -> IdeResult<References> {
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<Vec<Highlight>> {
@@ -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
+8 -1
View File
@@ -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)))
}
+3 -1
View File
@@ -21,7 +21,9 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.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 {
+8 -1
View File
@@ -20,6 +20,10 @@ pub struct VcsRoot {
pub root: ide_core::IpcPath,
/// Merge / rebase / cherry-pick / revert in progress.
pub operation: Option<String>,
/// Current branch; `None` when HEAD is detached (or unreadable).
pub head: Option<String>,
/// HEAD commit; `None` before the first commit.
pub head_commit: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -167,7 +171,10 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
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())
}
+52 -1
View File
@@ -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<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
pub http_history: Arc<ide_http::History>,
/// Coding agent tasks (Agents tool window).
pub agents: Arc<ide_agent::AgentManager>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -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<String> {
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
+47 -9
View File
@@ -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<impl FnOnce() + use<
Some(path) => 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<impl FnOnce() + use<
let cache = cache.filter(|_| refresh)?;
Some(move || {
std::thread::spawn(move || {
if let Some(fresh) = read_login_path(Duration::from_secs(10)) {
let Some(env) = read_login_env(Duration::from_secs(10)) else { return };
if let Some(fresh) = login_path(&env) {
write_cache(&cache, &fresh);
}
ide_core::process::set_shell_env(env);
});
})
}
@@ -50,20 +61,37 @@ fn write_cache(cache: &std::path::Path, path: &str) {
}
#[cfg(unix)]
fn read_login_path(timeout: std::time::Duration) -> Option<String> {
fn login_path(env: &[(OsString, OsString)]) -> Option<String> {
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<Vec<(OsString, OsString)>> {
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<String> {
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))]
+117 -8
View File
@@ -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<R: AsyncBufRead + Unpin>(reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
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<W: AsyncWrite + Unpin>(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<R: tokio::io::AsyncBufRead + Unpin>(self, reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
match self {
Framing::ContentLength => framing::read_message(reader).await,
Framing::Lines => framing::read_line_message(reader).await,
}
}
async fn write<W: AsyncWrite + Unpin>(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<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
) -> std::io::Result<Self> {
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<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
framing: Framing,
) -> std::io::Result<Self> {
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<R, W>(reader: R, mut writer: W, handler: Arc<dyn Handler>, close_on_eof: bool, pid: Option<u32>) -> Self
/// [`connect`](Self::connect) with the given wire framing.
pub fn connect_framed<R, W>(reader: R, writer: W, handler: Arc<dyn Handler>, 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<R, W>(
reader: R,
writer: W,
handler: Arc<dyn Handler>,
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<R, W>(
reader: R,
mut writer: W,
handler: Arc<dyn Handler>,
close_on_eof: bool,
pid: Option<u32>,
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 {
+22
View File
@@ -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());
+1
View File
@@ -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 }
@@ -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
@@ -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)
+12
View File
@@ -75,6 +75,14 @@ pub struct LanguageServerConfig {
/// Human hint shown when the binary is missing.
#[serde(default)]
pub install_hint: Option<String>,
/// `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<String>,
/// 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<String>,
#[serde(default)]
pub install_hint: Option<String>,
}
+38
View File
@@ -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::<std::net::SocketAddr>().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::<std::net::SocketAddr>().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();
@@ -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"],
},
];
@@ -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);
+18
View File
@@ -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",
+1 -1
View File
@@ -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
+8
View File
@@ -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
+44 -3
View File
@@ -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<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError>;
/// [`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<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
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<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, &CancellationToken::new())
}
fn launch_cancellable(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
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())))
}
+151 -77
View File
@@ -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<HashMap<DocId, DocState>>,
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
config_hook: RwLock<Option<ConfigHook>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
shutting_down: AtomicBool,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
@@ -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<Vec<Location>> {
/// 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<References> {
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<Vec<lsp::Location>>>(
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<Vec<lsp::Location>> =
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::<Vec<Location>>())
};
let files =
|v: &Vec<Location>| 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<Location> =
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<Location> = 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<WorkspaceEdit> {
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<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
let query = || async {
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
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<lsp::Uri> {
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<Option<lsp::WorkspaceEdit>> =
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<Vec<lsp::CallHierarchyItem>> =
self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?;
let mut out = Vec::new();
for item in items {
let params = json!({ "item": item });
match direction {
HierarchyDirection::Incoming => {
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
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<Vec<lsp::CallHierarchyIncomingCall>> =
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<Vec<lsp::CallHierarchyOutgoingCall>> =
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<Vec<lsp::CallHierarchyOutgoingCall>> =
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<HierarchyItem>| {
v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect()
};
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
w.second.unwrap_or(w.first)
}
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();
+248
View File
@@ -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<LanguageId>,
pub limit: usize,
}
#[derive(Clone, Debug, Default)]
pub struct CandidateFiles {
pub files: Vec<PathBuf>,
/// 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<R> {
pub first: R,
pub second: Option<R>,
pub incomplete: Option<ReferencesIncomplete>,
}
impl<R> Widened<R> {
/// 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<String> {
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<CandidateFn>) {
*self.candidates_hook.write() = Some(Arc::from(hook));
}
async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option<CandidateFiles> {
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<R, F, Fut>(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
query: F,
covered: impl Fn(&R) -> Vec<PathBuf>,
) -> IdeResult<Widened<R>>
where
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
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<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = 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<PathBuf>, 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::<Vec<_>>()
})
.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<Server>,
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);
}
}
}
}
+119 -1
View File
@@ -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<Instant>,
}
pub struct Progress {
pub task: TaskId,
pub title: String,
@@ -65,6 +79,8 @@ pub struct Server {
effective: Mutex<Option<LanguageServerConfig>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
start_cancel: Mutex<CancellationToken>,
phase: watch::Sender<Phase>,
status: Mutex<(ServerStatus, Option<String>)>,
/// Consecutive crashes of short-lived instances.
@@ -78,6 +94,9 @@ pub struct Server {
published: Mutex<HashMap<PathBuf, Instant>>,
/// 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<HashMap<PathBuf, Transient>>,
}
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) {
+188
View File
@@ -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<u16>, root: &Path) -> Vec<String> {
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<SocketAddr, LaunchError> {
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<TcpStream> {
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<u16> {
Ok(std::net::TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port())
}
fn into_tokio(stream: TcpStream) -> std::io::Result<tokio::net::TcpStream> {
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<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
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<Mutex<VecDeque<String>>> = Arc::default();
let on_line: Arc<dyn Fn(String) + Send + Sync> = {
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<String> = 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<R: AsyncRead + Unpin + Send + 'static>(stream: R, on_line: Arc<dyn Fn(String) + Send + Sync>) {
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<String> =
["--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(_))));
}
}
+17
View File
@@ -322,3 +322,20 @@ pub struct FileRenameEdits {
/// Servers that failed; their references are not updated.
pub errors: Vec<String>,
}
/// 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<Location>,
pub incomplete: Option<ReferencesIncomplete>,
}
+350 -4
View File
@@ -27,9 +27,13 @@ struct MockState {
/// uri → text as reconstructed by the mock from didOpen/didChange.
docs: Mutex<HashMap<String, String>>,
responses: Mutex<HashMap<String, Value>>,
/// Computes a response from the request and the open documents (before `responses`).
responder: Mutex<Option<Responder>>,
peer: Mutex<Option<Peer>>,
}
type Responder = Arc<dyn Fn(&MockState, &str, &Value) -> Option<Value> + Send + Sync>;
impl MockState {
fn notified(&self, method: &str) -> Vec<Value> {
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<Value, (i64, String)> {
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, &params)) {
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<_>>(), 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<Value> {
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<String>) -> 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<Mutex<Vec<ide_lsp::CandidateQuery>>> {
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<PathBuf> = 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<String> {
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<MockState>) {
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<PathBuf> = 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<PathBuf> = 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;
}
+3
View File
@@ -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<serde_json::Valu
root_markers: vec!["package.json".into()],
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
install_hint: None,
connect: None,
fallback: false,
launch_error: None,
}
+243
View File
@@ -0,0 +1,243 @@
//! Language servers reached over TCP (`connect`, the Godot editor's GDScript
//! server) against a mock LSP server on a local socket.
use ide_core::event::ServerStatus;
use ide_core::{CancellationToken, DocId, IdeEvent, LanguageId, Pos};
use ide_jsonrpc::{Handler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
#[cfg(unix)]
use ide_lsp::{LaunchError, Launcher};
use ide_lsp::{LspManager, ProcessLauncher};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[derive(Default)]
struct ServerLog {
requests: Mutex<Vec<String>>,
notifications: Mutex<Vec<String>>,
/// Server ends of the accepted connections.
peers: Mutex<Vec<Peer>>,
}
struct MockHandler(Arc<ServerLog>);
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<Value, (i64, String)> {
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<ServerLog>, 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<LspManager>,
events: Arc<Mutex<Vec<IdeEvent>>>,
}
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<String>)> {
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())
}
+69 -2
View File
@@ -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<String>,
}
struct Meta {
@@ -95,6 +103,8 @@ pub struct PluginHost {
active_files: Arc<Mutex<HashMap<String, String>>>,
/// 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<bool>, 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 `<root>/.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<String> {
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<ActiveTheme> {
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<CatalogPlugin> {
+87 -12
View File
@@ -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"));
+19
View File
@@ -19,6 +19,7 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
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<String> {
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<RunConfig>) {
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<RunConfig>) {
let Some(path) = first_existing(root, &["justfile", "Justfile", ".justfile"]) else { return };
let Some(src) = read(&path) else { return };
+6
View File
@@ -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<LaunchEnv> {
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);
}
+15
View File
@@ -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 = "\
+23 -2
View File
@@ -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<PathBuf> {
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<PathBuf> = 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<PathBuf> = Vec::new();
match c.kind.as_str() {
"python" | "pytest" => {
+109
View File
@@ -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<PathBuf>) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
let mut out: Vec<PathBuf> = 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")));
}
}
}
+1
View File
@@ -8,6 +8,7 @@
pub mod apply;
mod discover;
mod exec;
pub mod godot;
pub mod java;
pub mod libraries;
pub mod locate;
+1
View File
@@ -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 }
+31
View File
@@ -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();
+2
View File
@@ -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};
+118
View File
@@ -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<String>,
/// Files returned at most.
pub limit: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WordFiles {
pub files: Vec<PathBuf>,
/// 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<PathFilter<'_>>,
overlay: Overlay<'_>,
cancel: &CancellationToken,
) -> IdeResult<WordFiles> {
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());
}
}
+10 -2
View File
@@ -285,6 +285,8 @@ pub struct Tools {
platform: Platform,
/// Executables chosen in settings, by command.
custom: BTreeMap<String, PathBuf>,
/// Executables found outside the search path (app bundles), by command.
found: BTreeMap<String, PathBuf>,
}
impl Tools {
@@ -298,7 +300,7 @@ impl Tools {
let system: Vec<PathBuf> = 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<String, PathBuf>) -> 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(),
+10
View File
@@ -507,6 +507,11 @@ impl Repo {
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
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<String>, Option<String>)> {
refs::head(&self.gix()?)
}
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
pub fn operation(&self) -> IdeResult<Option<String>> {
Ok(refs::operation(&self.gix()?))
@@ -821,6 +826,11 @@ impl Repo {
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
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<Vec<(String, u32, u32)>> {
ops::numstat_with_worktree(self, rev)
}
// ---- patches and shelf ----
+29
View File
@@ -349,6 +349,26 @@ pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFi
Ok(parse_name_status(&out.stdout_str()))
}
/// Added / deleted line counts of tracked files whose working-tree content differs from `rev`
/// (`git diff --numstat`); binary files count 0.
pub(crate) fn numstat_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
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<Vec<ChangedFile>> {
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)]
);
}
}
+5
View File
@@ -30,6 +30,11 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult<BranchInfo> {
Ok(info)
}
pub(crate) fn head(repo: &gix::Repository) -> IdeResult<(Option<String>, Option<String>)> {
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<String> {
use gix::state::InProgress::*;
let op = match repo.state()? {
+2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+3 -2
View File
@@ -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
+100
View File
@@ -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.
+188
View File
@@ -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);
}
});
+5 -2
View File
@@ -10,12 +10,15 @@
<body>
<div id="root"></div>
<div id="splash" class="splash" data-tauri-drag-region>
<svg viewBox="0 0 64 64" aria-hidden="true">
<svg class="splash-logo" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="15" fill="#0E0E10" />
<circle cx="26" cy="32" r="11" fill="none" stroke="#FFFFFF" stroke-width="8" />
<rect x="43" y="18" width="8" height="28" rx="1.5" fill="#FF4F1A" />
</svg>
<span>OpenIDEA</span>
<!-- "OpenIDEA" in Inter 500 as outlines: the web font loads late in dev and would swap in. -->
<svg class="splash-wordmark" viewBox="0 -1984 9994 2478" role="img" aria-label="OpenIDEA">
<path fill="currentColor" d="M785.8 20Q592.6 20 440.4 -71.7Q288.2 -163.4 200.4 -334.7Q112.6 -506 112.6 -744Q112.6 -983.2 200.4 -1154.6Q288.2 -1326 440.4 -1418Q592.6 -1510 785.8 -1510Q979 -1510 1130.7 -1418Q1282.4 -1326 1369.8 -1154.6Q1457.3 -983.2 1457.3 -744Q1457.3 -505.8 1369.8 -334.5Q1282.4 -163.2 1130.7 -71.6Q979 20 785.8 20ZM785.8 -187.7Q913 -187.7 1013.5 -251.6Q1114 -315.5 1172.4 -439.9Q1230.7 -564.3 1230.7 -744Q1230.7 -925.6 1172.4 -1050Q1114 -1174.5 1013.5 -1238.4Q913 -1302.3 785.8 -1302.3Q657.9 -1302.3 557 -1238Q456.1 -1173.7 397.7 -1049.2Q339.2 -924.7 339.2 -744Q339.2 -564.3 397.7 -440.3Q456.1 -316.3 557 -252Q657.9 -187.7 785.8 -187.7ZM1717.9 418V-1118H1932.2V-935.9H1950.4Q1970 -970.8 2005.4 -1016.7Q2040.9 -1062.7 2104.6 -1097.3Q2168.3 -1132 2272.6 -1132Q2407.4 -1132 2512.7 -1064Q2617.9 -996 2678.8 -867Q2739.8 -738 2739.8 -556.4Q2739.8 -375.7 2679.8 -246.2Q2619.8 -116.7 2514.5 -47.3Q2409.1 22.2 2272.7 22.2Q2171.8 22.2 2107.4 -12.4Q2043 -47 2006.6 -93.2Q1970.2 -139.3 1950.4 -175H1937.9V418ZM2224 -165.3Q2320.1 -165.3 2384.9 -217.3Q2449.6 -269.3 2482.4 -358.3Q2515.2 -447.3 2515.2 -558Q2515.2 -668.1 2482.8 -755.4Q2450.4 -842.8 2385.7 -893.7Q2321 -944.5 2224 -944.5Q2129.6 -944.5 2065 -896.1Q2000.4 -847.7 1967.4 -761.2Q1934.4 -674.6 1934.4 -558Q1934.4 -441.7 1967.8 -353.2Q2001.2 -264.7 2066.2 -215Q2131.2 -165.3 2224 -165.3ZM3465.8 23.3Q3300.7 23.3 3181.1 -48.1Q3061.4 -119.5 2996.9 -248.5Q2932.4 -377.5 2932.4 -551.1Q2932.4 -723.3 2995.8 -854Q3059.1 -984.7 3175.1 -1058.4Q3291 -1132 3446.8 -1132Q3542.6 -1132 3632 -1100.7Q3721.4 -1069.4 3792.3 -1002.3Q3863.3 -935.2 3904.7 -828.3Q3946.2 -721.5 3946.2 -570.3V-492.2H3054.8V-656.5H3832.8L3730.1 -602.3Q3730.1 -704.7 3698.2 -783.2Q3666.3 -861.7 3603.5 -905.9Q3540.8 -950 3447.5 -950Q3354.2 -950 3288.2 -905Q3222.1 -860 3187.4 -786.5Q3152.6 -712.9 3152.6 -626.1V-514.4Q3152.6 -399.4 3192.1 -319.8Q3231.6 -240.1 3302.9 -199.4Q3374.2 -158.7 3468.1 -158.7Q3529.6 -158.7 3579.9 -176.3Q3630.2 -194 3666.5 -229.4Q3702.8 -264.9 3722.2 -317.2L3928.6 -268.5Q3902.2 -181.6 3838.6 -115.9Q3775 -50.2 3680.4 -13.4Q3585.8 23.3 3465.8 23.3ZM4406.9 -662.4V0H4186.9V-1118H4396.6L4398.9 -844.3H4376.2Q4424.9 -994.3 4517.8 -1063.1Q4610.7 -1132 4742.5 -1132Q4856.6 -1132 4942 -1084.2Q5027.5 -1036.3 5075.1 -942.5Q5122.7 -848.8 5122.7 -710.4V0H4902.7V-682.9Q4902.7 -803.7 4839.8 -872.1Q4776.9 -940.5 4667.3 -940.5Q4592.9 -940.5 4533.8 -907.9Q4474.8 -875.3 4440.8 -813.3Q4406.9 -751.2 4406.9 -662.4ZM5664.4 -1490V0H5435.9V-1490ZM6478.5 0H6109.9V-199.7H6465.8Q6635 -199.7 6747.1 -263Q6859.2 -326.3 6915 -448.6Q6970.8 -571 6970.8 -747.3Q6970.8 -922.1 6915.2 -1043.3Q6859.7 -1164.4 6750.5 -1227.4Q6641.3 -1290.3 6478.4 -1290.3H6102.2V-1490H6493Q6713.2 -1490 6869.9 -1400.9Q7026.7 -1311.8 7110.7 -1145.2Q7194.7 -978.6 7194.7 -747.3Q7194.7 -514.5 7110.4 -347.1Q7026 -179.6 6865.8 -89.8Q6705.5 0 6478.5 0ZM6222.4 -1490V0H5993.9V-1490ZM7471.9 0V-1490H8409.8V-1293.6H7700.4V-849.7H8359.5V-654.7H7700.4V-196.4H8416.4V0ZM8592.9 0 9123.5 -1490H9403.2L9942.8 0H9696.3L9406.1 -828.9Q9372.2 -930 9331.8 -1066.9Q9291.4 -1203.8 9238.4 -1395.6H9284.5Q9232.7 -1202.2 9192.5 -1063.6Q9152.4 -925.1 9121.4 -828.9L8839.9 0ZM8880.4 -392.6V-583.1H9655.3V-392.6Z" />
</svg>
<div class="splash-bar" role="progressbar" aria-label="Loading"><i></i></div>
</div>
<script type="module" src="/src/main.tsx"></script>
+26
View File
@@ -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"
+8
View File
@@ -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"
+8
View File
@@ -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"
+2 -1
View File
@@ -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",
+3 -4
View File
@@ -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" }
}
```
-11
View File
@@ -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"
@@ -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"
}
}
+850
View File
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -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 */
}
+13 -9
View File
@@ -1,11 +1,14 @@
/* Linked, not inlined in index.html: release builds hash inline <style> blocks into the CSP,
which voids style-src 'unsafe-inline' and blocks the editor's runtime-injected styles. */
/* Start-up splash over the page until the window's launch project is open (platform/splash.ts).
Tokens aren't loaded yet, so the colours mirror --bg-frame / --fg-muted / --accent. */
Until tokens load, the colours are the last theme's --bg-frame / --fg-muted / --accent that
boot.js restored as --boot-*, else these built-in ones. */
/* Not on html: the canvas then takes body's (themed) background, which a fast resize shows before relayout. */
body, .splash { background: #e9eaee; }
:root[data-theme="dark"] :is(body, .splash) { background: #26282c; }
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) { background: #1a1b1e; }
body, .splash { background: var(--bg-frame, var(--boot-frame, #e9eaee)); }
:root[data-theme="dark"] :is(body, .splash) { background: var(--bg-frame, var(--boot-frame, #26282c)); }
:root:is([data-theme="dark-hc"], [data-theme="dark"][data-contrast="high"]) :is(body, .splash) {
background: var(--bg-frame, var(--boot-frame, #1a1b1e));
}
.splash {
position: fixed;
inset: 0;
@@ -17,8 +20,7 @@ body, .splash { background: #e9eaee; }
gap: 14px;
padding-bottom: 38px;
box-sizing: border-box;
font: 500 15px/1.2 "Inter Variable", Inter, system-ui, -apple-system, sans-serif;
color: #6c707e;
color: var(--fg-muted, var(--boot-fg, #6c707e));
-webkit-user-select: none;
user-select: none;
cursor: default;
@@ -26,14 +28,16 @@ body, .splash { background: #e9eaee; }
transition: opacity 160ms ease-out;
}
.splash.splash-out { opacity: 0; pointer-events: none; }
:root[data-theme^="dark"] .splash { color: #8c8f95; }
.splash svg { width: 56px; height: 56px; }
:root[data-theme^="dark"] .splash { color: var(--fg-muted, var(--boot-fg, #8c8f95)); }
.splash-logo { width: 56px; height: 56px; }
/* 15px Inter: 2048 units per em, the line box from ascender to descender. */
.splash-wordmark { width: 73.2px; height: 18.15px; }
.splash-bar { width: 120px; height: 2px; border-radius: 1px; overflow: hidden; background: rgb(127 127 127 / 0.18); }
/* Fake progress: eases towards ~90% over the 4 s cap; hideSplash() fills it before fading out. */
.splash-bar > i {
display: block;
height: 100%;
background: #3871e1;
background: var(--accent, var(--boot-accent, #3871e1));
transform-origin: left;
animation: splash-fill 4s cubic-bezier(0.1, 0.7, 0.3, 1) both;
}
+1
View File
@@ -54,6 +54,7 @@ ide-analysis.workspace = true
ide-coverage.workspace = true
ide-profiler.workspace = true
ide-http.workspace = true
ide-agent.workspace = true
ide-db.workspace = true
ide-web.workspace = true
ide-spell.workspace = true
+150
View File
@@ -0,0 +1,150 @@
use crate::state::AppState;
use ide_agent::{
AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath};
use std::sync::Arc;
use tauri::ipc::Channel;
use tauri::{State, Window};
/// Streams the project's agent events to this window; returns every task.
#[tauri::command]
#[specta::specta]
pub fn agent_subscribe(
window: Window,
state: State<'_, AppState>,
on_event: Channel<AgentEvent>,
) -> IdeResult<Vec<AgentTask>> {
let project = state.project(window.label())?;
Ok(project.agents.subscribe(Arc::new(move |e| {
let _ = on_event.send(e);
})))
}
#[tauri::command]
#[specta::specta]
pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<AgentProvider>> {
Ok(state.project(window.label())?.agents.providers())
}
#[tauri::command]
#[specta::specta]
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
state.project(window.label())?.agents.entries(&task)
}
#[tauri::command]
#[specta::specta]
pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult<AgentTask> {
let project = state.project(window.label())?;
project.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
#[tauri::command]
#[specta::specta]
pub async fn agent_prompt(
window: Window,
state: State<'_, AppState>,
task: String,
prompt: AgentPrompt,
now: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.prompt(&task, prompt, now).await
}
/// Drops a queued message and returns it (to edit it).
#[tauri::command]
#[specta::specta]
pub fn agent_unqueue(
window: Window,
state: State<'_, AppState>,
task: String,
queued: String,
) -> IdeResult<AgentQueued> {
state.project(window.label())?.agents.unqueue(&task, &queued)
}
/// Sends a queued message now, interrupting the current turn.
#[tauri::command]
#[specta::specta]
pub fn agent_send_queued_now(
window: Window,
state: State<'_, AppState>,
task: String,
queued: String,
) -> IdeResult<()> {
state.project(window.label())?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
#[tauri::command]
#[specta::specta]
pub async fn agent_set_config(
window: Window,
state: State<'_, AppState>,
task: String,
option: String,
value: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.set_config(&task, &option, value).await
}
#[tauri::command]
#[specta::specta]
pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.cancel(&task)
}
/// Answers a permission request; `option: null` cancels it.
#[tauri::command]
#[specta::specta]
pub fn agent_respond(
window: Window,
state: State<'_, AppState>,
task: String,
permission: u32,
option: Option<String>,
) -> IdeResult<()> {
state.project(window.label())?.agents.respond(&task, permission, option)
}
#[tauri::command]
#[specta::specta]
pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.archive(&task)
}
#[tauri::command]
#[specta::specta]
pub async fn agent_delete(
window: Window,
state: State<'_, AppState>,
task: String,
remove_worktree: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.delete(&task, remove_worktree).await
}
#[tauri::command]
#[specta::specta]
pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentChange>> {
let project = state.project(window.label())?;
project.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
#[tauri::command]
#[specta::specta]
pub async fn agent_base_text(
window: Window,
state: State<'_, AppState>,
task: String,
path: IpcPath,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
project.agents.base_text(&task, path.into_path_buf()).await
}
+29 -1
View File
@@ -3,7 +3,8 @@
use crate::state::AppState;
use ide_build::compile::{BuildEvent, BuildKind, BuildResult};
use ide_build::{BuildModel, BuildTool, SyncProgress};
use ide_core::{IdeResult, RequestId};
use ide_build::pom_edit::ArtifactKey;
use ide_core::{IdeResult, Location, RequestId};
#[allow(unused_imports)]
pub use ide_host::ops::build::*;
use tauri::ipc::Channel;
@@ -42,6 +43,33 @@ pub async fn build_sync(
.await
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
#[tauri::command]
#[specta::specta]
pub async fn maven_dependency_declaration(
window: Window,
state: State<'_, AppState>,
pom: String,
dependency: ArtifactKey,
) -> IdeResult<Location> {
ide_host::ops::build::maven_dependency_declaration(&state.host, window.label(), pom, dependency).await
}
/// Adds an `<exclusion>` 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.
#[tauri::command]
#[specta::specta]
pub async fn maven_exclude_dependency(
window: Window,
state: State<'_, AppState>,
pom: String,
dependency: ArtifactKey,
exclusion: ArtifactKey,
) -> IdeResult<Location> {
ide_host::ops::build::maven_exclude_dependency(&state.host, window.label(), pom, dependency, exclusion).await
}
/// 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)
+8 -2
View File
@@ -92,16 +92,22 @@ pub async fn lsp_virtual_document(
ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), 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)`.
#[tauri::command]
#[specta::specta]
pub async fn lsp_references(
window: Window,
state: State<'_, AppState>,
request: RequestId,
doc: DocId,
pos: Pos,
include_declaration: bool,
) -> IdeResult<Vec<Location>> {
ide_host::ops::lsp::lsp_references(&state.host, window.label(), doc, pos, include_declaration).await
project_wide: bool,
) -> IdeResult<References> {
ide_host::ops::lsp::lsp_references(&state.host, window.label(), request, doc, pos, include_declaration, project_wide)
.await
}
#[tauri::command]
+18
View File
@@ -6,6 +6,7 @@
//! * streaming results use a `Channel<T>` argument; cancellable requests take a
//! UI-allocated `RequestId` and are cancelled via `cancel_request`.
pub mod agent;
pub mod analysis;
pub mod app;
pub mod build;
@@ -70,6 +71,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::project_roots,
app::cancel_request,
app::log_frontend,
agent::agent_subscribe,
agent::agent_providers,
agent::agent_entries,
agent::agent_create,
agent::agent_prompt,
agent::agent_unqueue,
agent::agent_send_queued_now,
agent::agent_set_config,
agent::agent_cancel,
agent::agent_respond,
agent::agent_archive,
agent::agent_delete,
agent::agent_changes,
agent::agent_base_text,
fs::fs_read_dir,
bytes::fs_probe,
bytes::fs_read_range,
@@ -242,6 +257,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_test_reports,
build::build_model,
build::build_sync,
build::maven_dependency_declaration,
build::maven_exclude_dependency,
build::build_run,
java::java_resolve_launch,
java::java_debug_server,
@@ -326,6 +343,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_run_command,
plugin::plugin_set_active_file,
plugin::plugin_set_theme,
plugin::plugin_set_theme_frame,
perf::app_metrics,
perf::clear_caches,
analysis::inspect_code,
+14
View File
@@ -11,9 +11,13 @@ use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State, Window};
/// How long `plugin_list` waits for the startup load before answering with what it has.
const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5);
/// Window label → its event channel.
static SUBSCRIBERS: LazyLock<Mutex<HashMap<String, Channel<PluginEvent>>>> = LazyLock::new(Default::default);
@@ -62,6 +66,8 @@ pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResul
let host = state.plugins.clone();
let root = state.project(window.label()).ok().map(|p| p.root.clone());
blocking(move || {
// A window opened at startup would otherwise see no plugins and drop the selected theme.
host.wait_initial_load(INITIAL_LOAD_WAIT);
if let Some(root) = &root {
host.sync_project(root);
}
@@ -156,3 +162,11 @@ pub fn plugin_set_theme(
_ => state.plugins.set_theme(None),
}
}
/// The UI reports the selected plugin theme's resolved frame colour (`#rrggbb[aa]`),
/// which new windows open in before their page loads.
#[tauri::command]
#[specta::specta]
pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option<String>) -> IdeResult<()> {
state.plugins.set_theme_frame(color.as_deref())
}
+1
View File
@@ -10,6 +10,7 @@ mod menu;
mod remote_dev;
mod state;
mod theme_frame;
mod windows;
use state::AppState;

Some files were not shown because too many files have changed in this diff Show More