Compare commits

..
Author SHA1 Message Date
l0ng-ai eff19e2ffa test(plugins): wait for a cold example component's editor results
The first editor-path call into a freshly installed component can miss its
budget on a slow machine and drop its answer; ask again until it arrives.
2026-11-09 08:18:54 +08:00
l0ng-ai dbbc70d71b fix(plugins): keep resolved paths consistent with host documents 2026-11-09 07:47:24 +08:00
l0ng-ai 03073d83e5 fix(plugins): normalize Windows paths across API 1.4 2026-11-09 07:47:24 +08:00
l0ng-ai 2dda0be909 test(locales): read backend strings continued over a line
A Rust string continued with a trailing backslash ended the literal scan
there, so catalog entries for messages later in the file looked unused.
2026-11-09 07:47:24 +08:00
l0ng-ai 46da4e8f5f chore(deny): allow the MIT-0 license
jsonschema, which validates plugin MCP tool schemas, depends on
borrow-or-share under MIT No Attribution.
2026-11-09 07:47:24 +08:00
l0ng-ai 39e884a07e feat(plugins): add database sources, MCP tools and API examples 2026-11-09 07:47:24 +08:00
l0ng-ai 129f0cc0d0 feat(plugins): add language features, injections, snippets and menus 2026-11-09 07:47:24 +08:00
l0ng-ai adc252ccd4 feat(plugins): add plugin run targets and interpreter routing 2026-11-09 07:47:24 +08:00
l0ng-ai 7a9baac475 feat(plugins): add run, terminal, test and content tab APIs 2026-11-09 07:47:24 +08:00
l0ng-ai 20fe72de09 feat(plugins): add editor and VCS APIs and command permissions 2026-11-09 07:47:24 +08:00
l0ng-ai d7edf8bded feat(plugins): add API 1.4 host services and window events 2026-11-09 07:47:23 +08:00
362 changed files with 1465 additions and 18691 deletions

No files matched your search

