mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e46d61dc0 | ||
|
|
dba85e8716 | ||
|
|
c25459317b | ||
|
|
0d8ad0370a | ||
|
|
3e3088c525 | ||
|
|
331987fc17 | ||
|
|
e23073c1d4 | ||
|
|
1699306eaa | ||
|
|
c01a538ea4 | ||
|
|
7cc0d5769d | ||
|
|
9828f18300 | ||
|
|
c580dd7c69 | ||
|
|
da3db5cbd6 | ||
|
|
d1bef52041 | ||
|
|
1d4c0e1cd4 | ||
|
|
2b80c75317 |
No files matched your search
File renamed without changes.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
+1
-1
@@ -12,7 +12,7 @@
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
|
||||
@@ -21,7 +21,7 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
- `crates/ide-host` — the `openidea-server` binary for remote development and the
|
||||
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
|
||||
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
|
||||
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
|
||||
Generated
+2
-4
@@ -4232,8 +4232,10 @@ name = "ide-build"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4274,7 +4276,6 @@ dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4307,8 +4308,6 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4456,7 +4455,6 @@ dependencies = [
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
|
||||
@@ -64,7 +64,7 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
|
||||
- WeChat group — scan to join:
|
||||
|
||||
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
|
||||
|
||||
The Solarized palettes in `src/styles/tokens.css` are adapted from
|
||||
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
|
||||
See [theme sources and mapping](docs/themes.md). MIT license:
|
||||
MIT license:
|
||||
|
||||
```text
|
||||
Copyright (c) 2011 Ethan Schoonover
|
||||
@@ -290,7 +290,11 @@ THE SOFTWARE.
|
||||
|
||||
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
|
||||
adapted from GitHub’s Atom One syntax and UI themes.
|
||||
Source revisions are listed in [theme sources and mapping](docs/themes.md).
|
||||
Source revisions:
|
||||
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
|
||||
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
|
||||
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
|
||||
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
|
||||
Both syntax themes use the following MIT notice:
|
||||
|
||||
```text
|
||||
|
||||
@@ -345,6 +345,8 @@ struct Task {
|
||||
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
|
||||
config_sent: Mutex<HashSet<(String, String)>>,
|
||||
dirty: Mutex<BTreeSet<(Session, usize)>>,
|
||||
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
|
||||
streamed: Mutex<HashMap<Session, Sent>>,
|
||||
flush_scheduled: AtomicBool,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
/// Set by `delete`; held while saving.
|
||||
@@ -852,6 +854,7 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
|
||||
task.emit_task();
|
||||
@@ -877,6 +880,7 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
@@ -1129,6 +1133,7 @@ impl Task {
|
||||
idle_epoch: AtomicU64::new(0),
|
||||
config_sent: Mutex::new(HashSet::new()),
|
||||
dirty: Mutex::new(BTreeSet::new()),
|
||||
streamed: Mutex::new(HashMap::new()),
|
||||
flush_scheduled: AtomicBool::new(false),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
deleted: Mutex::new(false),
|
||||
@@ -1250,11 +1255,12 @@ impl Task {
|
||||
let indices = std::mem::take(&mut *self.dirty.lock());
|
||||
let events: Vec<AgentEvent> = {
|
||||
let s = self.state.lock();
|
||||
let mut streamed = self.streamed.lock();
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|(subagent, i)| {
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
|
||||
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -2316,3 +2322,77 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
|
||||
let command = ide_mcp::stdio_command(executable, root);
|
||||
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
|
||||
}
|
||||
|
||||
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
|
||||
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
|
||||
/// that went on from one that was replaced without comparing (or keeping) all of it.
|
||||
struct Sent {
|
||||
index: usize,
|
||||
bytes: usize,
|
||||
utf16: usize,
|
||||
tail: String,
|
||||
}
|
||||
|
||||
const SENT_TAIL: usize = 64;
|
||||
|
||||
impl Sent {
|
||||
fn new(index: usize, text: &str) -> Self {
|
||||
Self::after(index, text, 0, 0)
|
||||
}
|
||||
|
||||
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
|
||||
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
|
||||
let mut start = text.len().saturating_sub(SENT_TAIL);
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let utf16 = utf16 + text[bytes..].encode_utf16().count();
|
||||
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
|
||||
}
|
||||
|
||||
/// Whether `text` at `index` is this message, gone on.
|
||||
fn extended_by(&self, index: usize, text: &str) -> bool {
|
||||
self.index == index
|
||||
&& text.len() > self.bytes
|
||||
&& text.is_char_boundary(self.bytes)
|
||||
&& text[..self.bytes].ends_with(&self.tail)
|
||||
}
|
||||
}
|
||||
|
||||
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
|
||||
/// it extends the message last sent for the session, else the whole entry.
|
||||
fn entry_event(
|
||||
task: &str,
|
||||
streamed: &mut HashMap<Session, Sent>,
|
||||
subagent: Session,
|
||||
i: usize,
|
||||
entry: &AgentEntry,
|
||||
) -> AgentEvent {
|
||||
let text = match entry {
|
||||
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
|
||||
_ => None,
|
||||
};
|
||||
let Some(text) = text else {
|
||||
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
|
||||
streamed.remove(&subagent);
|
||||
}
|
||||
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
|
||||
};
|
||||
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
|
||||
Some(sent) => {
|
||||
let (bytes, from) = (sent.bytes, sent.utf16);
|
||||
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
|
||||
AgentEvent::Append {
|
||||
task: task.to_owned(),
|
||||
subagent,
|
||||
index: i as u32,
|
||||
from: from as u32,
|
||||
text: text[bytes..].to_owned(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
streamed.insert(subagent.clone(), Sent::new(i, text));
|
||||
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,15 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// Text was appended to entry `index` (an agent message or a thought): it read the first
|
||||
/// `from` UTF-16 units of its text when the previous event for it was sent.
|
||||
Append {
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
index: u32,
|
||||
from: u32,
|
||||
text: String,
|
||||
},
|
||||
/// An agent reported the usage limits of its account (shared by every task of `provider`).
|
||||
Limits {
|
||||
provider: String,
|
||||
|
||||
@@ -2532,3 +2532,39 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_sent_as_appends() {
|
||||
let mut streamed = HashMap::new();
|
||||
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
|
||||
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
|
||||
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
|
||||
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
|
||||
assert!(
|
||||
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
|
||||
"{next:?}"
|
||||
);
|
||||
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
|
||||
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
|
||||
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
|
||||
assert!(
|
||||
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
|
||||
"from counts UTF-16 units: {after:?}"
|
||||
);
|
||||
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
|
||||
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
|
||||
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
|
||||
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
|
||||
let tool = AgentEntry::Error { message: "x".into() };
|
||||
entry_event("t", &mut streamed, None, 4, &tool);
|
||||
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
|
||||
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
|
||||
|
||||
let long = "é".repeat(5000);
|
||||
entry_event("t", &mut streamed, None, 5, &agent(&long));
|
||||
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
|
||||
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
|
||||
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
@@ -7,7 +7,9 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
chardetng.workspace = true
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -499,7 +499,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
|
||||
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
|
||||
/// `activeByDefault`.
|
||||
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
@@ -519,7 +519,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
let text = std::fs::read_to_string(settings).ok()?;
|
||||
let text = xml::read_text(settings)?;
|
||||
let root = xml::parse(&text)?;
|
||||
let repo = root.text_of("localRepository")?.trim().to_owned();
|
||||
if repo.is_empty() {
|
||||
@@ -545,7 +545,7 @@ impl Resolver {
|
||||
fn pom(&mut self, path: &Path) -> Option<RawPom> {
|
||||
self.poms
|
||||
.entry(path.to_path_buf())
|
||||
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
|
||||
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -736,7 +736,7 @@ pub(crate) fn launch_inputs(
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let text = xml::read_text(&path)?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
@@ -1640,7 +1640,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
|
||||
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
|
||||
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
|
||||
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
|
||||
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
|
||||
let Some(next) = xml::read_text(&pp) else { return chain };
|
||||
pom = resolver.pom(&pp);
|
||||
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
chain.push((pp, next));
|
||||
@@ -2297,6 +2297,24 @@ mod tests {
|
||||
assert!(!is_maven_project(&root.join("c")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_pom_with_declared_encoding_is_a_build() {
|
||||
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
|
||||
// instead of vanishing as "No maven project".
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let (bytes, _, _) = encoding_rs::GBK.encode(
|
||||
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
|
||||
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
|
||||
);
|
||||
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
|
||||
let model = load_with_repo(root, None);
|
||||
assert_eq!(model.projects.len(), 1);
|
||||
assert_eq!(model.projects[0].artifact_id, "应用");
|
||||
assert_eq!(model.projects[0].display_name, "演示");
|
||||
assert!(is_maven_project(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_pom_in_src_is_a_build() {
|
||||
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
|
||||
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
use std::path::Path;
|
||||
|
||||
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
|
||||
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
|
||||
/// whole project as "No maven project" while CLI Maven reads it fine.
|
||||
pub fn read_text(path: &Path) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
|
||||
/// every single-byte encoding, so the head scans before decoding), then
|
||||
/// UTF-8, then a chardetng guess.
|
||||
pub fn decode_bytes(bytes: &[u8]) -> String {
|
||||
if let Some((enc, len)) = Encoding::for_bom(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Some(enc) = declared_encoding(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(bytes);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_owned();
|
||||
}
|
||||
let mut detector = chardetng::EncodingDetector::new();
|
||||
detector.feed(bytes, true);
|
||||
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
|
||||
text.into_owned()
|
||||
}
|
||||
|
||||
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
|
||||
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
|
||||
let mut rest = head.split("?>").next().unwrap_or(head);
|
||||
while let Some(i) = rest.find("encoding") {
|
||||
rest = &rest[i + "encoding".len()..];
|
||||
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
|
||||
let value = value.trim_start();
|
||||
let ("e, after) = value.as_bytes().split_first()?;
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
continue;
|
||||
}
|
||||
let after = std::str::from_utf8(after).ok()?;
|
||||
let end = after.find(quote as char)?;
|
||||
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
|
||||
return Some(enc);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
@@ -210,4 +261,19 @@ mod tests {
|
||||
assert!(parse("").is_none());
|
||||
assert!(parse("just text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_the_declared_encoding() {
|
||||
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
|
||||
let (bytes, _, _) =
|
||||
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
|
||||
let text = decode_bytes(&bytes);
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
|
||||
// An unknown label falls back to UTF-8 rather than mis-decoding.
|
||||
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
|
||||
// No declaration: plain UTF-8 passes through untouched.
|
||||
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+131
-16
@@ -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.
|
||||
@@ -61,16 +59,16 @@ pub struct Settings {
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
/// Settings › Agents.
|
||||
pub agents: AgentSettings,
|
||||
/// Tools › MCP Server (global only).
|
||||
/// Agents › MCP Server (global only).
|
||||
pub mcp_server: McpServerSettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// What the meters in an agent task's header show.
|
||||
/// Settings › Agents: what the conversation shows and how messages are sent.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSettings {
|
||||
@@ -82,15 +80,125 @@ pub struct AgentSettings {
|
||||
pub show_usage_limits: bool,
|
||||
/// What the session has cost so far, as the agent reports it.
|
||||
pub show_cost: bool,
|
||||
/// A finished turn's work folds into one line under its message, leaving the reply.
|
||||
pub fold_turns: bool,
|
||||
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
|
||||
pub smooth_text: bool,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ThinkingDisplay)]
|
||||
pub thinking: ThinkingDisplay,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ToolCallDisplay)]
|
||||
pub tool_calls: ToolCallDisplay,
|
||||
/// A tool call's output (a command's, a diff) is shown without opening it.
|
||||
pub expand_tool_output: bool,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = SendKey)]
|
||||
pub send_with: SendKey,
|
||||
/// What sending does while the agent works: queue the message for after the turn, or
|
||||
/// send it into the turn.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = WhileRunning)]
|
||||
pub while_running: WhileRunning,
|
||||
/// Settings new tasks of an agent start with: agent id → option id → value.
|
||||
#[serde(deserialize_with = "agent_defaults")]
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
|
||||
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Default for AgentSettings {
|
||||
fn default() -> Self {
|
||||
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
|
||||
Self {
|
||||
show_context_usage: true,
|
||||
show_work_time: true,
|
||||
show_usage_limits: true,
|
||||
show_cost: false,
|
||||
fold_turns: true,
|
||||
smooth_text: true,
|
||||
thinking: ThinkingDisplay::Auto,
|
||||
tool_calls: ToolCallDisplay::Grouped,
|
||||
expand_tool_output: false,
|
||||
send_with: SendKey::Enter,
|
||||
while_running: WhileRunning::Queue,
|
||||
defaults: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › MCP Server: agents use the IDE's built-in tools.
|
||||
/// A hand-edited value these settings don't know reads as the default, rather than
|
||||
/// failing the whole settings file.
|
||||
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::de::DeserializeOwned + Default,
|
||||
{
|
||||
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
|
||||
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
|
||||
Ok(agents
|
||||
.into_iter()
|
||||
.filter_map(|(agent, options)| match options {
|
||||
serde_json::Value::Object(options) => Some((
|
||||
agent,
|
||||
options
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
serde_json::Value::String(v) => Some((k, v)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// How the agent's reasoning shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThinkingDisplay {
|
||||
/// Open while it streams, folded once it ends.
|
||||
#[default]
|
||||
Auto,
|
||||
Expanded,
|
||||
Collapsed,
|
||||
}
|
||||
|
||||
/// How tool calls between messages show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ToolCallDisplay {
|
||||
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
|
||||
#[default]
|
||||
Grouped,
|
||||
/// Each call on its own line.
|
||||
Each,
|
||||
}
|
||||
|
||||
/// The key that sends a message; the other one starts a new line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SendKey {
|
||||
#[default]
|
||||
Enter,
|
||||
ModEnter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WhileRunning {
|
||||
#[default]
|
||||
Queue,
|
||||
Steer,
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
@@ -141,14 +249,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 +343,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(),
|
||||
@@ -1361,6 +1460,22 @@ mod tests {
|
||||
assert_eq!(restored.agents, settings.agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
|
||||
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.editor.font_size, 17.0);
|
||||
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
|
||||
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
|
||||
assert_eq!(
|
||||
settings.agents.defaults,
|
||||
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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(),
|
||||
@@ -145,7 +143,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(),
|
||||
@@ -546,7 +543,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) {
|
||||
@@ -567,7 +564,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
}
|
||||
};
|
||||
let launch_after_configuration =
|
||||
if spec.adapter == "gdb" && spec.target.is_none() { prepare_gdb(&spec, &binary).await? } else { false };
|
||||
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
@@ -697,17 +696,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 +710,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()
|
||||
@@ -902,23 +874,12 @@ 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 launch_after_configuration = io.launch_after_configuration && request == "launch";
|
||||
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))
|
||||
@@ -959,9 +920,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");
|
||||
@@ -972,17 +930,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
|
||||
};
|
||||
@@ -1020,8 +967,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,
|
||||
|
||||
@@ -700,10 +700,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1369,7 +1369,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
@@ -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
|
||||
@@ -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:?}"));
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -594,6 +594,13 @@ fn is_identifier(name: &str) -> bool {
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
|
||||
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
|
||||
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
|
||||
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
|
||||
if vendor == "starrocks" { (false, false) } else { (true, true) }
|
||||
}
|
||||
|
||||
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
|
||||
fn mysql_value(v: &str) -> String {
|
||||
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
|
||||
@@ -839,6 +846,8 @@ impl Pool {
|
||||
if let Some(c) = &setup.collation {
|
||||
opts = opts.collation(c);
|
||||
}
|
||||
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
|
||||
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
@@ -1465,6 +1474,15 @@ mod tests {
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
|
||||
// MySQL and its other wire-compatible presets keep sqlx's defaults.
|
||||
for vendor in ["", "mysql", "mariadb", "tidb"] {
|
||||
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
|
||||
}
|
||||
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_properties_become_pragmas() {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
|
||||
@@ -270,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
@@ -559,6 +559,7 @@ mod tests {
|
||||
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Oracle));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -208,7 +208,7 @@ authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod output;
|
||||
mod target;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
@@ -9,42 +8,7 @@ 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> {
|
||||
debug_start_request(host, window, config, None).await
|
||||
}
|
||||
|
||||
pub async fn debug_start_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
request: Option<ide_core::RequestId>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let result = debug_start_inner(host, window, config, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn debug_start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
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, cancel).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -419,9 +383,7 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move {
|
||||
debug_start_request(&c.host, &c.window, c.arg("config")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -1,121 +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,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> 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 program = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("__runTargetProgram").and_then(|p| p.as_str()).map(str::to_owned))
|
||||
.or_else(|| config.program.clone())
|
||||
.unwrap_or_else(|| dbg.command.clone());
|
||||
let metadata = serde_json::from_value(json!({"id":"debug", "name":config.name, "kind":"shell", "command":program, "args":[], "cwd":config.cwd, "env":{}, "detected":false, "isTest":false}))
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let (validator, root, target, token) = (plugins.clone(), project.root.clone(), id.clone(), cancel.clone());
|
||||
host.blocking(move || {
|
||||
validator.prepare_run_target_launch(&root, &target, &metadata, ide_plugin::RunTargetOperation::Debug, &token)
|
||||
})
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
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");
|
||||
extra.as_object_mut().unwrap().remove("__runTargetProgram");
|
||||
// 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
|
||||
}
|
||||
@@ -546,42 +546,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(®istration.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -222,7 +222,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.
|
||||
|
||||
@@ -280,14 +280,11 @@ async fn start_inner(
|
||||
let token = cancel.clone();
|
||||
let config = host
|
||||
.blocking(move || {
|
||||
let operation =
|
||||
if config.extras.params.get("plugin.targetOperation").is_some_and(|op| op == "debug") {
|
||||
ide_plugin::RunTargetOperation::Debug
|
||||
} else if config.is_test {
|
||||
ide_plugin::RunTargetOperation::Test
|
||||
} else {
|
||||
ide_plugin::RunTargetOperation::Run
|
||||
};
|
||||
let operation = if config.is_test {
|
||||
ide_plugin::RunTargetOperation::Test
|
||||
} else {
|
||||
ide_plugin::RunTargetOperation::Run
|
||||
};
|
||||
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -426,7 +426,8 @@ pub async fn vcs_log_details_batch(
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
|
||||
r.enable_log_index(&project.cache);
|
||||
r.log_details_batch_cancellable(&revisions, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,15 +87,6 @@ impl PluginHost {
|
||||
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
|
||||
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
|
||||
}
|
||||
pub fn prepare_run_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, None)
|
||||
}
|
||||
|
||||
/// Prepares a user launch, including the optional plugin validation gate.
|
||||
pub fn prepare_run_target_launch(
|
||||
&self,
|
||||
@@ -113,16 +95,6 @@ impl PluginHost {
|
||||
config: &ide_run::RunConfig,
|
||||
operation: ide_plugin::RunTargetOperation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, Some((operation, cancel)))
|
||||
}
|
||||
|
||||
fn prepare_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
launch: Option<(ide_plugin::RunTargetOperation, &ide_core::CancellationToken)>,
|
||||
) -> 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"));
|
||||
@@ -136,10 +108,7 @@ impl PluginHost {
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
|
||||
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
|
||||
let validates = self.get(&target.plugin_id)?.meta.lock().has_run_target_validation;
|
||||
if let Some((operation, cancel)) = launch
|
||||
&& validates
|
||||
{
|
||||
if self.get(&target.plugin_id)?.meta.lock().has_run_target_validation {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
|
||||
@@ -139,49 +139,11 @@ impl Host {
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
// Workspace members have independent settings; target servers have their own path mappings.
|
||||
// Workspace members have independent settings.
|
||||
lsp.set_jdtls_shared_hook(Box::new(move || {
|
||||
let effective = settings.effective();
|
||||
!is_workspace
|
||||
&& effective.languages.java.jdtls_shared
|
||||
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
|
||||
!is_workspace && settings.effective().languages.java.jdtls_shared
|
||||
}));
|
||||
}
|
||||
{
|
||||
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();
|
||||
@@ -319,23 +281,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())
|
||||
}));
|
||||
}
|
||||
{
|
||||
|
||||
@@ -410,7 +410,16 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
||||
assert_eq!(wrapped.cwd.unwrap(), f.root);
|
||||
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
|
||||
assert_eq!(
|
||||
plugins.prepare_run_target(&f.root, &target.id, &php).unwrap().args,
|
||||
plugins
|
||||
.prepare_run_target_launch(
|
||||
&f.root,
|
||||
&target.id,
|
||||
&php,
|
||||
ide_plugin::RunTargetOperation::Run,
|
||||
&Default::default()
|
||||
)
|
||||
.unwrap()
|
||||
.args,
|
||||
["wrapped", "--cwd=/container", "php", "x.php"]
|
||||
);
|
||||
}
|
||||
@@ -472,7 +481,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
}
|
||||
let targets = plugins.run_targets(&f.root);
|
||||
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
|
||||
for operation in [RunTargetOperation::Run, RunTargetOperation::Test, RunTargetOperation::Debug] {
|
||||
for operation in [RunTargetOperation::Run, RunTargetOperation::Test] {
|
||||
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
|
||||
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
|
||||
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
|
||||
@@ -484,8 +493,6 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap();
|
||||
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
|
||||
// Non-launch consumers (LSP and document reads) do not invoke validation.
|
||||
assert!(plugins.prepare_run_target(&f.root, &id("refuse"), &config).is_ok());
|
||||
let selected = id("refuse");
|
||||
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
|
||||
for is_test in [false, true] {
|
||||
@@ -499,21 +506,6 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
|
||||
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
|
||||
}
|
||||
for adapter in ["php-debug", "debugpy"] {
|
||||
let launch = ide_dap::LaunchConfig {
|
||||
name: "Refused debug".into(),
|
||||
adapter: adapter.into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app.php".into()),
|
||||
args: Vec::new(),
|
||||
cwd: Some(f.root.clone().into()),
|
||||
env: Default::default(),
|
||||
extra_json: Some(serde_json::json!({"__runTarget":selected, "__runTargetProgram":"php"}).to_string()),
|
||||
};
|
||||
let error = ide_host::ops::debug::debug_start(&f.host, "main", launch).await.unwrap_err();
|
||||
assert!(error.message.contains("Project runtime is stopped"), "{adapter}: {error}");
|
||||
assert!(f.host.debugger.sessions(None).is_empty());
|
||||
}
|
||||
let error = plugins
|
||||
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap_err();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -998,7 +998,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
|
||||
@@ -1560,11 +1559,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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+17
-95
@@ -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`],
|
||||
@@ -25,7 +25,6 @@ mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
mod target;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
@@ -87,10 +86,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
|
||||
@@ -187,7 +182,7 @@ struct JdtlsWorkspace {
|
||||
}
|
||||
|
||||
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
|
||||
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
|
||||
config.name == "jdtls"
|
||||
}
|
||||
|
||||
pub struct LspManager {
|
||||
@@ -203,8 +198,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>>>,
|
||||
@@ -413,8 +406,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),
|
||||
@@ -512,15 +503,13 @@ 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();
|
||||
}
|
||||
@@ -571,13 +560,8 @@ impl LspManager {
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
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();
|
||||
@@ -679,8 +663,7 @@ impl LspManager {
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if cfg.target.is_none()
|
||||
&& !launch_cancel.is_cancelled()
|
||||
if !launch_cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
@@ -691,7 +674,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) || launch_cancel.is_cancelled() {
|
||||
@@ -2941,15 +2924,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()
|
||||
}
|
||||
|
||||
/// The binary archive behind a library document, never its displayed tab title.
|
||||
@@ -3016,29 +2992,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());
|
||||
}
|
||||
@@ -3193,11 +3146,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));
|
||||
}
|
||||
@@ -3218,23 +3166,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
|
||||
}
|
||||
|
||||
@@ -3308,11 +3242,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
|
||||
@@ -3333,7 +3262,7 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_servers().await;
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
@@ -3370,9 +3299,9 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -3387,10 +3316,7 @@ impl LspManager {
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER))
|
||||
|| !configs
|
||||
.iter()
|
||||
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
|
||||
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
|
||||
})
|
||||
.map(|a| a.server.id)
|
||||
.collect();
|
||||
@@ -3446,11 +3372,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;
|
||||
|
||||
@@ -407,12 +407,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…"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -268,8 +268,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,52 +829,6 @@ async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| {
|
||||
let mut target = configs[0].clone();
|
||||
target.target_key = Some("container".into());
|
||||
target
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("selectedTarget".into(), toml::Value::Boolean(true));
|
||||
vec![configs[0].clone(), target]
|
||||
}));
|
||||
h.open("Main.java", "class Main {}");
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
|
||||
let initial = state.requested("initialize");
|
||||
assert_eq!(initial.len(), 4);
|
||||
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
|
||||
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
|
||||
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
|
||||
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
|
||||
assert_eq!(local["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
|
||||
assert_eq!(
|
||||
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
|
||||
3,
|
||||
"local sharing does not retire targets"
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -5991,75 +5943,3 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
|
||||
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;
|
||||
}
|
||||
@@ -425,8 +425,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");
|
||||
@@ -698,8 +696,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();
|
||||
@@ -751,8 +747,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Tools › MCP Server",
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
project.display()
|
||||
)
|
||||
};
|
||||
|
||||
@@ -156,7 +156,6 @@ pub enum PluginTestEvent {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunTargetOperation {
|
||||
Run,
|
||||
Debug,
|
||||
Test,
|
||||
}
|
||||
|
||||
|
||||
@@ -1196,7 +1196,6 @@ impl Runtime {
|
||||
let launch = wit_rtv::LaunchInfo {
|
||||
operation: match launch.operation {
|
||||
crate::RunTargetOperation::Run => wit_rtv::Operation::Run,
|
||||
crate::RunTargetOperation::Debug => wit_rtv::Operation::Debug,
|
||||
crate::RunTargetOperation::Test => wit_rtv::Operation::Test,
|
||||
},
|
||||
program: launch.program,
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
daecb28adad6f82f932ef70e042f520959d3429e9e2a23cda7df7ec05ba5f259
|
||||
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ef4cedb7ab9caa7a5a597fa8bd302e4e10607989c575af1ca0c237f2d8bb06c8
|
||||
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c215812502ccbb85b08efb9da234e6e9709deb84a492b41e771c61d4c38a05fb
|
||||
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
52efdb99877cded6fc5357d80f722975e314610b9d2bc91979b4b4cb05d8cdb8
|
||||
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0fb78e5c48f267e529ceb719ceb1e5aca82f2288071e9a0cc14809c27e7cd15a
|
||||
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5a4c7a37b5ff83d62dcc3da616da3712113fa6257e834f51705a4f7e417e519a
|
||||
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e5598d719f6e66790858758718842e4474747c6e14eb3e1c29c4584b26279ef6
|
||||
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
18a37beb12a0a3da68345314f7bfbb9521e8819702ef50a1c81f29bc3112c997
|
||||
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c464178ead4043d49d10e63430436f9e261b211b1a7ff18d146ed244975baf7d
|
||||
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
655bbe70bf3280a6e301255955d3e99e68b29242538b60060f61082977019212
|
||||
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2987ca7af6f5ae800ed3bcf826b7f85b713371cb6efa483953e0a4974248f61f
|
||||
054bdb7b732f74b0266880bfea3699bb9994bbdba4054dc2935a6cc0bcdf57de
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ff914b1c3ae4709c86e8138cdc84ea500ebde77ac09dcd3722faeffeb25b3454
|
||||
fb3097d0429782d24ac647433bb7146a29f9b28a505bd2e7693c0244dac3c1e1
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8db8367b3e0872ebcb03b7f37a55fa1510f52e7f40eb07e401a40a0019349eb8
|
||||
e6da5f8b4c30861c17c7da1b62992de6132ed6ab99f404292193e39a40a68637
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8ec23043c138a4fe9ba13cdaf13b78b56032eea14f48caddde6e5016226e3d28
|
||||
514f87032622056e8b89f1c4d7950bb0a466c9c857fe27d65a523ea7d3c35911
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
cde70a51050b29cbf553df08c6aadb660be51c9146ec1d3e186de55a3130a7c1
|
||||
8908a092da5958e55fab506e081695fc7b48ea3bf287832aaac3e27e4c2adb21
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
21ac46f028c4a151b1f89eac12ad8894d1dbc84a3489f3054dec239c87de5e4a
|
||||
2a4996cbd60e29f4c3c47748d9ac7a4ca05ff4d8c55519fb6e06cec88b868be5
|
||||
@@ -1 +1 @@
|
||||
7981067231363be183e28e9aa0dd8cffb430783080c702a1843c361187b3f267
|
||||
4385c1553a4aaed9030d462a72677d07b02eaa0d08b8c1770f42116efca95d94
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e4fafd8b7ea6d9bb6d10836c8fc133a58277f68e293410d221372bced719a192
|
||||
777d722fbe5673ca37165c2032389ea7c9518f7b0b3b51953e26ba01b50b3ec4
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c6d9a0c72cf63c1a4ddb9cb45fa7eaf2cd4d6e14d6977b6e834196ad526f0a2a
|
||||
e9b3cc0b1bf82b06ace741ccb9f9e23d0dab2f61a5695d786dec08b7839f77aa
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
004d552137bc2d2654b7343a9368c14e8b8f07a03f31fb70b7ac42481bdfac8c
|
||||
fac47c0d2e74cb236b063e8edc617a991aaf7eecf6cf71a75404310ed270f75e
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bfbadd644f0b65d36817177a935f1262baf136ea26378902d01e9e3b6eec6412
|
||||
ba6c1563853158291b0524146031363c45288757cf959cf143db60437beb4b93
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
691d78767dadaa3bb8892b15592072cd343ca618d59e82653012de78e5072b22
|
||||
57e4765ec72da0bb4768b5c097fead43e9484449abfc80c925bccd7ecc91af7e
|
||||
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user