-2
View File
@@ -22,8 +22,6 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
Generated
-4
View File
@@ -4273,7 +4273,6 @@ dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4306,8 +4305,6 @@ dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
@@ -4455,7 +4452,6 @@ dependencies = [
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
+35 -54
View File
@@ -100,7 +100,7 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
/// One pre-session command lookup per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
@@ -153,7 +153,7 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
@@ -163,14 +163,14 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
if let Ok(commands) =
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
{
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
if shared.known_commands(&spec.id).is_empty() {
shared.remember(&spec.id, None, Some(&commands), None);
}
})
.await;
}
@@ -187,10 +187,6 @@ impl Shared {
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -203,52 +199,37 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, false);
}
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
changed = true;
}
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
if !changed {
return;
}
serde_json::to_vec_pretty(&*cache)
};
if let Ok(bytes) = snapshot
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
@@ -477,10 +458,10 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& offered.is_empty()
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_settings(spec).await;
self.shared.fetch_commands(spec).await;
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
+18 -43
View File
@@ -69,9 +69,8 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
/// Discover commands without a prompt, ACP session, or pending session file.
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
@@ -80,52 +79,28 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Va
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let request = json!({ "type": "control_request", "request_id": "commands",
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
io.stdin.flush().await.map_err(|e| e.to_string())?;
let mut lines = BufReader::new(io.stdout).lines();
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
let response = &msg["response"];
return if response["subtype"] == "success" {
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
} else {
Err("Claude Code did not initialize".into())
};
}
}
Err("Claude Code exited".into())
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
+10 -112
View File
@@ -371,7 +371,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
for outcome in ["success", "error", "exit", "timeout", "missing"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let script = root.join("fake-claude");
@@ -380,7 +380,6 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path.cwd()
(root / 'pid').write_text(str(os.getpid()))
with (root / 'launches').open('a') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
request = json.loads(sys.stdin.readline())
@@ -392,34 +391,16 @@ if outcome == 'exit':
if outcome == 'timeout':
time.sleep(60)
response = {'subtype': outcome, 'request_id': request['request_id']}
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
response['subtype'] = 'success'
if outcome == 'success':
response['response'] = {'commands': [
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
'models': [
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
if outcome == 'empty':
response['response'] = {}
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
else:
response['error'] = 'Authentication required'
if outcome == 'session_report':
(root / 'initialized').touch()
while not (root / 'continue').exists():
time.sleep(0.01)
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
for line in sys.stdin:
with (root / 'requests').open('a') as f:
f.write(line)
request = json.loads(line)
if request['request']['subtype'] == 'get_settings':
if outcome == 'settings_timeout':
time.sleep(60)
reply = {'subtype': 'success', 'request_id': request['request_id'],
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
"#,
)
.unwrap();
@@ -436,59 +417,19 @@ for line in sys.stdin:
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
std::fs::write(root.join("continue"), "").unwrap();
}
mgr.commands("claude", None).await
};
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
let first = first.unwrap();
assert_eq!(first, concurrent.unwrap());
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
if outcome == "success" {
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
let restored = manager(root, Arc::new(FakeAgent::default()));
assert_eq!(first, restored.commands("claude", None).await.unwrap());
// The same handshake lists the models, so the composer has them before any task.
for mgr in [&mgr, &restored] {
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(model.current.as_deref(), Some("default"));
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus"]);
let effort =
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
assert_eq!(
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
}
} else {
assert_eq!(names, ["triage"], "{outcome}");
assert_eq!(root.join("models.json").exists(), outcome == "empty");
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
assert!(!root.join("models.json").exists());
}
assert!(mgr.tasks.read().is_empty());
assert!(!root.join("store").exists());
@@ -498,25 +439,10 @@ for line in sys.stdin:
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
.collect();
// The handshake and the settings read: no prompt or other control request.
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
&["initialize", "get_settings"]
} else {
&["initialize"]
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery process must exit");
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
let request: Value = serde_json::from_str(requests.trim()).unwrap();
assert_eq!(request["type"], "control_request");
assert_eq!(request["request"]["subtype"], "initialize");
}
}
}
@@ -2432,31 +2358,3 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
+9 -641
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
}
out
}
@@ -565,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(statements) {
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -592,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -604,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -969,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -1155,7 +808,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script, &BTreeMap::new());
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -1169,291 +822,6 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+6 -57
View File
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
@@ -328,39 +312,4 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
-34
View File
@@ -818,40 +818,6 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
+1 -2
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -23,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
+10 -128
View File
@@ -4,12 +4,10 @@ use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
@@ -30,11 +28,10 @@ pub struct InputWriter {
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
/// Unix writers must first be made nonblocking with `nonblocking`.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -77,19 +74,11 @@ impl InputWriter {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
Err(e) => return Err(e),
}
@@ -179,46 +168,14 @@ impl Drop for InputWriter {
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
/// PTY clones share file status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
Ok(())
}
#[cfg(test)]
@@ -264,11 +221,6 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -291,11 +243,6 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -309,26 +256,6 @@ mod tests {
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -339,59 +266,14 @@ mod tests {
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
-2
View File
@@ -16,8 +16,6 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
-193
View File
@@ -1,193 +0,0 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
-29
View File
@@ -1,29 +0,0 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+3 -51
View File
@@ -104,21 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -139,48 +130,13 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -191,7 +147,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open_with(pid, access) else { continue };
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -231,12 +187,8 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
-11
View File
@@ -48,8 +48,6 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -141,14 +139,6 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -243,7 +233,6 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
-2
View File
@@ -24,7 +24,5 @@ tracing.workspace = true
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+83 -151
View File
@@ -31,7 +31,6 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -52,7 +51,6 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -136,7 +134,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -169,11 +166,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -402,9 +394,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
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);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -450,7 +439,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -470,140 +459,95 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
let port = match spec.transport {
// 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(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
unreachable!("the retry loop returns on its last attempt")
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) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -831,16 +775,4 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+2 -24
View File
@@ -71,7 +71,6 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -107,22 +106,9 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -131,7 +117,6 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -202,11 +187,7 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -265,16 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+9 -76
View File
@@ -16,7 +16,6 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
@@ -608,22 +607,11 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
}
}
@@ -697,17 +685,6 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -722,22 +699,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -886,22 +847,11 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
@@ -942,9 +892,6 @@ impl DebugManager {
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -955,17 +902,6 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -1003,8 +939,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
@@ -1130,9 +1066,6 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
+18 -103
View File
@@ -59,8 +59,6 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -113,8 +111,6 @@ impl Session {
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -302,21 +298,6 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -368,10 +349,12 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
// Console programs have no external graceful signal; the adapter gets its chance first.
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
@@ -408,18 +391,7 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
#[cfg(not(unix))]
{
let _ = pid;
}
@@ -607,10 +579,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
@@ -662,15 +630,9 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
if let Some(fd) = pair.master.as_raw_fd() {
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
}
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -700,27 +662,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
tracing::warn!("debug input: {e}");
})
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -729,11 +680,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -745,14 +692,6 @@ fn forward_terminal_output(
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
@@ -823,6 +762,7 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -835,9 +775,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -870,18 +808,14 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -895,22 +829,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
-72
View File
@@ -1,72 +0,0 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
-88
View File
@@ -14,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -1253,7 +1166,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
-30
View File
@@ -1,30 +0,0 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+3 -17
View File
@@ -93,23 +93,9 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-130
View File
@@ -1,130 +0,0 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
-26
View File
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+19 -181
View File
@@ -41,15 +41,6 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -89,10 +80,6 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -239,20 +186,17 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -317,23 +258,17 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -348,121 +283,24 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+1 -44
View File
@@ -1,9 +1,6 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
-1
View File
@@ -10,7 +10,6 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
+2 -2
View File
@@ -56,7 +56,7 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -125,7 +125,7 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: Arc::default(),
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
-19
View File
@@ -1,5 +1,4 @@
mod output;
mod target;
use crate::Host;
use crate::rpc::Dispatcher;
@@ -10,15 +9,6 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let target_id = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
if let Some(id) = target_id {
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
return target::start(host, window, config, owner, id).await;
}
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -361,14 +351,6 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// A loopback port that is free right now, for a debug agent the launch line starts
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
host.require_trust(window)?;
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
@@ -510,7 +492,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
-102
View File
@@ -1,102 +0,0 @@
use crate::Host;
use ide_core::path_mapping::PathMapping;
use ide_core::{IdeError, IdeResult};
use ide_dap::target::TargetMapping;
use ide_dap::{LaunchConfig, SessionOwner};
use ide_language::ProgramPin;
use serde_json::json;
pub(super) async fn start(
host: &Host,
window: &str,
mut config: LaunchConfig,
owner: SessionOwner,
id: String,
) -> IdeResult<ide_core::DebugSessionId> {
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let registration = plugins
.run_targets(&project.root)
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let container = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
let placement = dbg.target_placement.as_deref().unwrap_or("");
if !matches!(placement, "connect-back" | "adapter-in-target")
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
{
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
}
let mapping = TargetMapping {
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
external_prefix: format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
),
};
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or(json!({}));
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
// DAP paths already use target coordinates; adapter-native mappings stay identity.
if placement == "connect-back" {
config.program = None;
config.args.clear();
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
extra.as_object_mut().unwrap().remove(key);
}
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
extra["port"] = json!(9003);
}
extra["hostname"] = json!("0.0.0.0");
extra["pathMappings"] = json!({container.clone(): container.clone()});
} else {
for key in ["debugLauncherPython", "pythonPath"] {
extra.as_object_mut().unwrap().remove(key);
}
extra["console"] = json!("internalConsole");
extra["python"] = json!(dbg.command);
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
}
config.extra_json = Some(extra.to_string());
let wrapped = if placement == "adapter-in-target" {
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
let root = project.root.clone();
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
} else {
None
};
host.require_trust(window)?;
let root = project.root.clone();
let settings = project.settings.effective();
host.debugger
.start_prepared(
config,
owner,
|_| None,
move |spec, _| {
if let Some(run) = wrapped {
spec.command = run.command;
spec.args = run.args;
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
spec.env = run.env.into_iter().collect();
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
spec.command = command.to_string_lossy().into_owned();
}
spec.target = Some(mapping);
Ok(())
},
)
.await
}
+23 -146
View File
@@ -4,39 +4,11 @@ use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -69,8 +41,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -266,14 +238,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(target, pos, cancel).await;
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -288,8 +260,7 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
@@ -307,9 +278,7 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
if kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -320,7 +289,7 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
@@ -455,46 +424,8 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
@@ -525,9 +456,9 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(target, pos, cancel, &|locs| {
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
@@ -545,42 +476,6 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
host.require_trust(window)?;
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
for registration in plugins.run_targets(&project.root) {
let prefix = format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&registration.id, percent_encoding::NON_ALPHANUMERIC)
);
if let Some(suffix) = uri.strip_prefix(&prefix) {
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let container_root = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let target = ide_language::ServerTarget {
id: registration.id,
origin: None,
project_root: project.root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
container_root,
};
let title = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let language_id = host.languages.detect(std::path::Path::new(&path), None);
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
}
}
return Err(IdeError::unavailable("plugin run target is no longer available"));
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -592,8 +487,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// 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. Java: without `libraries`, the dependencies and the JDK are not
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
@@ -610,9 +505,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = async {
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -622,7 +517,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
target,
doc,
pos,
include_declaration,
project_wide,
@@ -748,8 +643,7 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
@@ -780,9 +674,7 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -919,9 +811,8 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -1542,9 +1433,7 @@ pub struct PluginLanguageVisuals {
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
let snap = host.project(window)?.documents.snapshot(doc)?;
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
@@ -1561,18 +1450,6 @@ pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> I
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
-120
View File
@@ -1,120 +0,0 @@
//! Project language-server targets, shared by desktop and openidea-server.
use crate::plugins::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageServerConfig, ServerTarget};
use std::path::Path;
pub(crate) fn configure(
plugins: &PluginHost,
root: &Path,
id: &str,
cfg: &mut LanguageServerConfig,
) -> IdeResult<bool> {
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
let container_root = registration.target.container_root.clone().unwrap_or_default();
cfg.target = Some(ServerTarget {
id: id.into(),
origin: None,
project_root: root.into(),
container_root: container_root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
});
if container_root.is_empty() {
return Err(IdeError::invalid("language server targets require a container root"));
}
let run = command(&cfg.command, cfg.args.clone());
let wrapped = plugins.prepare_run_target(root, id, &run)?;
cfg.command = wrapped.command;
cfg.args = wrapped.args;
cfg.connect = None;
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
Ok(true)
}
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":"language-server", "name":"Language server", "kind":"shell",
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
}))
.expect("valid internal run configuration")
}
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
let registration = plugins
.run_targets(&target.project_root)
.into_iter()
.find(|t| t.id == target.id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
if registration.target.container_root.as_deref() != Some(&target.container_root) {
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
}
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
wrapped.args.extend(["--".into(), path.into()]);
capture(&wrapped, &target.project_root)
}
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const LIMIT: u64 = 4 * 1024 * 1024;
let mut cmd = Command::new(&run.command);
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
let mut child = cmd
.args(&run.args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let overflow = Arc::new(AtomicBool::new(false));
let large = overflow.clone();
let output = std::thread::spawn(move || {
let mut bytes = Vec::new();
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
result.map(|_| bytes)
});
let errors = std::thread::spawn(move || {
let mut stderr = stderr;
let mut bytes = Vec::new();
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
bytes
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
if output.is_finished()
&& errors.is_finished()
&& let Ok(Some(status)) = child.try_wait()
{
break Ok(status);
}
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
_ => {
ide_core::process::kill_tree_of(child.id());
let _ = child.kill();
let _ = child.wait();
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
}
}
};
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
let stderr = errors.join().unwrap_or_default();
if !status?.success() {
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
}
if bytes.len() as u64 > LIMIT {
return Err(IdeError::invalid("container document exceeds 4 MiB"));
}
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
}
-2
View File
@@ -98,5 +98,3 @@ impl Host {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
pub(crate) mod lsp_target;
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn set_settings(
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
+6 -7
View File
@@ -218,13 +218,12 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
let mut plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
+8 -42
View File
@@ -276,12 +276,16 @@ impl PluginHost {
self.shared.run_configurations.write().insert((plugin, project), items);
}
}
/// Resolves only an owned plugin test configuration in the client's trusted project.
fn test_configuration(
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
return Err(IdeError::invalid("project is not trusted"));
@@ -298,45 +302,7 @@ impl PluginHost {
{
return Ok(None);
}
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv for a user action, under the command budget.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
{
return Ok(None);
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
let timeout = self.limits.timeout;
let started = Instant::now();
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let plugin = self.get(owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
+1 -33
View File
@@ -1,4 +1,4 @@
//! Embedded documents preserve the host's UTF-16 coordinates.
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
@@ -104,38 +104,6 @@ pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], languag
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
-14
View File
@@ -76,29 +76,16 @@ pub struct Providers {
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let injections = decl.kinds.contains(&ProviderKind::Injections);
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
if injections {
self.injections_changed();
}
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
if injections {
this.injections_changed();
}
}
})
}
fn injections_changed(self: &Arc<Self>) {
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
super::injections::providers_changed();
}
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
@@ -273,7 +260,6 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
super::injections::providers_changed();
}
/// The plugin host whose providers take part, when any declares `kind`.
@@ -468,16 +468,6 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_select_tests", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let tests = c.arg("tests")?;
blocking(move || host.select_tests(&c.window, &config, tests)).await
}
});
let h = host.clone();
d.add("plugin_parse_test_results", move |c| {
let h = h.clone();
async move {
@@ -627,11 +617,6 @@ impl RemotePlugins {
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
Ok(())
});
d.add("mcp_report_problems", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
Ok(())
});
let h = host.clone();
d.add("plugin_decoration_sets", move |_c| {
let h = h.clone();
+3 -15
View File
@@ -21,15 +21,6 @@ fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn targets_changed(&self) {
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
for project in host.projects() {
project.lsp.reconfigure_soon();
}
}
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
@@ -40,7 +31,7 @@ impl Shared {
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.targets_changed();
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -79,12 +70,12 @@ impl PluginApi {
);
drop(targets);
drop(blocked);
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -102,9 +93,6 @@ impl PluginHost {
id: &str,
config: &ide_run::RunConfig,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let target = self
.shared
.run_targets
+7 -60
View File
@@ -133,45 +133,9 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
let root = root.clone();
let plugins = self.plugin_host.clone();
let reader_plugins = plugins.clone();
lsp.set_target_hooks(
Box::new(move |language, cfg| {
let effective = settings.effective();
let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
else {
return false;
};
let Some(plugins) = plugins.read().upgrade() else { return false };
// A withdrawn target returns the server to the host. An invalid active
// target stays visibly failed; it must never silently fall back.
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
return false;
}
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
cfg.launch_error = Some(e.to_string());
}
true
}),
Box::new(move |target, path| {
let plugins = reader_plugins
.read()
.upgrade()
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
crate::ops::lsp_target::read(&plugins, target, path)
}),
);
}
{
let settings = settings.clone();
let root = root.clone();
@@ -308,23 +272,7 @@ impl Host {
{
let settings = settings.clone();
lsp.set_servers_hook(Box::new(move |language, defined| {
let effective = settings.effective();
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
if let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
{
let chosen = if selection.server.is_empty() {
servers.first().map(|s| s.name.clone())
} else {
Some(selection.server.clone())
};
servers.retain(|s| Some(&s.name) == chosen.as_ref());
for server in &mut servers {
server.fallback = false;
server.target_key = Some(selection.target.clone());
}
}
servers
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
}));
}
{
@@ -1069,13 +1017,12 @@ impl Project {
let Some(root) = root else {
return ide_lsp::CandidateFiles::default();
};
let mut exclude = self.search_excludes();
// Directories the server analyses fully: their files are never read.
exclude.extend(q.skip.iter().filter_map(|dir| {
let rel = dir.strip_prefix(&root).ok()?.to_str()?.replace('\\', "/");
(!rel.is_empty() && !rel.contains(['*', '?', '[', ']', '{', '}', '!', ','])).then(|| format!("{rel}/**"))
}));
let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude, limit: q.limit };
let query = ide_search::WordQuery {
word: q.word.clone(),
dir: q.root.clone(),
exclude: self.search_excludes(),
limit: q.limit,
};
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
-4
View File
@@ -286,10 +286,6 @@ where
let p = Peer::connect(reader, writer, handler);
let _ = peer.set(p.clone());
p.closed().await;
// Reports belong to the disconnected desktop, even while the host shuts down.
for window in host.projects.read().keys() {
host.mcp_reports.forget_window(window);
}
}
/// Sends a window's events to the client as `event` notifications.
@@ -93,9 +93,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let p = plugins(tmp.path());
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&p));
let generation = host.project("main").unwrap().lsp.injection_generation();
drop(plugins(&tmp.path().join("unrelated")));
assert_eq!(host.project("main").unwrap().lsp.injection_generation(), generation);
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
@@ -204,7 +201,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
set_editor_providers(Some(&p));
injected_lsp(&host, &p, &root, &peer).await;
p.set_enabled(ID, false).unwrap();
assert!(ops::lsp::plugin_formatters().is_empty());
set_editor_providers(None);
@@ -236,131 +232,3 @@ fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
}
// Same TCP fake-server pattern as plugin_refactoring; the real Wasm export supplies the range.
async fn injected_lsp(host: &Host, plugins: &PluginHost, root: &Path, remote: &ide_jsonrpc::Peer) {
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use serde_json::{Value, json};
#[derive(Default)]
struct Fake {
docs: Mutex<std::collections::HashMap<String, String>>,
requests: Mutex<Vec<(String, Value)>>,
}
impl Handler for Fake {
fn on_notification(&self, method: &str, p: Value) {
let uri = p["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
match method {
"textDocument/didOpen" => {
self.docs.lock().insert(uri, p["textDocument"]["text"].as_str().unwrap().into());
}
"textDocument/didChange" => {
self.docs.lock().insert(uri, p["contentChanges"][0]["text"].as_str().unwrap().into());
}
"textDocument/didClose" => {
self.docs.lock().remove(&uri);
}
_ => {}
}
}
fn on_request(&self, method: &str, p: Value) -> Result<Value, (i64, String)> {
self.requests.lock().push((method.to_owned(), p.clone()));
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": {"openClose":true,"change":1}, "completionProvider":{"resolveProvider":true}, "hoverProvider":true, "definitionProvider":true}})
}
"textDocument/completion" => json!([{"label":"embedded-server", "data":{"identity":7}}]),
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"identity":7}));
json!({"label":"embedded-server", "detail":"resolved"})
}
"textDocument/hover" => {
json!({"contents":"embedded hover", "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
"textDocument/definition" => {
json!({"uri":p["textDocument"]["uri"], "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
_ => Value::Null,
})
}
}
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let fake = Arc::new(Fake::default());
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let (accepted, state) = (peers.clone(), fake.clone());
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (r, w) = stream.into_split();
accepted.lock().push(Peer::connect(r, w, state.clone()));
}
});
let cfg: ide_language::LanguageServerConfig =
toml::from_str(&format!("name='injected-sql'\ncommand='no-process-required'\nconnect='127.0.0.1:{port}'"))
.unwrap();
let project = host.project("main").unwrap();
project.lsp.set_servers_hook(Box::new(
move |language, _| if language.as_str() == "sql" { vec![cfg.clone()] } else { Vec::new() },
));
let path = root.join("injection.txt");
std::fs::write(&path, "😀[[sql]] tail").unwrap();
let doc = ops::doc::open(host, "main", path.clone()).await.unwrap();
let items = ops::lsp::lsp_completion(host, "main", RequestId(91), doc.id, Pos::new(0, 5), None).await.unwrap();
let item = items.items.into_iter().find(|i| i.label == "embedded-server").unwrap();
assert_eq!(
ops::lsp::lsp_resolve_completion(host, "main", doc.id, item).await.unwrap().detail.as_deref(),
Some("resolved")
);
let opened = fake.docs.lock().clone();
assert_eq!(opened.len(), 1);
assert_eq!(opened.values().next().unwrap(), " sql ");
let virtual_uri = opened.keys().next().unwrap().clone();
assert!(!ide_lsp::uri_to_path(&virtual_uri).unwrap().exists());
let before = fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count();
ops::lsp::lsp_completion(host, "main", RequestId(92), doc.id, Pos::new(0, 2), None).await.unwrap();
assert_eq!(fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count(), before);
let defs = ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Declaration).await;
// Declaration isn't advertised; definition exercises mapping without a plugin definition provider.
assert!(defs.is_err());
let defs =
ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path.0, path);
assert_eq!(defs[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
let hover: Value = remote
.request(
"invoke",
json!({"window":"main","cmd":"lsp_hover","args":{"request":93,"doc":doc.id,"pos":{"line":0,"col":5}}}),
None,
)
.await
.unwrap();
assert!(hover.to_string().contains("embedded hover"), "{hover}");
ops::doc::edit(
host,
"main",
doc.id,
doc.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 7, insert: "SQL".into() }] },
)
.unwrap();
ops::lsp::lsp_completion(host, "main", RequestId(94), doc.id, Pos::new(0, 5), None).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while fake.docs.lock().get(&virtual_uri).is_none_or(|s| s != " SQL ") {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
plugins.set_enabled(ID, false).unwrap();
while !fake.docs.lock().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.lsp.injected_document(doc.id, Pos::new(0, 5)), doc.id);
ops::doc::close(host, "main", doc.id).unwrap();
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| Vec::new()));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
+2 -47
View File
@@ -76,11 +76,11 @@ impl TestWorkspace {
impl PluginWorkspace for TestWorkspace {
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || plugin == "api14" && id == "tests") {
if plugin != "execution" || !matches!(id, "detected" | "dynamic") {
return Err(HostError::NotFound(id.into()));
}
Ok(ide_run::RunConfig {
id: format!("plugin:{plugin}:{id}"),
id: format!("plugin:execution:{id}"),
name: "Tests".into(),
kind: "plugin".into(),
command: "execution-tool".into(),
@@ -6018,51 +6018,6 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
}
#[test]
fn api14_fixture_exports_test_selection() {
let f = fixture(Limits::default());
f.install(&sample("api14"));
assert_eq!(
f.host.select_tests("main", "plugin:api14:tests", vec![vec!["Suite".into(), "Test".into()]]).unwrap(),
Some(vec!["Suite::Test".into()])
);
}
#[cfg(unix)]
#[test]
fn api14_test_selection_uses_owned_config_ids_and_name_paths() {
let f = execution_fixture();
f.host.refresh_run_configurations(&f.project);
for id in ["dynamic", "detected"] {
assert_eq!(
f.host
.select_tests(
"main",
&format!("plugin:execution:{id}"),
vec![vec!["Suite".into(), "Test".into()], vec!["Other".into()]]
)
.unwrap(),
Some(vec![id.into(), "Suite::Test".into(), "Other".into()])
);
}
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec!["none".into()]]).unwrap(), None);
assert_eq!(f.host.select_tests("main", "cargo:test", vec![vec!["Test".into()]]).unwrap(), None);
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![]).unwrap(), None);
}
#[cfg(unix)]
#[test]
fn api14_test_selection_traps_and_timeouts_are_unsupported() {
for failure in ["trap", "timeout"] {
let f = execution_fixture();
f.host.refresh_run_configurations(&f.project);
let started = Instant::now();
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None);
// Selection is a user action: bounded by the command budget, not the edit path.
assert!(started.elapsed() < Limits::default().timeout + Duration::from_secs(2));
}
}
#[cfg(unix)]
#[test]
fn api14_stage3_terminal_calls_are_denied_without_process_grants() {
+2 -73
View File
@@ -396,7 +396,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() });
let peer = Peer::connect(cr, cw, client.clone());
@@ -416,53 +416,6 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
assert!(editors.as_str().unwrap().contains("active: true"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert_eq!(problems.as_str(), Some("[]"));
let other_file = project.join("other.txt");
std::fs::write(&other_file, "other text").unwrap();
let outside_file = tmp.path().join("outside.txt");
std::fs::write(&outside_file, "outside").unwrap();
let problem = |path: &Path, message: &str| {
json!({"path": path, "items": [{
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
@@ -513,33 +466,9 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
let result = tokio::task::spawn_blocking(move || {
plugin_host
.run_command(
"api14",
"problems",
None,
ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(project),
..Default::default()
},
)
.unwrap()
})
.await
.unwrap();
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-102
View File
@@ -1,102 +0,0 @@
//! Target debugging uses the same trusted plugin wrapper on desktop and the SSH server.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::LanguageRegistry;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
#[tokio::test(flavor = "multi_thread")]
async fn registered_target_debugs_over_stdio_and_rechecks_program_grant() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let adapter = base.join("adapter.py");
std::fs::write(&adapter, include_str!("../../ide-dap/tests/fixtures/target_adapter.py")).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then exec cat "$2" "$3"; fi
export FAKE_ROOT=/container
exec /usr/bin/python3 -u '{}'
"#,
adapter.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let launch: ide_dap::LaunchConfig=serde_json::from_value(json!({"name":"Fake target","adapter":"debugpy","request":"launch","program":root.join("main.py"),"args":[],"cwd":root,"env":{},"extraJson":json!({"__runTarget":id}).to_string()})).unwrap();
host.debugger
.set_breakpoints(
root.join("main.py"),
vec![ide_dap::Breakpoint {
line: 2,
enabled: true,
condition: None,
hit_condition: None,
log_message: None,
}],
)
.await
.unwrap();
let session = ide_host::ops::debug::debug_start(&host, "main", launch.clone()).await.unwrap();
let frames = host.debugger.stack_trace(session, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
host.debugger.stop(session).await.unwrap();
// External debug sources use the container reader even with no LSP running.
let library = base.join("library.py");
std::fs::write(&library, "container library").unwrap();
let uri = format!(
"openidea-target://{}/0/{}",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC),
library.to_string_lossy().trim_start_matches('/')
);
let doc = ide_host::ops::doc::doc_open_virtual(&host, "main", uri).await.unwrap();
assert!(doc.read_only);
assert_eq!(doc.text, "container library");
std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap();
let error = ide_host::ops::debug::debug_start(&host, "main", launch).await.unwrap_err();
assert!(error.message.contains("changed") || error.message.contains("approved"), "{error:?}");
host.close_project("main");
}
-224
View File
@@ -1,224 +0,0 @@
//! Real stdio LSP through a registered plugin wrapper, without a container daemon.
#![cfg(unix)]
use ide_core::{DocId, IdeEvent, LanguageId, Pos};
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::{LanguageConfig, LanguageRegistry};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc, time::Duration};
const SERVER: &str = r#"import sys,json,os
root = 'file:///container' if os.environ.get('FAKE_IN_TARGET') else sys.argv[3]
def send(v):
body=json.dumps(v).encode(); sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n'%len(body)).encode()+body); sys.stdout.buffer.flush()
while True:
headers={}
while True:
line=sys.stdin.buffer.readline()
if not line: sys.exit(0)
if line in (b'\r\n',b'\n'): break
k,v=line.decode().split(':',1); headers[k.lower()]=v.strip()
msg=json.loads(sys.stdin.buffer.read(int(headers['content-length'])))
method=msg.get('method'); params=msg.get('params',{})
if method=='initialize':
assert params['rootUri']==root,params
assert params['rootPath']==root.removeprefix('file://'),params
assert params['workspaceFolders'][0]['uri']==root,params
open(sys.argv[1],'a').write(json.dumps(params)+'\n')
result={'capabilities':{'textDocumentSync':1,'definitionProvider':True,'documentLinkProvider':True}}
elif method=='textDocument/didOpen':
if params['textDocument']['uri'] != root+'/main.mock': continue
send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':root+'/main.mock','diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'message':'mapped diagnostic','severity':2}]}})
continue
elif method=='textDocument/documentLink':
result=[{'target':sys.argv[2]+'#L2','range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}}}]
elif method=='textDocument/definition':
assert params['textDocument']['uri']==root+'/main.mock',params
r={'start':{'line':0,'character':0},'end':{'line':0,'character':1}}
result=[{'uri':root+'/definition.mock','range':r},{'uri':sys.argv[2],'range':r}]
elif method=='shutdown': result=None
elif method=='exit': sys.exit(0)
else:
if 'id' not in msg: continue
result=None
send({'jsonrpc':'2.0','id':msg['id'],'result':result})
"#;
#[tokio::test(flavor = "multi_thread")]
async fn initialize_definition_diagnostics_external_read_only_and_withdrawal() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let source = root.join("main.mock");
std::fs::write(&source, "x").unwrap();
std::fs::write(root.join("definition.mock"), "x").unwrap();
let library = base.join("library with space.mock");
std::fs::write(&library, "read only library").unwrap();
let log = base.join("initialize.json");
let reads = base.join("reads");
let server = base.join("server.py");
std::fs::write(&server, SERVER).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then printf '%s' "$3" > '{}'; fi
export FAKE_IN_TARGET=1
exec "$@"
"#,
reads.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut language: LanguageConfig = toml::from_str(
"id='mock'\nname='Mock'\nextensions=['mock']\n[[language-servers]]\nname='mock-ls'\ncommand='python3'",
)
.unwrap();
language.language_servers[0].args = vec![
server.to_string_lossy().into(),
log.to_string_lossy().into(),
ide_lsp::path_to_uri_string(&library),
ide_lsp::path_to_uri_string(&root),
];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::from_configs(vec![language])),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let project = host.project("main").unwrap();
project
.settings
.set(
ide_workspace::SettingsScope::Project,
"languageServerTargets",
json!({"mock":{"target":id,"server":"mock-ls"}}),
)
.unwrap();
let doc = DocId::next();
project.lsp.did_open(doc, &source, &LanguageId::new("mock"), 1, "x");
// Links have no previously recorded location origin. Their URI must identify
// the right server by itself, including when its fragment is stripped.
let links = project.lsp.document_links(doc).await.unwrap();
let link = &links[0];
assert_eq!(link.fragment.as_deref(), Some("L2"));
assert!(link.url.is_none());
let linked =
ide_host::ops::doc::doc_open_virtual(&host, "main", link.path.as_ref().unwrap().to_string_lossy().into_owned())
.await
.unwrap();
assert!(linked.read_only);
let locations = tokio::time::timeout(
Duration::from_secs(15),
project.lsp.goto(doc, Pos::new(0, 0), ide_lsp::types::GotoKind::Definition),
)
.await
.unwrap()
.unwrap();
assert_eq!(locations[0].path.to_path_buf(), root.join("definition.mock"));
assert!(log.exists(), "initialize reached the fake server with the mapped root");
for _ in 0..200 {
if events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))) { break; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))));
let uri = locations[1].uri.clone().expect("external paths are virtual documents");
assert!(uri.starts_with("openidea-target:"));
let opened = ide_host::ops::doc::doc_open_virtual(&host, "main", uri.clone()).await.unwrap();
assert!(opened.read_only);
assert_eq!(project.documents.text_of(std::path::Path::new(&uri)).unwrap(), "read only library");
assert_eq!(std::fs::read_to_string(&reads).unwrap(), library.to_string_lossy());
assert!(
project
.documents
.apply_edits(opened.id, &[ide_core::TextEdit { range: Default::default(), new_text: "bad".into() }])
.is_err()
);
for operation in [
ide_core::FileEdit::Create { path: uri.clone().into(), overwrite: true },
ide_core::FileEdit::Delete { path: uri.clone().into(), recursive: false },
ide_core::FileEdit::Rename { from: uri.clone().into(), to: root.join("copy.mock").into() },
ide_core::FileEdit::Edit { path: uri.clone().into(), version: None, edits: Vec::new() },
] {
let mut edit = ide_core::WorkspaceEdit { files: vec![operation], ..Default::default() };
assert!(project.documents.prepare_workspace_edit(&mut edit).is_err());
}
// A library tab must not prevent ordinary project refactorings.
project
.documents
.apply_workspace_edit(&ide_core::WorkspaceEdit {
files: vec![ide_core::FileEdit::Create { path: root.join("created.mock").into(), overwrite: false }],
..Default::default()
})
.unwrap();
assert!(root.join("created.mock").exists());
// Changing the project setting restarts on the host, then back inside the target.
let selection = json!({"mock":{"target":id,"server":"mock-ls"}});
for value in [json!({}), selection.clone()] {
project.settings.set(ide_workspace::SettingsScope::Project, "languageServerTargets", value.clone()).unwrap();
project.lsp.reconfigure().await;
let expected = if value == json!({}) { ide_lsp::path_to_uri_string(&root) } else { "file:///container".into() };
wait_root(&log, &expected).await;
}
// Withdrawal invalidates reads, even when contents were cached earlier.
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
plugins.run_command("execution", "target", None, invocation()).unwrap();
wait_root(&log, "file:///container").await;
plugins.set_enabled("execution", false).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
host.shutdown().await;
plugins.shutdown();
}
async fn wait_root(log: &std::path::Path, expected: &str) {
for _ in 0..500 {
let text = std::fs::read_to_string(log).unwrap_or_default();
if text
.lines()
.last()
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.is_some_and(|v| v["rootUri"] == expected)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("server did not initialize with {expected}: {}", std::fs::read_to_string(log).unwrap_or_default());
}
+6 -52
View File
@@ -248,7 +248,6 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
transform: Option<Arc<dyn MessageTransform>>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
@@ -256,38 +255,11 @@ struct Inner {
exited: CancellationToken,
}
/// Translates protocol values before serialization and after parsing, including
/// responses to requests initiated by the server.
pub trait MessageTransform: Send + Sync + 'static {
fn outgoing(&self, value: &mut Value);
fn incoming(&self, value: &mut Value);
}
#[derive(Default)]
pub struct TransportOptions {
pub framing: Framing,
pub transform: Option<Arc<dyn MessageTransform>>,
}
impl From<Framing> for TransportOptions {
fn from(framing: Framing) -> Self {
Self { framing, transform: None }
}
}
impl Inner {
fn send_value(&self, value: &Value) -> Result<(), RpcError> {
if self.transport_closed.load(Ordering::Acquire) {
return Err(RpcError::Closed);
}
let mut translated;
let value = if let Some(transform) = &self.transform {
translated = value.clone();
transform.outgoing(&mut translated);
&translated
} else {
value
};
let body = serde_json::to_vec(value).map_err(|e| RpcError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_jsonrpc::wire", "--> {}", String::from_utf8_lossy(&body));
self.out.send(body).map_err(|_| RpcError::Closed)
@@ -415,19 +387,6 @@ impl Peer {
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
framing: Framing,
) -> std::io::Result<Self> {
Self::spawn_transport(command, args, cwd, env, handler, on_stderr, framing.into())
}
/// Starts a stdio peer with message translation installed before any IO.
pub fn spawn_transport(
command: &str,
args: &[String],
cwd: &std::path::Path,
env: &[(String, String)],
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
options: TransportOptions,
) -> std::io::Result<Self> {
use std::process::Stdio;
// Windows resolves a bare name only as `.exe`; find the file a child
@@ -453,7 +412,7 @@ impl Peer {
let pid = child.id();
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
@@ -507,7 +466,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -516,7 +475,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing.into(), None)
Self::start(reader, writer, handler, true, None, framing, None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -533,7 +492,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
peer.supervise(child, None);
peer
}
@@ -544,14 +503,13 @@ impl Peer {
handler: Arc<dyn Handler>,
close_on_eof: bool,
pid: Option<u32>,
options: TransportOptions,
framing: Framing,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let TransportOptions { framing, transform } = options;
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (closed, _) = watch::channel(None);
let inner = Arc::new(Inner {
@@ -562,7 +520,6 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
transform,
reader_done: CancellationToken::new(),
exited: CancellationToken::new(),
});
@@ -828,16 +785,13 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
if let Some(transform) = &inner.transform {
transform.incoming(&mut msg);
}
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-19
View File
@@ -176,30 +176,11 @@ pub struct LanguageServerConfig {
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
/// Host-only transport configuration; never accepted from language definitions.
#[serde(skip)]
pub target: Option<ServerTarget>,
/// Separates instances when languages sharing a server select different targets.
#[serde(skip)]
pub target_key: Option<String>,
}
/// A prepared plugin wrapper. The LSP client never resolves container binaries on the host.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerTarget {
pub id: String,
pub origin: Option<ide_core::ServerId>,
pub project_root: PathBuf,
pub container_root: String,
pub mapping: ide_core::path_mapping::PathMapping,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebuggerConfig {
/// Placement in plugin run targets; absent means unsupported.
#[serde(default)]
pub target_placement: Option<String>,
/// DAP adapter name, e.g. `codelldb`, `debugpy`, `js-debug`, `delve`.
pub adapter: String,
pub command: String,
-26
View File
@@ -468,25 +468,6 @@ impl ServerCaps {
self.restriction = restriction;
return;
}
// JDT LS registers the commands of bundles that were not loaded when it initialized.
if method == "workspace/executeCommand"
&& let Some(added) = options.and_then(|o| o.get("commands")).and_then(Value::as_array)
{
if !self.raw.get("executeCommandProvider").is_some_and(Value::is_object) {
self.raw["executeCommandProvider"] = json!({ "commands": [] });
}
let provider = &mut self.raw["executeCommandProvider"];
if !provider.get("commands").is_some_and(Value::is_array) {
provider["commands"] = json!([]);
}
let commands = provider["commands"].as_array_mut().expect("commands");
for command in added {
if !commands.contains(command) {
commands.push(command.clone());
}
}
return;
}
if method == "textDocument/didSave" {
let include = options.and_then(|o| o.get("includeText")).and_then(Value::as_bool).unwrap_or(false);
self.save = Some(include);
@@ -585,13 +566,6 @@ mod tests {
assert!(c.file_operation("willRename").is_some());
c.unregister("workspace/willRenameFiles");
assert!(c.file_operation("willRename").is_none());
// Commands registered later join those announced at initialization.
let mut c = ServerCaps::parse(json!({ "executeCommandProvider": { "commands": ["a"] } }));
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b", "a"] })));
assert_eq!(c.commands(), vec!["a", "b"]);
let mut c = ServerCaps::parse(json!({}));
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b"] })));
assert_eq!(c.commands(), vec!["b"]);
}
fn restriction(features: &[&str], kinds: &[&str]) -> Restriction {
+3 -37
View File
@@ -17,30 +17,15 @@ pub type Lookup<'a> = Box<dyn Fn(&Path) -> Option<Arc<DocText>> + Send + Sync +
/// Converts server positions to IDE coordinates. For UTF-16 servers this is
/// the identity; otherwise line text comes from open documents or disk.
type MapLocation<'a> = Box<dyn Fn(Location) -> Option<Location> + Send + Sync + 'a>;
type MapEdit<'a> = Box<dyn Fn(&mut WorkspaceEdit) + Send + Sync + 'a>;
pub struct Conv<'a> {
pub enc: Encoding,
map_location: Option<MapLocation<'a>>,
map_edit: Option<MapEdit<'a>>,
lookup: Lookup<'a>,
cache: Mutex<HashMap<PathBuf, Option<Arc<DocText>>>>,
}
impl<'a> Conv<'a> {
pub fn new(enc: Encoding, lookup: impl Fn(&Path) -> Option<Arc<DocText>> + Send + Sync + 'a) -> Self {
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()), map_location: None, map_edit: None }
}
pub fn with_injections(
mut self,
location: impl Fn(Location) -> Option<Location> + Send + Sync + 'a,
edit: impl Fn(&mut WorkspaceEdit) + Send + Sync + 'a,
) -> Self {
self.map_location = Some(Box::new(location));
self.map_edit = Some(Box::new(edit));
self
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()) }
}
#[cfg(test)]
@@ -77,11 +62,7 @@ impl<'a> Conv<'a> {
pub fn location(&self, loc: &lsp::Location) -> Option<Location> {
let (path, uri) = uri_target(loc.uri.as_str());
let range = self.range(&path, loc.range);
let loc = Location { path: path.into(), range, uri };
match &self.map_location {
Some(map) => map(loc),
None => Some(loc),
}
Some(Location { path: path.into(), range, uri })
}
pub fn text_edit(&self, path: &Path, e: lsp::TextEdit) -> TextEdit {
@@ -681,9 +662,6 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
}
}
let mut edit = WorkspaceEdit { files, ..Default::default() };
if let Some(map) = &conv.map_edit {
map(&mut edit);
}
edit.deduplicate();
edit
}
@@ -851,13 +829,7 @@ pub fn workspace_symbols(conv: &Conv, r: lsp::WorkspaceSymbolResponse) -> Vec<Wo
lsp::OneOf::Left(l) => conv.location(&l)?,
lsp::OneOf::Right(w) => {
let (path, uri) = uri_target(w.uri.as_str());
{
let loc = Location { path: path.into(), range: Range::default(), uri };
match &conv.map_location {
Some(map) => map(loc)?,
None => loc,
}
}
Location { path: path.into(), range: Range::default(), uri }
}
};
Some(WorkspaceSymbol { name: s.name, kind: symbol_kind(s.kind), container: s.container_name, location })
@@ -977,7 +949,6 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<D
let target = target.as_str();
let (file, url) = match uri_to_path(target) {
Some(p) => (Some(p), None),
None if target.starts_with("openidea-target:") => (Some(PathBuf::from(target.split('#').next()?)), None),
None => (None, Some(target.to_owned())),
};
let fragment = file
@@ -1471,11 +1442,6 @@ mod tests {
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
assert_eq!(file.fragment.as_deref(), Some("L12"));
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
let virtual_link =
document_link(&conv, here, link(Some("openidea-target://target/7/usr/lib/a.php#L2"))).unwrap();
assert_eq!(virtual_link.path.as_deref(), Some(Path::new("openidea-target://target/7/usr/lib/a.php")));
assert_eq!(virtual_link.fragment.as_deref(), Some("L2"));
assert!(virtual_link.url.is_none());
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
assert!(document_link(&conv, here, link(None)).is_none());
-3
View File
@@ -139,9 +139,6 @@ impl ClientHandler {
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
if !mgr.accepts_injected_diagnostics(&path, params.version) {
return;
}
// Files opened only for a request (references search) are not the user's problems.
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
&& !mgr.docs.lock().values().any(|d| d.path == path))
-769
View File
@@ -1,769 +0,0 @@
//! Masked file documents for plugin-provided embedded languages.
use crate::{
LspManager, convert,
server::Server,
text::Encoding,
types::{Command, CompletionItem},
};
use ide_core::{DocId, FileEdit, IdeError, IdeResult, LanguageId, Location, Pos, Range, WorkspaceEdit};
use parking_lot::{Mutex, RwLock};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub const MAX_INJECTED_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_DOCUMENTS: usize = 32;
const MARKER: &str = ".openidea-injected-";
const ORIGIN: &str = "$openideaInjection";
pub(super) const COMMAND: &str = "$openideaInjectedCommand";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InjectedRange {
pub range: Range,
pub language: LanguageId,
}
/// Runs on a blocking worker, with the host's unsaved snapshot.
pub type InjectionProvider = dyn Fn(&Path, &LanguageId, u32, &str) -> Vec<InjectedRange> + Send + Sync;
#[derive(Clone)]
struct Document {
host: DocId,
host_path: PathBuf,
version: u32,
path: PathBuf,
injection: InjectedRange,
active: bool,
}
#[derive(Default)]
pub(super) struct State {
pub gate: Mutex<()>,
provider: RwLock<Option<Arc<InjectionProvider>>>,
documents: RwLock<HashMap<DocId, Document>>,
refreshed: Mutex<HashMap<DocId, u32>>,
refresh: tokio::sync::Mutex<()>,
generation: AtomicU64,
}
fn reserved(path: &Path) -> bool {
path.file_name().is_some_and(|s| {
let name = s.to_string_lossy();
name.rsplit_once(MARKER)
.and_then(|(_, suffix)| suffix.split_once('.'))
.is_some_and(|(id, _)| !id.is_empty() && id.bytes().all(|c| c.is_ascii_digit()))
}) && !path.exists()
}
fn contains(range: Range, pos: Pos) -> bool {
range.start <= pos && pos < range.end
}
fn clip(bounds: Range, range: Range) -> Option<Range> {
if range.start > range.end {
return None;
}
if range.is_empty() {
return contains(bounds, range.start).then_some(range);
}
let start = bounds.start.max(range.start);
let end = bounds.end.min(range.end);
(start < end).then_some(Range::new(start, end))
}
fn confined(bounds: Range, range: Range) -> bool {
range.start <= range.end && bounds.start <= range.start && range.end <= bounds.end
}
fn mask(text: &str, range: Range) -> String {
let mut at = Pos::default();
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if contains(range, at) || matches!(c, '\n' | '\r') {
out.push(c);
} else {
out.extend(std::iter::repeat_n(' ', c.len_utf16()));
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else if c != '\r' {
at.col += c.len_utf16() as u32;
}
}
out
}
impl LspManager {
pub fn set_injection_provider(&self, provider: Arc<InjectionProvider>) {
*self.injections.provider.write() = Some(provider);
}
pub(super) fn schedule_injections(&self, doc: DocId) {
if self.injections.provider.read().is_none() || self.is_injected_doc(doc) {
return;
}
let Some(me) = self.me.upgrade() else { return };
let Ok(snap) = self.snapshot(doc) else { return };
self.runtime.spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
if me.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
let _ = me.refresh_injections(doc).await;
}
});
}
/// Refresh on demand as well as after edits: point requests need not wait for the debounce.
pub async fn refresh_injections(&self, doc: DocId) -> IdeResult<()> {
let _refresh = self.injections.refresh.lock().await;
let generation = self.injections.generation.load(Ordering::Acquire);
let Ok(snap) = self.snapshot(doc) else { return Ok(()) };
if self.is_injected_doc(doc) || self.injections.refreshed.lock().get(&doc) == Some(&(snap.version as u32)) {
return Ok(());
}
let provider = self.injections.provider.read().clone();
let ranges = if snap.text.as_str().len() <= MAX_INJECTED_DOCUMENT_BYTES && !uri_is_virtual(&snap.path) {
if let Some(provider) = provider {
let (path, language, text, version) = (snap.path, snap.language, snap.text, snap.version);
tokio::task::spawn_blocking(move || provider(&path, &language, version as u32, text.as_str()))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?
} else {
Vec::new()
}
} else {
Vec::new()
};
if let Some(me) = self.me.upgrade() {
tokio::task::spawn_blocking(move || me.set_injections_at(doc, snap.version as u32, ranges, generation))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?;
}
if self.injection_generation() != generation || !self.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Revision of plugin providers used to reject an obsolete refresh.
pub fn injection_generation(&self) -> u64 {
self.injections.generation.load(Ordering::Acquire)
}
pub fn set_injections(&self, host: DocId, version: u32, ranges: Vec<InjectedRange>) {
self.set_injections_at(host, version, ranges, self.injection_generation());
}
pub fn set_injections_at(&self, host: DocId, version: u32, mut ranges: Vec<InjectedRange>, generation: u64) {
let _gate = self.injections.gate.lock();
if generation != self.injection_generation() {
return;
}
let Ok(snap) = self.snapshot(host) else { return };
if snap.version as u32 != version || self.is_injected_doc(host) {
return;
}
if snap.text.as_str().len() > MAX_INJECTED_DOCUMENT_BYTES {
ranges.clear();
}
ranges.sort_by_key(|r| r.range);
let mut end = Pos::default();
ranges.retain(|i| {
let r = i.range;
let valid_pos = |p: Pos| {
(p.line as usize) < snap.text.line_count()
&& snap.text.lsp_to_ide(Encoding::Utf8, snap.text.ide_to_lsp(Encoding::Utf8, p)) == p
};
if r.start >= r.end
|| r.start < end
|| !valid_pos(r.start)
|| !valid_pos(r.end)
|| i.language == snap.language
|| self.language_servers(&i.language).is_empty()
{
return false;
}
end = r.end;
true
});
ranges.truncate(MAX_DOCUMENTS);
let mut old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
old.sort_by_key(|(_, d)| d.injection.range);
let mut retained = Vec::new();
for (slot, injection) in ranges.into_iter().enumerate() {
let existing =
old.iter().position(|(id, d)| !retained.contains(id) && d.injection == injection).or_else(|| {
old.get(slot)
.filter(|(id, d)| !retained.contains(id) && d.injection.language == injection.language)
.map(|_| slot)
});
let text = mask(snap.text.as_str(), injection.range);
if let Some(index) = existing {
let (id, mut d) = old[index].clone();
if d.injection != injection {
self.clear_injected_diagnostics(&d);
}
d.version = version;
d.injection = injection;
d.active = true;
self.injections.documents.write().insert(id, d);
self.sync_injected(id, &text);
retained.push(id);
} else {
let Some(cfg) = self.languages.get(&injection.language) else { continue };
let ext = cfg
.extensions
.iter()
.find(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()))
.map(String::as_str)
.unwrap_or("txt");
let mut id = DocId::next();
let path = loop {
let path = snap.path.with_file_name(format!(
".{}{MARKER}{}.{}",
snap.path.file_name().unwrap_or_default().to_string_lossy(),
id.0,
ext
));
if !path.exists() {
break path;
}
id = DocId::next();
};
self.injections.documents.write().insert(
id,
Document {
host,
host_path: snap.path.clone(),
version,
path: path.clone(),
injection: injection.clone(),
active: true,
},
);
let servers = self.servers_for(&injection.language, &path);
self.attach(
id,
path.clone(),
crate::uri::path_to_uri(&path),
&injection.language,
version,
&text,
servers,
);
retained.push(id);
}
}
for (id, d) in old {
if !retained.contains(&id) {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
self.injections.refreshed.lock().insert(host, version);
}
/// Plugin enable/disable/reload changes the ranges even without a buffer edit.
pub fn injection_providers_changed(&self) {
let _gate = self.injections.gate.lock();
self.injections.generation.fetch_add(1, Ordering::AcqRel);
self.injections.refreshed.lock().clear();
let hosts: Vec<_> = self.docs.lock().keys().copied().collect();
for doc in hosts {
if !self.is_injected_doc(doc) {
self.close_injections(doc);
self.schedule_injections(doc);
}
}
}
pub(super) fn map_injected_event(&self, mut event: ide_core::IdeEvent) -> Option<ide_core::IdeEvent> {
if let ide_core::IdeEvent::Diagnostics { path, .. } = &mut event
&& reserved(path)
{
let docs = self.injections.documents.read();
let d = docs.values().find(|d| d.path == path.as_ref())?;
*path = d.host_path.clone().into();
}
Some(event)
}
pub(super) fn injected_request_stamp(&self, params: &Value) -> Option<(DocId, i32)> {
let path = crate::uri::uri_to_path(params.pointer("/textDocument/uri")?.as_str()?)?;
if !reserved(&path) {
return None;
}
self.docs.lock().iter().find(|(_, d)| d.path == path).map(|(id, d)| (*id, d.version))
}
pub(super) fn check_injected_request(&self, doc: DocId, version: i32) -> IdeResult<()> {
let active = self.injections.documents.read().get(&doc).is_some_and(|d| d.active);
if !active || !self.snapshot(doc).is_ok_and(|s| s.version == version) {
return Err(IdeError::cancelled());
}
Ok(())
}
pub(super) fn accepts_injected_diagnostics(&self, path: &Path, version: Option<i32>) -> bool {
if !reserved(path) {
return true;
}
let docs = self.injections.documents.read();
docs.iter().any(|(id, d)| {
d.active && d.path == path && self.snapshot(*id).is_ok_and(|s| version.is_none_or(|v| s.version == v))
})
}
pub fn injected_document(&self, host: DocId, pos: Pos) -> DocId {
let version = self.snapshot(host).ok().map(|s| s.version as u32);
self.injections
.documents
.read()
.iter()
.find(|(_, d)| d.host == host && d.active && Some(d.version) == version && contains(d.injection.range, pos))
.map_or(host, |(id, _)| *id)
}
pub fn injected_request_range(&self, doc: DocId, range: Range) -> Range {
self.clip_injected_range(doc, range).unwrap_or(range)
}
pub(super) fn is_injected_doc(&self, doc: DocId) -> bool {
self.injections.documents.read().contains_key(&doc)
}
pub(super) fn clip_injected_range(&self, doc: DocId, range: Range) -> Option<Range> {
match self.injections.documents.read().get(&doc) {
Some(d) if d.active => clip(d.injection.range, range),
Some(_) => None,
None => Some(range),
}
}
fn sync_injected(&self, id: DocId, text: &str) {
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&id) else { return };
if d.text.as_str() == text {
return;
}
d.version += 1;
Arc::make_mut(&mut d.text).set(text);
for a in &d.servers {
let Some(run) = a.server.running() else { continue };
if a.opened_gen != Some(run.generation) || run.caps.read().sync == crate::SyncKind::None {
continue;
}
let _ = run.peer.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": d.uri, "version": d.version }, "contentChanges": [{ "text": text }]
}),
);
}
drop(docs);
self.schedule_pull(id, None, Duration::ZERO);
}
pub(super) fn invalidate_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
if old.is_empty() {
return;
}
for (id, d) in old {
if let Some(d) = self.injections.documents.write().get_mut(&id) {
d.active = false;
}
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn close_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
for (id, d) in old {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn map_injected_location(&self, mut loc: Location) -> Option<Location> {
let docs = self.injections.documents.read();
if let Some(d) = docs.values().find(|d| d.path == loc.path.as_ref()) {
if !d.active {
return None;
}
loc.range = clip(d.injection.range, loc.range)?;
loc.path = d.host_path.clone().into();
loc.uri = None;
Some(loc)
} else {
(!reserved(&loc.path)).then_some(loc)
}
}
pub(super) fn map_injected_edit(&self, edit: &mut WorkspaceEdit) {
let docs = self.injections.documents.read();
edit.files.retain_mut(|f| match f {
FileEdit::Edit { path, edits, version } => {
if let Some(d) = docs.values().find(|d| d.path == path.as_ref()) {
if !d.active {
return false;
}
// Clipping replacements changes their meaning; discard edits crossing a boundary.
edits.retain(|e| confined(d.injection.range, e.range));
*path = d.host_path.clone().into();
*version = Some(d.version as i32);
!edits.is_empty()
} else {
!reserved(path)
}
}
FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => {
!reserved(path) && !docs.values().any(|d| d.path == path.as_ref())
}
FileEdit::Rename { from, to } => {
!reserved(from)
&& !reserved(to)
&& !docs.values().any(|d| d.path == from.as_ref() || d.path == to.as_ref())
}
});
}
pub(super) fn provides_injected_command(&self, host: DocId, server: Option<ide_core::ServerId>) -> bool {
let ids: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(id, d)| d.host == host || **id == host)
.map(|(id, _)| *id)
.collect();
let docs = self.docs.lock();
ids.iter()
.any(|id| docs.get(id).is_some_and(|d| d.servers.iter().any(|a| server.is_none_or(|s| a.server.id == s))))
}
pub(super) fn tag_injected_command(&self, doc: DocId, server: ide_core::ServerId, command: &mut Option<Command>) {
let Some(original) = command.take() else { return };
if self.is_injected_doc(doc) {
let mut data = Some(serde_json::to_value(&original).expect("command is JSON"));
self.tag_injected_origin(doc, &mut data);
*command = Some(Command {
title: original.title,
command: COMMAND.into(),
arguments: vec![json!({ "origin": data, "server": server })],
});
} else {
*command = Some(original);
}
}
pub(super) fn injected_command(
&self,
host: DocId,
command: Command,
) -> IdeResult<(DocId, Command, Option<ide_core::ServerId>)> {
if command.command != COMMAND {
return Ok((host, command, None));
}
let payload = command.arguments.first().ok_or_else(IdeError::cancelled)?;
let origin = payload.pointer("/origin/$openideaInjection").ok_or_else(IdeError::cancelled)?;
let doc =
origin["doc"].as_u64().and_then(|id| u32::try_from(id).ok()).map(DocId).ok_or_else(IdeError::cancelled)?;
// Acceptance may already have edited the host. Keep the server/document identity;
// execute refreshes its mask first, and cannot revive a disappeared injection.
if !self.injections.documents.read().get(&doc).is_some_and(|d| d.host == host || doc == host) {
return Err(IdeError::cancelled());
}
let command = serde_json::from_value(origin["data"].clone()).map_err(|_| IdeError::cancelled())?;
let server =
payload["server"].as_u64().map(|id| ide_core::ServerId(id as u32)).ok_or_else(IdeError::cancelled)?;
Ok((doc, command, Some(server)))
}
pub(super) fn tag_injected_origin(&self, doc: DocId, data: &mut Option<Value>) {
if let Some(d) = self.injections.documents.read().get(&doc) {
*data = Some(json!({ ORIGIN: { "doc": doc, "version": d.version, "data": data.take() } }));
}
}
pub(super) fn resolve_injected_origin(&self, host: DocId, data: &mut Option<Value>) -> IdeResult<DocId> {
let Some(origin) = data.as_ref().and_then(|v| v.get(ORIGIN)) else { return Ok(host) };
let doc = origin["doc"].as_u64().map(|id| DocId(id as u32)).ok_or_else(IdeError::cancelled)?;
let docs = self.injections.documents.read();
let d = docs
.get(&doc)
.filter(|d| {
(d.host == host || doc == host) && d.active && origin["version"].as_u64() == Some(d.version.into())
})
.ok_or_else(IdeError::cancelled)?;
if self.snapshot(d.host)?.version as u32 != d.version {
return Err(IdeError::cancelled());
}
*data = origin.get("data").filter(|v| !v.is_null()).cloned();
Ok(doc)
}
pub(super) fn bound_completion(&self, doc: DocId, item: &mut CompletionItem) -> bool {
let docs = self.injections.documents.read();
let Some(d) = docs.get(&doc) else { return !self.snapshot(doc).is_ok_and(|s| reserved(&s.path)) };
if !d.active {
return false;
}
for r in [&mut item.replace_range, &mut item.replace_range_tab].into_iter().flatten() {
let Some(clipped) = clip(d.injection.range, *r) else { return false };
*r = clipped;
}
item.additional_edits.retain(|e| confined(d.injection.range, e.range));
drop(docs);
self.tag_injected_command(doc, item.server, &mut item.command);
self.tag_injected_origin(doc, &mut item.data);
true
}
fn clear_injected_diagnostics(&self, d: &Document) {
let servers = self.servers.lock().clone();
for server in servers {
server.set_diagnostics(d.path.clone(), Vec::new());
server.clear_pulled(&d.path);
let items = server.diagnostics_for(&d.host_path);
let enc = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
self.emit_diagnostics(&server, d.host_path.clone(), items, enc);
}
}
/// One event per host/source includes every fragment and the server's native host diagnostics.
pub(super) fn injected_diagnostics(
&self,
server: &Server,
path: &Path,
enc: Encoding,
native: Vec<lsp_types::Diagnostic>,
) -> Option<(PathBuf, Vec<ide_core::Diagnostic>)> {
let docs: Vec<_> = self.injections.documents.read().values().cloned().collect();
let host_path = if let Some(d) = docs.iter().find(|d| d.path == path) {
d.host_path.clone()
} else if reserved(path) {
return None;
} else {
path.to_owned()
};
let conv = self.conv(enc);
let native = if path == host_path { native } else { server.diagnostics_for(&host_path) };
let mut items: Vec<_> =
native.into_iter().map(|d| convert::diagnostic(&conv, &host_path, d, &server.name)).collect();
for d in docs.iter().filter(|d| d.active && d.host_path == host_path) {
items.extend(server.diagnostics_for(&d.path).into_iter().filter_map(|diag| {
let mut diag = convert::diagnostic(&conv, &d.path, diag, &server.name);
if !confined(d.injection.range, diag.range)
|| (diag.range.is_empty() && !contains(d.injection.range, diag.range.start))
{
return None;
}
diag.source = Some(server.name.clone());
Some(diag)
}));
}
(!reserved(&host_path)).then_some((host_path, items))
}
}
fn uri_is_virtual(path: &Path) -> bool {
ide_core::is_virtual_uri(&path.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
struct Missing;
impl crate::Launcher for Missing {
fn launch(
&self,
_: &ide_language::LanguageServerConfig,
_: &Path,
_: Arc<dyn ide_jsonrpc::Handler>,
_: Box<dyn Fn(String) + Send + Sync>,
) -> Result<ide_jsonrpc::Peer, crate::LaunchError> {
Err(crate::LaunchError::Missing)
}
}
fn manager() -> (Arc<LspManager>, tempfile::TempDir, Arc<Mutex<Vec<ide_core::IdeEvent>>>) {
let root = tempfile::tempdir().unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let output = events.clone();
let mgr = LspManager::with_launcher(
root.path().to_path_buf(),
Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
Arc::new(move |e| output.lock().push(e)),
Arc::new(Missing),
);
(mgr, root, events)
}
fn injection(a: u32, b: u32) -> InjectedRange {
InjectedRange { range: Range::new(Pos::new(0, a), Pos::new(0, b)), language: LanguageId::new("sql") }
}
#[test]
fn routing_mapping_edits_and_limits() {
let (mgr, root, _) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "😀 select tail");
mgr.set_injections(host, 1, vec![injection(3, 9)]);
let embedded = mgr.injected_document(host, Pos::new(0, 3));
assert_ne!(host, embedded);
assert_eq!(mgr.injected_document(host, Pos::new(0, 2)), host);
assert_eq!(mgr.injected_document(host, Pos::new(0, 9)), host);
let virtual_path = mgr.snapshot(embedded).unwrap().path;
assert_eq!(virtual_path.parent(), path.parent());
assert_eq!(virtual_path.extension().unwrap(), "sql");
assert!(!virtual_path.exists());
let loc = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 99)));
let mapped = mgr.map_injected_location(loc).unwrap();
assert_eq!(mapped.path.0, path);
assert_eq!(mapped.range, injection(3, 9).range);
let outside = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 2)));
assert!(mgr.map_injected_location(outside).is_none());
let real = root.path().join(".real.openidea-injected-123.sql");
std::fs::write(&real, "real file").unwrap();
let other = Location::file(real, Range::default());
assert_eq!(mgr.map_injected_location(other.clone()), Some(other));
let mut edit = WorkspaceEdit {
files: vec![
FileEdit::Edit {
path: virtual_path.clone().into(),
edits: vec![
ide_core::TextEdit { range: injection(3, 9).range, new_text: "SELECT".into() },
ide_core::TextEdit { range: injection(0, 9).range, new_text: "bad".into() },
],
version: Some(999),
},
FileEdit::Create { path: virtual_path.clone().into(), overwrite: true },
FileEdit::Rename { from: path.clone().into(), to: virtual_path.clone().into() },
FileEdit::Delete { path: virtual_path.clone().into(), recursive: false },
],
..Default::default()
};
mgr.map_injected_edit(&mut edit);
assert_eq!(edit.files.len(), 1);
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, version: Some(1) } if p.0 == path && edits.len() == 1)
);
mgr.set_injections(host, 0, vec![]);
assert_ne!(mgr.injected_document(host, Pos::new(0, 5)), host, "stale refresh ignored");
mgr.set_injections(host, 1, vec![]);
assert_eq!(mgr.injected_document(host, Pos::new(0, 5)), host);
assert!(mgr.map_injected_location(Location::file(virtual_path, injection(3, 9).range)).is_none());
mgr.did_open(host, &path, &LanguageId::plain_text(), 2, &"a ".repeat(40));
mgr.set_injections(host, 2, (0..40).map(|n| injection(n * 2, n * 2 + 1)).collect());
assert_eq!(mgr.injections.documents.read().len(), 32);
mgr.did_close(host);
assert!(mgr.injections.documents.read().is_empty());
mgr.did_open(host, &path, &LanguageId::plain_text(), 3, &"a".repeat(MAX_INJECTED_DOCUMENT_BYTES + 1));
mgr.set_injections(host, 3, vec![injection(0, 1)]);
assert!(mgr.injections.documents.read().is_empty());
}
#[test]
fn diagnostics_merge_fragments_native_source_and_clear() {
let (mgr, root, events) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "xxsqlxxsql");
mgr.set_injections(host, 1, vec![injection(2, 5), injection(7, 10)]);
let a = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 2))).unwrap();
let b = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 7))).unwrap();
let server = a.servers[0].clone();
let diag = |start, end, message: &str| lsp_types::Diagnostic {
range: lsp_types::Range::new(lsp_types::Position::new(0, start), lsp_types::Position::new(0, end)),
message: message.into(),
source: Some("untrusted-source".into()),
..Default::default()
};
server.set_diagnostics(path.clone(), vec![diag(0, 1, "native")]);
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
})
.unwrap()
};
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
assert!(
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
}
#[test]
fn masking_preserves_utf16_crlf_and_line_edges() {
let text = "😀éSELECT\r\n中sql\r\nend";
let range = Range::new(Pos::new(0, 3), Pos::new(1, 4));
assert_eq!(mask(text, range), " SELECT\r\n中sql\r\n ");
assert_eq!(mask(text, range).encode_utf16().count(), text.encode_utf16().count());
assert_eq!(mask("a\nsql\nz", Range::new(Pos::new(1, 0), Pos::new(2, 0))), " \nsql\n ");
assert_eq!(mask("sql", Range::new(Pos::new(0, 0), Pos::new(0, 3))), "sql");
}
#[test]
fn boundaries_are_half_open_and_results_clip() {
let r = Range::new(Pos::new(1, 3), Pos::new(2, 5));
assert!(contains(r, r.start));
assert!(!contains(r, r.end));
assert!(!contains(r, Pos::new(1, 2)));
assert_eq!(clip(r, Range::new(Pos::new(0, 0), Pos::new(3, 0))), Some(r));
assert_eq!(clip(r, Range::point(r.end)), None);
assert_eq!(clip(r, Range::new(Pos::new(0, 0), r.start)), None);
}
}
-30
View File
@@ -95,33 +95,6 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
if let Some(target) = &config.target {
let argv =
format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::<Vec<_>>());
on_stderr(argv.clone());
let binary = config
.pinned
.as_ref()
.ok_or_else(|| LaunchError::Config("target wrapper has no executable pin".into()))?
.verify()
.map_err(LaunchError::Failed)?
.to_owned();
let env = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>();
return Peer::spawn_transport(
&binary.to_string_lossy(),
&config.args,
&target.project_root,
&env,
handler,
on_stderr,
ide_jsonrpc::TransportOptions {
transform: Some(Arc::new(crate::target::TargetTransform(target.clone()))),
..Default::default()
},
)
.map(|peer| (peer, Some(binary)))
.map_err(|e| LaunchError::Failed(format!("{argv}: {e}")));
}
let search = server_search_path(config);
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
@@ -152,9 +125,6 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if config.target.is_some() {
return true;
}
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
+56 -231
View File
@@ -1,6 +1,6 @@
//! Language server manager.
//!
//! Servers start lazily per `(server name, workspace root, target)` when a document of
//! Servers start lazily per `(server name, workspace root)` when a document of
//! a matching language opens. Several servers may serve one language (e.g.
//! `vtsls` + `eslint`); results are merged. Every request takes positions in
//! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`],
@@ -15,7 +15,6 @@ mod file_ops;
mod file_watch;
mod go_scope;
mod handler;
mod injections;
mod inspect;
pub mod jdtls;
mod launch;
@@ -24,7 +23,6 @@ mod loading;
mod pull;
mod scope;
mod server;
mod target;
mod tcp;
mod text;
mod ts_file_moves;
@@ -33,7 +31,6 @@ pub mod types;
mod uri;
mod virtual_doc;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
@@ -86,10 +83,6 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
/// settings before each launch; receives the server's workspace root.
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
pub type ConfigHook = Arc<ConfigFn>;
/// Returns true when target configuration replaces host toolchain configuration.
pub type TargetConfigFn = dyn Fn(&LanguageId, &mut LanguageServerConfig) -> bool + Send + Sync;
/// Fetches a bounded read-only container document after rechecking grants and trust.
pub type TargetReadFn = dyn Fn(&ide_language::ServerTarget, &str) -> IdeResult<String> + Send + Sync;
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
/// The servers a language uses, from its definition's list (user settings
@@ -188,8 +181,6 @@ pub struct LspManager {
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
config_hook: RwLock<Option<ConfigHook>>,
target_hook: RwLock<Option<Arc<TargetConfigFn>>>,
target_read_hook: RwLock<Option<Arc<TargetReadFn>>>,
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
@@ -205,7 +196,6 @@ pub struct LspManager {
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
injections: injections::State,
}
/// Fetched virtual documents kept before the cache is reset.
@@ -371,23 +361,12 @@ impl LspManager {
sink: SharedSink,
launcher: Arc<dyn Launcher>,
) -> Arc<Self> {
Arc::<Self>::new_cyclic(|me| Self {
Arc::new_cyclic(|me| Self {
me: me.clone(),
project_root,
content_roots: RwLock::new(Vec::new()),
languages,
sink: {
let me = me.clone();
Arc::new(move |event| {
if let Some(mgr) = me.upgrade() {
if let Some(event) = mgr.map_injected_event(event) {
sink.emit(event);
}
} else {
sink.emit(event);
}
})
},
sink,
launcher,
runtime: runtime_handle(),
servers: Mutex::new(Vec::new()),
@@ -395,8 +374,6 @@ impl LspManager {
apply_edit_hook: RwLock::new(None),
applied_edits: tokio::sync::broadcast::channel(64).0,
config_hook: RwLock::new(None),
target_hook: RwLock::new(None),
target_read_hook: RwLock::new(None),
prepare_hook: RwLock::new(None),
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
@@ -409,7 +386,6 @@ impl LspManager {
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
})
}
@@ -436,14 +412,12 @@ impl LspManager {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
.filter(|s| s.name == cfg.name)
.min_by_key(|s| s.root != self.project_root)
.map(|s| s.root.clone())
.unwrap_or_else(|| self.project_root.clone())
});
if let Some(s) =
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
{
if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root) {
s.add_language(language);
return s.clone();
}
@@ -490,13 +464,8 @@ impl LspManager {
/// to the open documents (and start them). Also attaches servers newly
/// enabled in settings.
fn attach_fallbacks(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
.iter()
.filter(|(_, d)| !d.uri.as_str().starts_with("openidea-target:"))
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
.collect();
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
@@ -573,8 +542,7 @@ impl LspManager {
}
tail.set_effective_config(generation, cfg.clone());
let prepare = me.prepare_hook.read().clone();
if cfg.target.is_none()
&& !cancel.is_cancelled()
if !cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
&& let Some(prepare) = prepare
{
@@ -585,7 +553,7 @@ impl LspManager {
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
// A missing binary still asks to be installed first.
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
let missing = !me.launcher.available(&cfg, &root);
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
}
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
@@ -844,27 +812,22 @@ impl LspManager {
pub(crate) fn conv(&self, enc: Encoding) -> Conv<'_> {
Conv::new(enc, move |p: &Path| self.text_for_path(p))
.with_injections(move |loc| self.map_injected_location(loc), move |edit| self.map_injected_edit(edit))
}
// ---- document sync (driven by ide-workspace's DocumentObserver) ----
pub fn did_open(&self, doc: DocId, path: &Path, language: &LanguageId, version: u32, text: &str) {
let _gate = self.injections.gate.lock();
if self.docs.lock().contains_key(&doc) {
self.close_injections(doc);
self.close_document(doc);
self.did_close(doc);
}
let servers = self.servers_for(language, path);
self.attach(doc, path.to_owned(), uri::path_to_uri(path), language, version, text, servers);
self.schedule_injections(doc);
}
/// Opens a read-only virtual document (`jdt://…`) on the server that
/// returned its URI (`didOpen` only: it is never changed or saved).
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
if self.docs.lock().contains_key(&doc) {
self.close_injections(doc);
self.close_document(doc);
self.did_close(doc);
}
let Ok(lsp_uri) = uri.parse::<lsp::Uri>() else {
tracing::debug!("not a valid URI: {uri}");
@@ -924,7 +887,6 @@ impl LspManager {
}
pub fn did_change(&self, doc: DocId, version: u32, changes: &[AppliedChange], full_text: &dyn Fn() -> String) {
let _gate = self.injections.gate.lock();
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32;
@@ -967,6 +929,9 @@ impl LspManager {
tracing::warn!("lsp text copy of {} out of sync; resending full text", d.path.display());
text.set(&full_text());
}
if targets.is_empty() {
return;
}
let full = json!([{ "text": text.as_str() }]);
for (server, run, sync, enc) in targets {
let key = match enc {
@@ -989,8 +954,6 @@ impl LspManager {
}
drop(docs);
self.schedule_pull(doc, None, pull::AFTER_EDIT);
self.invalidate_injections(doc);
self.schedule_injections(doc);
}
/// Sends the documents open on instance `generation` of `server` again, as a `didChange`
@@ -1041,12 +1004,6 @@ impl LspManager {
}
pub fn did_close(&self, doc: DocId) {
let _gate = self.injections.gate.lock();
self.close_injections(doc);
self.close_document(doc);
}
fn close_document(&self, doc: DocId) {
let Some(d) = self.docs.lock().remove(&doc) else { return };
for a in &d.servers {
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
@@ -1110,25 +1067,19 @@ impl LspManager {
cancel: Option<&CancellationToken>,
timeout: Duration,
) -> IdeResult<R> {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params);
let fut = async {
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
}
};
let result = match tokio::time::timeout(timeout, fut).await {
match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
};
if let Some((doc, version)) = stamp {
self.check_injected_request(doc, version)?;
}
result
}
// ---- features ----
@@ -1163,10 +1114,8 @@ impl LspManager {
let conv = self.conv(t.enc());
let list = out.value.get_or_insert(CompletionList { is_incomplete: false, items: Vec::new() });
list.is_incomplete |= incomplete;
list.items.extend(items.into_iter().filter_map(|i| {
let mut item = convert::completion_item(&conv, &snap.path, i, t.server.id);
self.bound_completion(doc, &mut item).then_some(item)
}));
list.items
.extend(items.into_iter().map(|i| convert::completion_item(&conv, &snap.path, i, t.server.id)));
}
Err(e) => out.fail(e),
}
@@ -1208,33 +1157,21 @@ impl LspManager {
out.finish(Vec::new())
}
pub async fn resolve_completion(&self, doc: DocId, mut item: CompletionItem) -> IdeResult<CompletionItem> {
let original = item.clone();
let host_doc = doc;
let doc = self.resolve_injected_origin(doc, &mut item.data)?;
pub async fn resolve_completion(&self, doc: DocId, item: CompletionItem) -> IdeResult<CompletionItem> {
let snap = self.snapshot(doc)?;
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(original) };
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(item) };
let Some(t) = self
.ready_targets(std::slice::from_ref(server), |c| c.has_sub("completionProvider", "resolveProvider"))
.await
.pop()
else {
return Ok(original);
return Ok(item);
};
if let Some(command) = item.command.take() {
item.command = Some(self.injected_command(doc, command)?.1);
}
let lsp_item = convert::lsp_completion_item(&snap.text, t.enc(), &item);
let resolved: lsp::CompletionItem =
self.call(&t, "completionItem/resolve", lsp_item, None, DEFAULT_TIMEOUT).await?;
if doc != host_doc {
self.check_injected_request(doc, snap.version)?;
}
let mut out = item;
convert::overlay_completion(&self.conv(t.enc()), &snap.path, &mut out, resolved, false);
if !self.bound_completion(doc, &mut out) {
return Err(IdeError::cancelled());
}
Ok(out)
}
@@ -1251,11 +1188,7 @@ impl LspManager {
// Still loading: no documentation yet, not an error.
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
Ok(Some(h)) => {
if let Some(mut h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
if let Some(range) = h.range {
let Some(range) = self.clip_injected_range(doc, range) else { continue };
h.range = Some(range);
}
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
return Ok(Some(h));
}
out.value = Some(None);
@@ -1462,8 +1395,8 @@ impl LspManager {
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
}
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources
/// are searched (`libraries_skipped`); with it and `project_wide`, those found
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
/// workspace's sources are searched (`libraries_skipped`); with it, those found
/// there arrive on `partial` before the dependencies and the JDK are searched.
#[allow(clippy::too_many_arguments)]
pub async fn references_streaming(
@@ -1630,10 +1563,7 @@ impl LspManager {
};
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
} else {
// Scoped probes (usage counts, bulk scans) run for many symbols at once:
// without `libraries`, the dependencies and the JDK are not searched.
libraries_skipped = !libraries;
let first = query(!libraries).await?;
let first = query(false).await?;
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
};
@@ -1767,14 +1697,7 @@ impl LspManager {
match r {
Ok(Some(hs)) if !hs.is_empty() => {
let conv = self.conv(t.enc());
return Ok(hs
.into_iter()
.filter_map(|h| {
let mut h = convert::highlight(&conv, &snap.path, h);
h.range = self.clip_injected_range(doc, h.range)?;
Some(h)
})
.collect());
return Ok(hs.into_iter().map(|h| convert::highlight(&conv, &snap.path, h)).collect());
}
Ok(_) => out.value = Some(Vec::new()),
Err(e) => out.fail(e),
@@ -1925,12 +1848,7 @@ impl LspManager {
actions
.unwrap_or_default()
.into_iter()
.map(|a| {
let mut action = convert::code_action(&conv, a, t.server.id);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
action
})
.map(|a| convert::code_action(&conv, a, t.server.id))
// Servers may ignore `only`, and the kinds kept for this one.
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
);
@@ -1941,10 +1859,7 @@ impl LspManager {
out.finish(Vec::new()).map(|actions| CodeActionQuery { actions, advertised_kinds })
}
pub async fn resolve_code_action(&self, doc: DocId, mut action: CodeAction) -> IdeResult<CodeAction> {
let original = action.clone();
let host_doc = doc;
let doc = self.resolve_injected_origin(doc, &mut action.data)?;
pub async fn resolve_code_action(&self, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
let snap = self.snapshot(doc)?;
let servers: Vec<Arc<Server>> = match snap.servers.iter().find(|s| s.id == action.server) {
Some(s) => vec![s.clone()],
@@ -1952,33 +1867,19 @@ impl LspManager {
};
let Some(t) = self.ready_targets(&servers, |c| c.has_sub("codeActionProvider", "resolveProvider")).await.pop()
else {
return Ok(original);
return Ok(action);
};
if let Some(command) = action.command.take() {
action.command = Some(self.injected_command(doc, command)?.1);
}
let resolved: lsp::CodeAction =
self.call(&t, "codeAction/resolve", convert::lsp_code_action(&action), None, DEFAULT_TIMEOUT).await?;
if doc != host_doc {
self.check_injected_request(doc, snap.version)?;
}
let mut action = convert::merge_code_action(&self.conv(t.enc()), action, resolved);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
Ok(action)
Ok(convert::merge_code_action(&self.conv(t.enc()), action, resolved))
}
pub async fn execute_command(&self, doc: DocId, command: Command) -> IdeResult<()> {
if command.command == injections::COMMAND {
self.refresh_injections(doc).await?;
}
let (doc, command, server) = self.injected_command(doc, command)?;
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
};
let name = command.command.clone();
let servers: Vec<_> = servers.into_iter().filter(|s| server.is_none_or(|id| s.id == id)).collect();
let targets = self.ready_targets(&servers, |c| c.commands().contains(&name)).await;
let t = targets
.first()
@@ -1992,9 +1893,6 @@ impl LspManager {
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
/// `_typescript.didOrganizeImports`): no server provides those.
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
if name == injections::COMMAND {
return self.provides_injected_command(doc, server);
}
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
@@ -2023,10 +1921,6 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
let (doc, command, pinned) = self.injected_command(doc, command)?;
if pinned.is_some_and(|id| id != server) {
return Err(IdeError::cancelled());
}
// Nothing to ask for a command its server doesn't provide (executing it decides).
if !self.provides_command(doc, Some(server), &command.command).await {
return Ok(None);
@@ -2050,19 +1944,17 @@ impl LspManager {
return Err(IdeError::unavailable("unsupported command input type"));
}
}
let mut command = Some(Command {
title: command.title,
command: response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::internal("command form omitted command"))?
.to_owned(),
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
.map_err(parse_error)?,
});
self.tag_injected_command(doc, server, &mut command);
Ok(Some(CommandForm {
command: command.expect("resolved command"),
command: Command {
title: command.title,
command: response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::internal("command form omitted command"))?
.to_owned(),
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
.map_err(parse_error)?,
},
form_fields: serde_json::from_value(fields).map_err(parse_error)?,
form_answers: serde_json::from_value(
response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])),
@@ -2080,13 +1972,6 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
if command.command == injections::COMMAND {
self.refresh_injections(doc).await?;
}
let (doc, command, pinned) = self.injected_command(doc, command)?;
if pinned.is_some_and(|id| id != server) {
return Err(IdeError::cancelled());
}
let t = self.command_target(doc, server, &command.command).await?;
let open_created = t.server.name == "gopls" && command.command == "gopls.add_test";
let mut edits = self.applied_edits.subscribe();
@@ -2785,15 +2670,8 @@ impl LspManager {
}
fn origin_server(&self, uri: &str) -> Option<Arc<Server>> {
let id = self.virtual_origins.lock().get(uri).copied();
self.servers
.lock()
.iter()
.find(|s| {
Some(s.id) == id
|| s.effective_config().target.as_ref().is_some_and(|t| target::external_path(t, uri).is_some())
})
.cloned()
let id = *self.virtual_origins.lock().get(uri)?;
self.servers.lock().iter().find(|s| s.id == id).cloned()
}
/// Contents of a virtual document, from the server that owns `uri`:
@@ -2802,29 +2680,6 @@ impl LspManager {
/// is read from its archive, a `jar:` class shown as its library source
/// (see [`library_source`]) or else decompiled by the server. Cached.
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
if uri.starts_with("openidea-target:") {
let server = self
.origin_server(uri)
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let target = server
.effective_config()
.target
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let path = target::external_path(&target, uri)
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let read = self
.target_read_hook
.read()
.clone()
.ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
let languages = server.languages();
let title = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned());
let description = Some(path.clone());
let text = tokio::task::spawn_blocking(move || read(&target, &path))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))??;
return Ok(self.store_virtual(uri, text, title, description, &languages, Some(server.id)));
}
if let Some(d) = self.virtual_docs.lock().get(uri) {
return Ok(d.clone());
}
@@ -2979,11 +2834,6 @@ impl LspManager {
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_target_hooks(&self, configure: Box<TargetConfigFn>, read: Box<TargetReadFn>) {
*self.target_hook.write() = Some(Arc::from(configure));
*self.target_read_hook.write() = Some(Arc::from(read));
}
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
*self.config_hook.write() = Some(Arc::from(hook));
}
@@ -3004,23 +2854,9 @@ impl LspManager {
cfg.launch_error = Some("Project is in Restricted Mode".into());
return cfg;
}
let in_target = self.target_hook.read().clone().is_some_and(|hook| {
server
.languages()
.iter()
.filter(|language| {
self.language_servers(language)
.iter()
.any(|c| c.name == server.name && c.target_key == server.config.target_key)
})
.any(|language| hook(language, &mut cfg))
});
if !in_target && let Some(hook) = self.config_hook.read().clone() {
if let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
if let Some(target) = &mut cfg.target {
target.origin = Some(server.id);
}
cfg
}
@@ -3041,9 +2877,6 @@ impl LspManager {
self.ensure_started(server);
}
}
if allowed != previous {
self.injection_providers_changed();
}
}
/// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers).
@@ -3091,11 +2924,6 @@ impl LspManager {
}
continue;
}
if next.target != current.target {
for doc in self.docs.lock().values_mut().filter(|d| d.uri.as_str().starts_with("openidea-target:")) {
doc.servers.retain(|a| a.server.id != s.id);
}
}
match s.status() {
ide_core::event::ServerStatus::Ready
| ide_core::event::ServerStatus::Busy
@@ -3116,13 +2944,12 @@ impl LspManager {
}
}
self.attach_fallbacks();
self.detach_shadowed_servers().await;
self.injection_providers_changed();
self.detach_shadowed_fallbacks().await;
}
/// Detaches fallbacks shadowed by enabled servers and instances whose target
/// no longer matches the document's language. Stop them once unused.
async fn detach_shadowed_servers(&self) {
/// A server enabled again shadows the fallbacks attached in its place: they
/// leave the open documents, and stop once no document uses them.
async fn detach_shadowed_fallbacks(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
@@ -3136,10 +2963,7 @@ impl LspManager {
.iter()
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| !configs
.iter()
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
&& configs.iter().any(|c| c.fallback && c.name == a.server.name)
})
.map(|a| a.server.id)
.collect();
@@ -3195,11 +3019,7 @@ impl LspManager {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let unlisted: Vec<Arc<Server>> = servers
.into_iter()
.filter(|s| {
!s.languages().iter().any(|l| {
self.language_servers(l).iter().any(|c| c.name == s.name && c.target_key == s.config.target_key)
})
})
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
.collect();
if unlisted.is_empty() {
return;
@@ -3233,8 +3053,13 @@ impl LspManager {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for server in servers {
let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
let conv = self.conv(encoding);
for (path, items) in server.diagnostics_by_path() {
self.emit_diagnostics(&server, path, items, encoding);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(
&self.sink,
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
);
}
}
}
+4 -4
View File
@@ -5,7 +5,7 @@
//! and shown together.
use crate::server::{Running, Server};
use crate::{LspManager, virtual_doc};
use crate::{LspManager, convert, virtual_doc};
use ide_core::{DocId, IdeEvent};
use ide_jsonrpc::RpcError;
use lsp_types as lsp;
@@ -189,8 +189,8 @@ impl LspManager {
items: Vec<lsp::Diagnostic>,
enc: crate::text::Encoding,
) {
if let Some((path, items)) = self.injected_diagnostics(server, &path, enc, items) {
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
let conv = self.conv(enc);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
}
+14 -51
View File
@@ -25,17 +25,12 @@ use std::time::Duration;
/// Files opened for one search at most.
pub const MAX_CANDIDATES: usize = 300;
/// Candidates checked against a server's analysis scope ([`COVERED_FILES`]) at most: most
/// are usually covered, so a common name only exceeds [`MAX_CANDIDATES`] after that check.
const MAX_SCOPE_CANDIDATES: usize = 5000;
/// 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);
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
/// Optional server command: directories whose files of its languages it all analyses.
const COVERED_ROOTS: &str = "openidea.workspace.coveredRoots";
/// Project files that may mention a symbol.
#[derive(Clone, Debug)]
@@ -47,8 +42,6 @@ pub struct CandidateQuery {
/// Only files of these languages.
pub languages: Vec<LanguageId>,
pub limit: usize,
/// Directories not to look in: the server already analyses every file there.
pub skip: Vec<PathBuf>,
}
#[derive(Clone, Debug, Default)]
@@ -99,16 +92,14 @@ impl LspManager {
*self.candidates_hook.write() = Some(Arc::from(hook));
}
async fn candidates(
&self,
t: &Target,
word: String,
limit: usize,
skip: Vec<PathBuf>,
cancel: &CancellationToken,
) -> Option<CandidateFiles> {
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, skip };
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()
}
@@ -134,12 +125,11 @@ impl LspManager {
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
let reports_scope = has_command(t, COVERED_FILES);
let limit = if reports_scope { MAX_SCOPE_CANDIDATES } else { MAX_CANDIDATES };
let candidates = async {
let w = word?;
let skip = if reports_scope { self.covered_roots(t, cancel).await } else { Vec::new() };
self.candidates(t, w, limit, skip, cancel).await
match word {
Some(w) => self.candidates(t, w, cancel).await,
None => None,
}
};
let (first, candidates) = futures::join!(query(), candidates);
let mut first = first?;
@@ -158,14 +148,10 @@ impl LspManager {
}
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();
let mut files = self.files_beyond_scope(t, files, cancel).await;
let files = self.files_beyond_scope(t, files, cancel).await;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if files.len() > MAX_CANDIDATES {
files.truncate(MAX_CANDIDATES);
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
}
if files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
@@ -194,25 +180,8 @@ impl LspManager {
}
}
/// Directories whose files `t` analyses all ([`COVERED_ROOTS`]); none when it can't say.
async fn covered_roots(&self, t: &Target, cancel: &CancellationToken) -> Vec<PathBuf> {
if !has_command(t, COVERED_ROOTS) {
return Vec::new();
}
let params = json!({ "command": COVERED_ROOTS, "arguments": [] });
let roots: IdeResult<Vec<String>> =
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
match roots {
Ok(roots) => roots.iter().filter_map(|uri| crate::uri::uri_to_path(uri)).collect(),
Err(e) => {
tracing::debug!("{}: covered roots unavailable: {}", t.server.name, e.message);
Vec::new()
}
}
}
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
if files.is_empty() || !has_command(t, COVERED_FILES) {
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
return files;
}
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
@@ -255,9 +224,7 @@ impl LspManager {
.filter(|l| l.uri.is_none())
.collect();
let Some(declaration) = declarations.first() else { return GoMentions::default() };
let Some(candidates) = self.candidates(t, word.clone(), MAX_CANDIDATES, Vec::new(), cancel).await else {
return GoMentions::default();
};
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
let files = tokio::task::spawn_blocking(move || {
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
@@ -511,7 +478,3 @@ impl Drop for TransientFiles<'_> {
}
}
}
fn has_command(t: &Target, command: &str) -> bool {
t.caps().commands().iter().any(|c| c == command)
}
-6
View File
@@ -368,12 +368,6 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
if cfg.target.is_some() {
message.push_str(&format!(
"\nargv: {:?}",
std::iter::once(&cfg.command).chain(cfg.args.iter()).collect::<Vec<_>>()
));
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
-95
View File
@@ -1,95 +0,0 @@
//! Protocol mapping for a stdio server inside a plugin target.
use ide_core::path_mapping::{Direction, file_path, file_uri, rewrite_strings};
use ide_jsonrpc::MessageTransform;
use ide_language::ServerTarget;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::Value;
pub(crate) fn virtual_prefix(target: &ServerTarget) -> String {
format!(
"openidea-target://{}/{}/",
utf8_percent_encode(&target.id, NON_ALPHANUMERIC),
target.origin.map_or(0, |id| id.0)
)
}
pub(crate) fn external_path(target: &ServerTarget, uri: &str) -> Option<String> {
let path = file_path(&format!("file:///{}", uri.strip_prefix(&virtual_prefix(target))?))?;
(path.starts_with('/')
&& !path.split('/').any(|s| s == ".." || s == ".")
&& target.mapping.map(&file_uri(&path), Direction::ToHost).is_none())
.then_some(path)
}
fn suffix(uri: &str) -> &str {
uri.find(['?', '#']).map(|i| &uri[i..]).unwrap_or("")
}
pub(crate) struct TargetTransform(pub ServerTarget);
impl MessageTransform for TargetTransform {
fn outgoing(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
// A container library path may happen to have the host root's spelling.
external_path(&self.0, s)
.map(|p| format!("{}{}", file_uri(&p), suffix(s)))
.or_else(|| self.0.mapping.map(s, Direction::ToTarget))
});
}
fn incoming(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
if let Some(mapped) = self.0.mapping.map(s, Direction::ToHost) {
return Some(mapped);
}
let path = file_path(s)?;
if !path.starts_with('/') || path.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let uri = file_uri(&path);
Some(format!("{}{}{}", virtual_prefix(&self.0), uri.strip_prefix("file:///")?, suffix(s)))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::path_mapping::PathMapping;
use serde_json::json;
#[test]
fn external_uris_are_virtual_but_plain_outside_paths_and_opaque_values_stay_intact() {
let target = ServerTarget {
id: "plugin-target:example".into(),
origin: Some(ide_core::ServerId(7)),
project_root: "/host/project".into(),
container_root: "/container".into(),
mapping: PathMapping::new("/host/project", "/container"),
};
let mapper = TargetTransform(target.clone());
let mut value = json!({"uri":"file:///container/a%20b.php", "relatedInformation":[{"location":{"uri":"file:///usr/lib/a%20b.php"}}],
"data":{"uri":"file:///container/a.php"}, "command":{"arguments":["/container/a.php"]}, "path":"/usr/lib/a.php", "link":"file:///usr/lib/a.php#L12", "alias":"file:///host/project/std.php"});
mapper.incoming(&mut value);
assert_eq!(value["uri"], "file:///host/project/a%20b.php");
let external = value["relatedInformation"][0]["location"]["uri"].as_str().unwrap();
assert!(external.starts_with("openidea-target:"));
assert_eq!(external_path(&target, external).unwrap(), "/usr/lib/a b.php");
let other = ServerTarget { origin: Some(ide_core::ServerId(8)), ..target.clone() };
assert!(external_path(&other, external).is_none());
assert!(value["link"].as_str().unwrap().ends_with("#L12"));
assert_eq!(value["path"], "/usr/lib/a.php");
assert_eq!(value["data"]["uri"], "file:///container/a.php");
assert_eq!(value["command"]["arguments"][0], "/container/a.php");
mapper.outgoing(&mut value);
assert_eq!(value["uri"], "file:///container/a%20b.php");
assert_eq!(value["relatedInformation"][0]["location"]["uri"], "file:///usr/lib/a%20b.php");
assert_eq!(value["link"], "file:///usr/lib/a.php#L12");
assert_eq!(value["alias"], "file:///host/project/std.php");
let mut configuration =
json!({"result":[{"path":"/host/project/vendor","uris":["file:///host/project/vendor/a.php"]}]});
mapper.outgoing(&mut configuration);
assert_eq!(configuration["result"][0]["path"], "/container/vendor");
assert_eq!(configuration["result"][0]["uris"][0], "file:///container/vendor/a.php");
}
}
+1
View File
@@ -50,6 +50,7 @@ impl DocText {
&self.text
}
#[cfg(test)]
pub fn line_count(&self) -> usize {
self.lines.len()
}
+1 -367
View File
@@ -1,9 +1,7 @@
//! Protocol tests against in-memory mock language servers.
use ide_core::event::ServerStatus;
use ide_core::{
CancellationToken, DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit,
};
use ide_core::{DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit};
use ide_jsonrpc::{Handler, NoopHandler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::types::*;
@@ -243,8 +241,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
@@ -3454,81 +3450,6 @@ async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
}
}
/// A common name is mentioned by more files than a search opens, but a server that reports its
/// analysis scope already covers most of them: only those beyond it count toward the limit.
#[tokio::test(flavor = "multi_thread")]
async fn covered_candidates_do_not_count_toward_the_open_limit() {
for covers_all in [true, false] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
let files: Vec<PathBuf> = (0..ide_lsp::MAX_CANDIDATES + 100)
.map(|i| {
let path = h.root.join(format!("f{i:03}.mk"));
std::fs::write(&path, "target\n").unwrap();
path
})
.collect();
let covered: Vec<String> = if covers_all { files.iter().map(|f| uri_of(f)).collect() } else { vec![] };
let responder = open_files_only(vec![]);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" {
return Some(json!(covered));
}
responder(s, method, params)
}));
let seen = grep_candidates(&h);
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert!(seen.lock().iter().all(|q| q.limit > ide_lsp::MAX_CANDIDATES));
let opened = s.notified("textDocument/didOpen").len() - 1;
if covers_all {
assert_eq!(refs.incomplete, None);
assert_eq!(opened, 0);
assert_eq!(s.requested("textDocument/references").len(), 1);
} else {
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
assert_eq!(opened, ide_lsp::MAX_CANDIDATES);
}
}
}
/// Files under the directories a server analyses fully are not read to find candidates.
#[tokio::test(flavor = "multi_thread")]
async fn candidates_skip_the_directories_the_server_covers() {
for (commands, reported) in [
(json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(["src"])),
(json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(null)),
(json!(["openidea.workspace.coveredFiles"]), json!(["src"])),
] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = commands.clone();
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
let src = h.root.join("src");
let roots = reported.as_array().map(|r| r.iter().map(|_| json!(uri_of(&src))).collect::<Vec<_>>());
let responder = open_files_only(vec![]);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" {
return Some(match params["command"].as_str() {
Some("openidea.workspace.coveredRoots") => roots.clone().map_or(json!(null), Value::Array),
_ => json!([]),
});
}
responder(s, method, params)
}));
let seen = grep_candidates(&h);
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
let skipped: Vec<Vec<PathBuf>> = seen.lock().iter().map(|q| q.skip.clone()).collect();
let expected = if commands.as_array().unwrap().len() == 2 && reported.is_array() { vec![src] } else { vec![] };
assert_eq!(skipped, vec![expected], "{commands} {reported}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
@@ -3827,32 +3748,6 @@ async fn classified_java_references_widen_without_losing_metadata() {
}
}
/// Usage counts probe many symbols at once: without `libraries` they search the sources alone.
#[tokio::test(flavor = "multi_thread")]
async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.respond("workspace/executeCommand", json!([]));
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let seen = grep_candidates(&h);
for libraries in [false, true] {
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), false, false, libraries, Default::default(), None)
.await
.unwrap();
assert_eq!(refs.libraries_skipped, !libraries);
let requested = s.requested("workspace/executeCommand");
let arguments = requested.last().unwrap()["arguments"].as_array().unwrap().clone();
assert_eq!(arguments.get(4) == Some(&json!("sources")), !libraries);
assert_eq!(arguments.len(), if libraries { 4 } else { 5 });
}
assert!(seen.lock().is_empty());
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
@@ -5222,264 +5117,3 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
for encoding in ["utf-16", "utf-8"] {
let mut caps = incremental_caps();
caps["positionEncoding"] = json!(encoding);
caps["signatureHelpProvider"] = json!({});
for cap in
["declarationProvider", "typeDefinitionProvider", "implementationProvider", "documentHighlightProvider"]
{
caps[cap] = json!(true);
}
let mut h = harness_for("sql", "sql", &[("embedded", caps)], &[]);
let builtins = LanguageRegistry::load(None).unwrap();
let mut sql = builtins.get(&LanguageId::new("sql")).unwrap();
sql.language_servers = vec![server_cfg("embedded")];
let mut native = builtins.get(&LanguageId::plain_text()).unwrap();
native.language_servers = vec![server_cfg("host")];
*h.launcher.state("host").caps.lock() = incremental_caps();
h.launcher.state("host").respond("textDocument/completion", json!([{"label":"native"}]));
let events = h.events.clone();
h.mgr = LspManager::with_launcher(
h.root.clone(),
Arc::new(LanguageRegistry::from_configs(vec![sql, native])),
Arc::new(move |e| events.lock().push(e)),
h.launcher.clone(),
);
let state = h.launcher.state("embedded");
*state.responder.lock() = Some(Arc::new(|_, method, p| {
let whole = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":99}});
let uri = p["textDocument"]["uri"].clone();
Some(match method {
"textDocument/completion" => {
json!([{"label":"one","textEdit":{"range":whole,"newText":"ONE"},"data":{"id":7},"command":{"title":"run","command":"mock.run"}}])
}
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"id":7}));
assert_eq!(p["command"]["command"], "mock.run");
json!({"label":"one","detail":"resolved"})
}
"textDocument/hover" => json!({"contents":"SQL","range":whole}),
"textDocument/signatureHelp" => json!({"signatures":[{"label":"sql()"}]}),
"textDocument/definition"
| "textDocument/declaration"
| "textDocument/typeDefinition"
| "textDocument/implementation" => json!([
{"uri":uri,"range":whole},
{"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}
]),
"textDocument/references" => json!([{"uri":uri,"range":whole}]),
"textDocument/documentHighlight" => json!([{"range":whole}]),
"textDocument/codeAction" => json!([{"title":"fix","data":{"uri":uri}}]),
"codeAction/resolve" => json!({"title":"fix","edit":{"documentChanges":[
{"textDocument":{"uri":p["data"]["uri"],"version":1},"edits":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"ONE"}]},
{"kind":"create","uri":p["data"]["uri"]}
]},"command":{"title":"run","command":"mock.run"}}),
_ => Value::Null,
})
}));
h.mgr.set_injection_provider(Arc::new(|_, _, _, text| {
text.match_indices("[[")
.filter_map(|(start, _)| {
let end = start + 2 + text[start + 2..].find("]]")?;
Some(ide_lsp::InjectedRange {
range: Range::new(
Pos::new(0, text[..start + 2].encode_utf16().count() as u32),
Pos::new(0, text[..end].encode_utf16().count() as u32),
),
language: LanguageId::new("sql"),
})
})
.collect()
}));
let host = DocId::next();
let path = h.root.join("host.java");
let source = "😀[[one]] [[two]]";
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, source);
h.mgr.refresh_injections(host).await.unwrap();
let doc = h.mgr.injected_document(host, Pos::new(0, 5));
let second = h.mgr.injected_document(host, Pos::new(0, 13));
assert_ne!(doc, host);
assert_ne!(doc, second, "separate SQL fragments must not share a document");
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 7)), host);
assert_eq!(
h.mgr.completion(host, Pos::new(0, 1), None, Default::default()).await.unwrap().items[0].label,
"native"
);
let item = h.mgr.completion(doc, Pos::new(0, 5), None, Default::default()).await.unwrap().items.remove(0);
assert_eq!(item.replace_range, Some(rng(0, 4, 0, 7)));
let resolved = h.mgr.resolve_completion(host, item).await.unwrap();
assert_eq!(resolved.detail.as_deref(), Some("resolved"));
h.mgr.execute_command(host, resolved.command.clone().unwrap()).await.unwrap();
assert_eq!(state.requested("workspace/executeCommand")[0]["command"], "mock.run");
assert_eq!(
h.mgr.hover(doc, Pos::new(0, 5), Default::default()).await.unwrap().unwrap().range,
Some(rng(0, 4, 0, 7))
);
assert!(h.mgr.signature_help(doc, Pos::new(0, 5)).await.unwrap().is_some());
for kind in [GotoKind::Definition, GotoKind::Declaration, GotoKind::TypeDefinition, GotoKind::Implementation] {
let locations = h.mgr.goto(doc, Pos::new(0, 5), kind).await.unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].path.0, path);
assert_eq!(locations[0].range, rng(0, 4, 0, 7));
}
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations[0].path.0, path);
let highlights = h.mgr.document_highlights(doc, Pos::new(0, 5)).await.unwrap();
assert_eq!(highlights[0].range, rng(0, 4, 0, 7));
let action = h.mgr.code_actions(doc, rng(0, 5, 0, 5), vec![]).await.unwrap().remove(0);
let action = h.mgr.resolve_code_action(host, action).await.unwrap();
let edit = action.edit.unwrap();
assert_eq!(edit.files.len(), 1, "resource operations on virtual paths are dropped");
assert!(
matches!(&edit.files[0],FileEdit::Edit {path:p,edits,..} if p.0 == path && edits[0].range == rng(0,4,0,7))
);
let command = action.command.unwrap();
assert!(h.mgr.provides_command(host, Some(action.server), &command.command).await);
assert!(
h.mgr
.resolve_command(host, action.server, command.clone(), vec![], Default::default())
.await
.unwrap()
.is_none()
);
h.mgr.execute_action_command(host, action.server, command, vec![], Default::default()).await.unwrap();
eventually(|| state.docs.lock().len() == 2).await;
assert!(state.docs.lock().values().any(|t| t == " one "));
assert!(state.docs.lock().values().any(|t| t == " two "));
let virtual_uris: Vec<_> = state.docs.lock().iter().map(|(u, t)| (u.clone(), t.clone())).collect();
for (uri, text) in &virtual_uris {
let col = if text.contains("one") { 4 } else { 12 };
state.peer().notify("textDocument/publishDiagnostics", json!({"uri":uri,"version":1,"diagnostics":[
{"range":{"start":{"line":0,"character":col},"end":{"line":0,"character":col+3}},"message":"embedded"},
{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"message":"outside"}
]})).unwrap();
}
let diagnostics = || {
h.events
.lock()
.iter()
.rev()
.find_map(|e| match e {
IdeEvent::Diagnostics { path: p, source, items } if p.0 == path && source == "embedded" => {
Some(items.clone())
}
_ => None,
})
.unwrap_or_default()
};
eventually(|| diagnostics().len() == 2).await;
assert!(diagnostics().iter().all(|d| d.source.as_deref() == Some("embedded")));
h.mgr.did_change(host, 2, &[AppliedChange { range: rng(0, 4, 0, 7), text: "ONE".into() }], &|| {
"😀[[ONE]] [[two]]".into()
});
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), host, "stale ranges cannot route");
h.mgr.refresh_injections(host).await.unwrap();
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), doc);
eventually(|| state.docs.lock().values().any(|t| t == " ONE ")).await;
assert!(h.mgr.resolve_completion(host, resolved).await.is_err(), "old completions cannot resolve after edits");
h.mgr.set_injections(host, 2, vec![]);
eventually(|| diagnostics().is_empty()).await;
assert!(h.events.lock().iter().all(
|e| !matches!(e,IdeEvent::Diagnostics {path,..} if path.0.to_string_lossy().contains(".openidea-injected-"))
));
h.mgr.did_close(host);
eventually(|| state.docs.lock().is_empty()).await;
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_injected_server_uses_the_normal_single_install_notification() {
let h = harness_for("sql", "sql", &[("embedded", incremental_caps())], &[]);
h.launcher.state("embedded").missing.store(true, Ordering::SeqCst);
let host = DocId::next();
let path = h.root.join("host.java");
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "sql");
let ranges = || vec![ide_lsp::InjectedRange { range: rng(0, 0, 0, 3), language: LanguageId::new("sql") }];
h.mgr.set_injections(host, 1, ranges());
h.wait_status("embedded", ServerStatus::Missing).await;
for version in 2..10 {
h.mgr.did_change(host, version, &[], &|| "sql".into());
h.mgr.set_injections(host, version, ranges());
}
assert_eq!(h.mgr.servers().len(), 1);
assert_eq!(h.statuses("embedded").iter().filter(|s| **s == ServerStatus::Missing).count(), 1);
h.mgr.did_close(host);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn languages_sharing_a_server_keep_independent_target_choices() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let language = |id: &str| {
let mut lang: LanguageConfig = toml::from_str(&format!("id='{id}'\nname='{id}'\nextensions=['{id}']")).unwrap();
lang.language_servers = vec![server_cfg("shared")];
lang
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![language("one"), language("two")]));
struct ScopedLauncher(Arc<MockLauncher>);
impl Launcher for ScopedLauncher {
fn launch(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
stderr: Box<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError> {
let mut config = config.clone();
if let Some(key) = &config.target_key {
config.name = format!("{}@{key}", config.name);
}
self.0.launch(&config, root, handler, stderr)
}
}
let launcher = Arc::new(MockLauncher::default());
for name in ["shared", "shared@container"] {
*launcher.state(name).caps.lock() = json!({"textDocumentSync":1,"hoverProvider":true});
}
let manager = LspManager::with_launcher(
root.clone(),
registry,
Arc::new(ide_core::NullSink),
Arc::new(ScopedLauncher(launcher.clone())),
);
let selected = Arc::new(std::sync::atomic::AtomicBool::new(false));
let selected_hook = selected.clone();
manager.set_servers_hook(Box::new(move |lang, defined| {
let mut configs = defined.to_vec();
if lang.as_str() == "one" && selected_hook.load(Ordering::Acquire) {
configs[0].target_key = Some("container".into());
}
configs
}));
let one = DocId::next();
let two = DocId::next();
manager.did_open(one, &root.join("a.one"), &LanguageId::new("one"), 1, "one");
manager.did_open(two, &root.join("b.two"), &LanguageId::new("two"), 1, "two");
eventually(|| manager.servers().iter().any(|s| s.status == ServerStatus::Ready)).await;
assert_eq!(manager.servers().len(), 1);
let host = manager.servers()[0].id;
selected.store(true, Ordering::Release);
manager.reconfigure().await;
eventually(|| manager.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 2).await;
assert!(manager.servers().iter().any(|s| s.id == host));
// Each document has one server. Hover must not send to both after splitting.
let state = launcher.state("shared");
state.requests.lock().clear();
let target_state = launcher.state("shared@container");
target_state.requests.lock().clear();
let _ = manager.hover(one, Pos::new(0, 0), CancellationToken::new()).await;
let _ = manager.hover(two, Pos::new(0, 0), CancellationToken::new()).await;
assert_eq!(state.requested("textDocument/hover").len(), 1);
assert_eq!(target_state.requested("textDocument/hover").len(), 1);
selected.store(false, Ordering::Release);
manager.reconfigure().await;
assert_eq!(manager.servers().len(), 1);
assert_eq!(manager.servers()[0].id, host);
manager.shutdown_all().await;
}
-6
View File
@@ -327,8 +327,6 @@ async fn missing_binary_is_reported_with_install_hint() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -600,8 +598,6 @@ async fn typescript_language_server_end_to_end() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -653,8 +649,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
-51
View File
@@ -102,54 +102,3 @@ async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn target_launch_failure_shows_exact_argv_and_stderr_tail() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let wrapper = root.join("target wrapper");
std::fs::write(&wrapper, "#!/bin/sh\ni=0\nwhile [ $i -lt 60 ]; do echo stderr-$i >&2; i=$((i+1)); done\nexit 37\n")
.unwrap();
std::fs::set_permissions(&wrapper, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let mut server: LanguageServerConfig = toml::from_str("name='target-ls'\ncommand='unused'").unwrap();
server.command = wrapper.to_string_lossy().into_owned();
server.args = vec!["literal prefix".into(), "--cwd=/container".into(), "missing-lsp".into()];
server.pinned = Some(ide_language::ProgramPin::approve(&wrapper).unwrap());
server.target = Some(ide_language::ServerTarget {
id: "test-target".into(),
origin: None,
project_root: root.clone(),
container_root: "/container".into(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), "/container"),
});
let expected =
format!("argv: {:?}", std::iter::once(&server.command).chain(server.args.iter()).collect::<Vec<_>>());
let mut language: LanguageConfig = toml::from_str("id='mock'\nname='Mock'\nextensions=['mock']").unwrap();
language.language_servers = vec![server];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let manager = LspManager::new(
root.clone(),
Arc::new(LanguageRegistry::from_configs(vec![language])),
Arc::new(move |event: IdeEvent| sink_events.lock().push(event)),
);
manager.did_open(DocId::next(), &root.join("main.mock"), &LanguageId::new("mock"), 1, "x");
let message = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(message) = events.lock().iter().find_map(|event| match event {
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
_ => None,
}) {
break message;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(message.contains("exited with code 37"), "{message}");
assert!(message.contains("stderr-59"), "{message}");
assert!(message.contains(&expected), "{message}");
assert!(!message.contains("stderr-0\n"), "{message}");
manager.shutdown_all().await;
}
-16
View File
@@ -1165,22 +1165,6 @@ impl Runtime {
})
.collect())
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
&mut self,
config: String,
tests: Vec<Vec<String>>,
invocation: Invocation,
timeout: Duration,
) -> Result<Option<Vec<String>>, CallError> {
if self.exports.test_results.is_none() {
return Err(CallError::missing_export("test-results"));
}
self.call(invocation, timeout, |e, store| {
e.test_results.as_ref().unwrap().call_select_tests(store, &config, &tests)
})
}
/// One editor-budget batch of a plugin test parser.
pub fn parse_test_results(
&mut self,
-2
View File
@@ -34,8 +34,6 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
Binary file not shown.
@@ -1 +1 @@
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06
+1 -9
View File
@@ -155,12 +155,4 @@ impl Plugin for Api14 {
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
impl openidea_plugin::TestResults for Api14 {
fn parse(&mut self, _config: &str, _chunk: &str) -> Vec<openidea_plugin::PluginTestEvent> {
vec![]
}
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
(config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect())
}
}
register_plugin!(Api14, test_results);
register_plugin!(Api14);
@@ -1 +1 @@
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5
@@ -1 +1 @@
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3
@@ -1 +1 @@
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c
@@ -1 +1 @@
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982
@@ -1 +1 @@
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623
Binary file not shown.
@@ -1 +1 @@
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb
-11
View File
@@ -89,17 +89,6 @@ impl RunConfigurations for Execution {
}
}
impl TestResults for Execution {
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
match tests.first()?.first()?.as_str() {
"none" => return None,
"trap" => panic!("selection failed"),
"timeout" => loop {
std::hint::spin_loop();
},
_ => {}
}
Some(std::iter::once(config.to_owned()).chain(tests.iter().map(|p| p.join("::"))).collect())
}
fn parse(&mut self, _: &str, chunk: &str) -> Vec<PluginTestEvent> {
if chunk == "trap" {
panic!("parser failed");
@@ -1 +1 @@
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4
@@ -1 +1 @@
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944
@@ -1 +1 @@
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d
@@ -1 +1 @@
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77
@@ -1 +1 @@
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3
@@ -1 +1 @@
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6
@@ -1 +1 @@
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e
@@ -1 +1 @@
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1
@@ -1 +1 @@
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98
@@ -1 +1 @@
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640
@@ -1 +1 @@
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf
@@ -1 +1 @@
d695409029a66c8a44f524072be1d47ed974fc8810f6fe1d8d068765184d4028
e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a
@@ -1 +1 @@
d5e81a43dc2c5298d0aaafd70c0bd91ab6cc040d70224d2936349de41253735f
1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356
@@ -1 +1 @@
c29076023ccedf2da8fd9e5a851a47bbe7131b97713c8140a20cd55608a1cc0d
2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc
@@ -1 +1 @@
616157bd77dc272f9e2dfed4c54c9d65fca110e55ad01f92b7c4f8a6136537b4
69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407
@@ -1 +1 @@
022f216f0f4ca5da56f4b2fabf5ee4a987d5e9f9a752feecbb438f9b00b1acf4
e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5
@@ -1 +1 @@
626f0e0c83e69704c0365c273b6f63d43278ee37786f38ed467ce908c30eadf7
c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150
@@ -1 +1 @@
aa141701049f5c6c0d045341c3337344ed7a368800f08237b1b7812cce216814
315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d
@@ -1 +1 @@
d6c40099cc959ef2dead996beeb4acd73c1327597cd029674026c6f2bc878300
7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19
@@ -1 +1 @@
3201f643cf868f7a358ba812f9c90e06b982d864ed141b6b4c3fdeee13cb1559
c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63
+18 -151
View File
@@ -271,11 +271,6 @@ impl Cx<'_> {
}
}
/// A filesystem path: expand IDEA's macros before converting a file URL.
fn path(&self, value: Option<&str>) -> Res<Option<String>> {
Ok(self.text(value)?.map(|v| file_url_path(&v)))
}
/// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`.
fn words(&self, value: Option<&str>) -> Res<Vec<String>> {
self.words_quoted(value, Quotes::Double)
@@ -291,16 +286,12 @@ impl Cx<'_> {
}
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
Ok(self
.words_quoted(value, quotes)?
.into_iter()
.map(|a| path_from(self.root, file_url_path(&a), dir))
.collect())
Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect())
}
/// The working directory (absolute) and the configuration's `cwd`.
fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option<PathBuf>)> {
Ok(match self.path(value)? {
Ok(match self.text(value)? {
Some(dir) => working_dir(self.root, &dir),
None => (self.root.to_path_buf(), None),
})
@@ -359,7 +350,7 @@ impl Cx<'_> {
/// options, and the working directory.
fn interpreter(&self, c: &El, ignored: &mut Vec<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.path(c.option("SDK_HOME"))? };
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? };
let (exe, mut args) = match sdk {
Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()),
None => project_python(self.root, &dir),
@@ -378,7 +369,7 @@ impl Cx<'_> {
rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false");
// PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`.
for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) {
if let Some(file) = self.path(Some(file))? {
if let Some(file) = self.text(Some(file))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
@@ -412,7 +403,7 @@ impl Cx<'_> {
ignored.push(format!("EnvFile {path}"));
continue;
}
if let Some(file) = self.path(Some(path))? {
if let Some(file) = self.text(Some(path))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
@@ -427,7 +418,7 @@ impl Cx<'_> {
if c.flag("MODULE_MODE") {
args.extend(["-m".to_owned(), script]);
} else {
args.push(path_from(self.root, file_url_path(&script), &dir));
args.push(path_from(self.root, script, &dir));
}
args.extend(self.args(c.option("PARAMETERS"), &dir)?);
let mut rc = config(name, "python", exe, args, Some("debugpy"));
@@ -489,7 +480,7 @@ impl Cx<'_> {
args.extend([manage, "test".to_owned()]);
args.extend(self.words(c.option("TARGET"))?);
if c.flag("CUSTOM_SETTINGS")
&& let Some(settings) = self.path(c.option("SETTINGS_FILE"))?
&& let Some(settings) = self.text(c.option("SETTINGS_FILE"))?
{
args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]);
}
@@ -528,7 +519,7 @@ impl Cx<'_> {
}
}
Some("CUSTOM") => {}
_ => args.push(path_from(self.root, file_url_path(&target), &dir)),
_ => args.push(path_from(self.root, target, &dir)),
}
}
if target_type.as_deref() != Some("CUSTOM")
@@ -583,7 +574,7 @@ impl Cx<'_> {
fn node_interpreter(&self, c: &El) -> Res<Option<String>> {
match c.value("node-interpreter").or_else(|| c.attr("node-interpreter")).map(str::trim) {
None | Some("" | "project") => Ok(None),
interpreter => self.path(interpreter),
interpreter => self.text(interpreter),
}
}
@@ -596,12 +587,12 @@ impl Cx<'_> {
}
fn npm(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let package_json = self.path(c.value("package-json"))?;
let package_json = self.text(c.value("package-json"))?;
let dir = match &package_json {
Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()),
None => self.root.to_path_buf(),
};
let manager = match self.path(c.value("package-manager"))? {
let manager = match self.text(c.value("package-manager"))? {
Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(),
_ => {
let pkg = std::fs::read_to_string(dir.join("package.json"))
@@ -659,14 +650,14 @@ impl Cx<'_> {
fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
let mut args: Vec<String> = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] };
if !vitest && let Some(config_file) = self.path(c.value("config-file"))? {
if !vitest && let Some(config_file) = self.text(c.value("config-file"))? {
args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]);
}
args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?);
match c.value("scope-kind").unwrap_or("ALL") {
"DIRECTORY" => args.extend(self.path(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
"DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
"TEST_FILE" | "SUITE" | "TEST" => {
args.extend(self.path(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
let names: Vec<&str> = c
.child("test-names")
.into_iter()
@@ -695,7 +686,7 @@ impl Cx<'_> {
fn node(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.attr("working-dir"))?;
let Some(file) = self.path(c.attr("path-to-js-file"))? else {
let Some(file) = self.text(c.attr("path-to-js-file"))? else {
return Err(SkipReason::NothingToRun);
};
let mut args = self.words(c.attr("node-parameters"))?;
@@ -765,11 +756,11 @@ impl Cx<'_> {
let mut params = BTreeMap::new();
// Both default to true when missing (`ShRunConfiguration.java:148-149`).
let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") {
let Some(path) = self.path(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
let path = path_from(self.root, path, &dir);
let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default();
let interpreter =
self.path(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default();
// Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH.
let path = if interpreter.is_empty() { dot_slash(path) } else { path };
@@ -870,67 +861,7 @@ const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
/// The `ENV_FILES` paths, joined with `separator` (intellij-community
/// `AbstractPythonRunConfiguration.java:303-304`, `:337`).
fn split_env_files(value: &str, separator: char) -> impl Iterator<Item = &str> {
let mut rest = value;
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
let end = rest.match_indices(separator).find_map(|(end, _)| {
let field = rest[..end].trim_start();
if separator == ':' {
// A URL's scheme and optional drive letter aren't list separators.
let scheme = field.eq_ignore_ascii_case("file");
let drive = field.strip_prefix("file:").is_some_and(|body| {
let body = body.trim_start_matches('/');
let body = body
.split_once('/')
.filter(|(host, _)| host.eq_ignore_ascii_case("localhost"))
.map_or(body, |(_, path)| path);
body.len() == 1 && body.as_bytes()[0].is_ascii_alphabetic()
});
if scheme || drive {
return None;
}
}
Some(end)
});
let field = match end {
Some(end) => {
let field = &rest[..end];
rest = &rest[end + separator.len_utf8()..];
field
}
None => std::mem::take(&mut rest),
};
Some(field)
})
.filter(|f| !f.trim().is_empty())
}
/// The path a `file:` URL names, and `value` unchanged when it is not one.
///
/// IDEA writes path options as URLs (`file://$PROJECT_DIR$`), which expand to
/// `file://C:/project` on Windows: the drive letter lands where a URL's authority
/// belongs. Parsing that as a path yields `C:project`, a drive-relative path that
/// then resolves against the project directory instead of replacing it.
fn file_url_path(value: &str) -> String {
let Some(rest) = value.strip_prefix("file:") else {
return value.to_owned();
};
let rest = match rest.strip_prefix("//").and_then(|r| r.split_once('/')) {
Some((host, path)) if host.eq_ignore_ascii_case("localhost") => path,
_ => rest,
};
let body = rest.trim_start_matches('/');
let drive = matches!(body.as_bytes(), [d, b':', ..] if d.is_ascii_alphabetic());
if drive {
body.to_owned()
} else if rest.starts_with("//") && !rest.starts_with("///") {
// A real authority (`file://server/share`): a UNC path.
format!("//{body}")
} else {
format!("/{body}")
}
value.split(separator).filter(|f| !f.trim().is_empty())
}
/// Keywords without the quotes around them (`"not slow"` → `not slow`).
@@ -1034,57 +965,6 @@ mod tests {
assert!(parse("<a><b></a>").is_err());
}
#[test]
fn file_urls_become_paths_whatever_the_drive_letter_looks_like() {
// IDEA on Windows: the drive letter sits where the authority belongs.
assert_eq!(file_url_path("file://C:/p"), "C:/p");
assert_eq!(file_url_path(r"file://C:\p"), r"C:\p");
assert_eq!(file_url_path("file:///C:/p"), "C:/p");
assert_eq!(file_url_path("file:///p/a"), "/p/a");
assert_eq!(file_url_path("file://server/share"), "//server/share");
// Not a URL: left alone, including a bare Windows path.
assert_eq!(file_url_path(r"C:\p"), r"C:\p");
assert_eq!(file_url_path("/p/a"), "/p/a");
assert_eq!(file_url_path(""), "");
}
#[test]
fn file_urls_preserve_paths_and_treat_localhost_as_local() {
for (url, expected) in [
("file:///p/100%20", "/p/100%20"),
("file:///p/a%E4", "/p/a%E4"),
("file:///p/a b/中.py", "/p/a b/中.py"),
("file://C:/a b/é", "C:/a b/é"),
("file:///C:/a%20b", "C:/a%20b"),
("file://localhost/p/a%20b", "/p/a%20b"),
("file://localhost/C:/a%20b", "C:/a%20b"),
("file://LOCALHOST/p", "/p"),
("file://server/share/a%20b", "//server/share/a%20b"),
("file:///p/%2520", "/p/%2520"),
("/p/a%20b", "/p/a%20b"),
("relative%20path", "relative%20path"),
("https://localhost/a%20b", "https://localhost/a%20b"),
] {
assert_eq!(file_url_path(url), expected, "{url}");
}
}
/// The property the bug broke: `working_dir` joins its `cwd` onto the project
/// directory, so a URL that reads as a relative path resolves inside it instead
/// of replacing it.
#[test]
fn file_urls_name_absolute_paths() {
let absolute = if cfg!(windows) {
vec!["file://C:/p", "file:///C:/p", r"file://C:\p", "file://server/share"]
} else {
vec!["file:///p/a", "file://server/share"]
};
for url in absolute {
let path = file_url_path(url);
assert!(Path::new(&path).is_absolute(), "{url} became {path:?}, which is not absolute");
}
}
#[test]
fn env_files_split_on_the_path_separator() {
assert_eq!(
@@ -1095,19 +975,6 @@ mod tests {
split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::<Vec<_>>(),
[r"C:\p\.env", r"C:\p\.env.local"]
);
assert_eq!(
split_env_files("file://$PROJECT_DIR$/.env:file://localhost/p/a%20b:.env.local", ':').collect::<Vec<_>>(),
["file://$PROJECT_DIR$/.env", "file://localhost/p/a%20b", ".env.local"]
);
assert_eq!(
split_env_files("file://C:/p/.env:file:///D:/p/.env:file://localhost/E:/p/.env%3Adev", ':')
.collect::<Vec<_>>(),
["file://C:/p/.env", "file:///D:/p/.env", "file://localhost/E:/p/.env%3Adev"]
);
assert_eq!(
split_env_files("file://C:/p/.env;file://localhost/C:/p/.env.local", ';').collect::<Vec<_>>(),
["file://C:/p/.env", "file://localhost/C:/p/.env.local"]
);
assert_eq!(split_env_files("", ':').count(), 0);
assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' });
}
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more