diff --git a/Cargo.lock b/Cargo.lock index e3d45f110..8c44c1bc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4273,6 +4273,7 @@ dependencies = [ "dunce", "encoding_rs", "libc", + "percent-encoding", "serde", "serde_json", "specta", diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index 5e854ac05..759abf8e6 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] dunce.workspace = true +percent-encoding = "2" encoding_rs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index a56559fb0..24a925368 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -16,6 +16,7 @@ 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; diff --git a/crates/ide-core/src/path_mapping.rs b/crates/ide-core/src/path_mapping.rs new file mode 100644 index 000000000..09492f991 --- /dev/null +++ b/crates/ide-core/src/path_mapping.rs @@ -0,0 +1,193 @@ +//! 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 { + 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 { + 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) { + 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); + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index b249c3453..20913ed2b 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -48,6 +48,8 @@ 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, + /// Project language id → server and plugin target. Empty target uses the host. + pub language_server_targets: BTreeMap, /// Editor › Inspections profiles. pub inspections: InspectionSettings, /// Editor › Natural Languages › Spelling. @@ -139,6 +141,14 @@ 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 { @@ -233,6 +243,7 @@ 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(), diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 5606766e7..f629f6641 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -56,7 +56,7 @@ pub struct HostConfig { pub struct Host { pub(crate) mcp_executable: RwLock>, - plugin_host: RwLock>, + plugin_host: Arc>>, 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: RwLock::default(), + plugin_host: Arc::default(), mcp_executable: RwLock::default(), mcp_reports: Default::default(), mcp_requests: Default::default(), diff --git a/crates/ide-host/src/ops/lsp_target.rs b/crates/ide-host/src/ops/lsp_target.rs new file mode 100644 index 000000000..3f7aa7c8c --- /dev/null +++ b/crates/ide-host/src/ops/lsp_target.rs @@ -0,0 +1,120 @@ +//! 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 { + 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) -> 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 { + 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 { + 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")) +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 98e92e1a0..f324bfa8b 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -98,3 +98,5 @@ impl Host { self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? } } + +pub(crate) mod lsp_target; diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 8d71234c8..17f09aff8 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -181,7 +181,7 @@ pub fn set_settings( || key.starts_with("editor.codeStyle.") || key == "inspections" || key.starts_with("inspections."); - let toolchain = ["languages", "tools", "languageServers", "projectStructure"] + let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "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. diff --git a/crates/ide-host/src/plugins/run_targets.rs b/crates/ide-host/src/plugins/run_targets.rs index 8b2b01325..0126440fe 100644 --- a/crates/ide-host/src/plugins/run_targets.rs +++ b/crates/ide-host/src/plugins/run_targets.rs @@ -21,6 +21,15 @@ 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()) @@ -31,7 +40,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.sink.emit(None, PluginEvent::RunTargetsChanged); + self.targets_changed(); } } } @@ -70,12 +79,12 @@ impl PluginApi { ); drop(targets); drop(blocked); - self.shared.sink.emit(None, PluginEvent::RunTargetsChanged); + self.shared.targets_changed(); 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.sink.emit(None, PluginEvent::RunTargetsChanged); + self.shared.targets_changed(); } } } @@ -93,6 +102,9 @@ impl PluginHost { id: &str, config: &ide_run::RunConfig, ) -> IdeResult { + if !self.shared.workspace().is_some_and(|w| w.trusted(root)) { + return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted")); + } let target = self .shared .run_targets diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 6de60ad9f..80fe600d4 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -137,6 +137,41 @@ impl Host { lsp.set_execution_allowed(trusted.load(Ordering::Acquire)); lsp.set_content_roots(extra_roots.clone()); let settings = Arc::new(settings); + { + let settings = settings.clone(); + let root = root.clone(); + let plugins = self.plugin_host.clone(); + let reader_plugins = plugins.clone(); + lsp.set_target_hooks( + Box::new(move |language, cfg| { + let effective = settings.effective(); + let Some(selection) = + effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty()) + else { + return false; + }; + let Some(plugins) = plugins.read().upgrade() else { return false }; + // A withdrawn target returns the server to the host. An invalid active + // target stays visibly failed; it must never silently fall back. + if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) { + return false; + } + ide_sdk::apply::server_launch_overrides(cfg, &effective); + ide_sdk::apply::server_protocol_overrides(cfg, &effective); + if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) { + cfg.launch_error = Some(e.to_string()); + } + true + }), + Box::new(move |target, path| { + let plugins = reader_plugins + .read() + .upgrade() + .ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?; + crate::ops::lsp_target::read(&plugins, target, path) + }), + ); + } { let settings = settings.clone(); let root = root.clone(); @@ -273,7 +308,23 @@ impl Host { { let settings = settings.clone(); lsp.set_servers_hook(Box::new(move |language, defined| { - ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective()) + 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 })); } { diff --git a/crates/ide-host/tests/target_lsp.rs b/crates/ide-host/tests/target_lsp.rs new file mode 100644 index 000000000..49113b71b --- /dev/null +++ b/crates/ide-host/tests/target_lsp.rs @@ -0,0 +1,224 @@ +//! 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::(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()); +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index a44c98a15..ddb0597b5 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -248,6 +248,7 @@ struct Inner { /// Fired by [`Peer::kill`]; stops tasks and kills the child. kill: CancellationToken, pid: Option, + transform: Option>, /// 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`], @@ -255,11 +256,38 @@ 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>, +} + +impl From 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) @@ -387,6 +415,19 @@ impl Peer { handler: Arc, on_stderr: Box, framing: Framing, + ) -> std::io::Result { + 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, + on_stderr: Box, + options: TransportOptions, ) -> std::io::Result { use std::process::Stdio; // Windows resolves a bare name only as `.exe`; find the file a child @@ -412,7 +453,7 @@ impl Peer { let pid = child.id(); let on_output: Arc = Arc::from(on_stderr); - let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone())); + let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone())); let stderr_task = stderr.map(|stderr| { let name = command.to_owned(); @@ -466,7 +507,7 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None, Framing::ContentLength, None) + Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None) } /// [`connect`](Self::connect) with the given wire framing. @@ -475,7 +516,7 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None, framing, None) + Self::start(reader, writer, handler, true, None, framing.into(), None) } /// [`Peer::connect`] over streams served by `child` (a server reached over a socket @@ -492,7 +533,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, None); + let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None); peer.supervise(child, None); peer } @@ -503,13 +544,14 @@ impl Peer { handler: Arc, close_on_eof: bool, pid: Option, - framing: Framing, + options: TransportOptions, on_noise: Option>, ) -> 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::>(); let (closed, _) = watch::channel(None); let inner = Arc::new(Inner { @@ -520,6 +562,7 @@ impl Peer { closed, kill: CancellationToken::new(), pid, + transform, reader_done: CancellationToken::new(), exited: CancellationToken::new(), }); @@ -785,13 +828,16 @@ async fn handle_request(inner: Arc, handler: Arc, id: Value, fn dispatch(inner: &Arc, notif_tx: &std::sync::mpsc::Sender, body: &[u8]) { tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body)); - let msg: Value = match serde_json::from_slice(body) { + let mut 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; diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 3c6ca323a..fb3ff0807 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -176,6 +176,22 @@ pub struct LanguageServerConfig { /// Set on plugin languages: start exactly this approved program. #[serde(skip)] pub pinned: Option, + /// Host-only transport configuration; never accepted from language definitions. + #[serde(skip)] + pub target: Option, + /// Separates instances when languages sharing a server select different targets. + #[serde(skip)] + pub target_key: Option, +} + +/// 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, + pub project_root: PathBuf, + pub container_root: String, + pub mapping: ide_core::path_mapping::PathMapping, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 7c21b9a8d..08d4e579a 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -977,6 +977,7 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option (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 @@ -1470,6 +1471,11 @@ 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()); diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index e7ce586b5..0acc5e4aa 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -95,6 +95,33 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result<(Peer, Option), LaunchError> { + if let Some(target) = &config.target { + let argv = + format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::>()); + 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::>(); + 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. @@ -125,6 +152,9 @@ 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(); } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index da3be48a2..e3a60bd34 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1,6 +1,6 @@ //! Language server manager. //! -//! Servers start lazily per `(server name, workspace root)` when a document of +//! Servers start lazily per `(server name, workspace root, target)` 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`], @@ -24,6 +24,7 @@ mod loading; mod pull; mod scope; mod server; +mod target; mod tcp; mod text; mod ts_file_moves; @@ -85,6 +86,10 @@ pub type ApplyEditHook = Arc 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; +/// 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 + 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 @@ -183,6 +188,8 @@ pub struct LspManager { apply_edit_hook: RwLock>, applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>, config_hook: RwLock>, + target_hook: RwLock>>, + target_read_hook: RwLock>>, prepare_hook: RwLock>>, servers_hook: RwLock>>, candidates_hook: RwLock>>, @@ -388,6 +395,8 @@ 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), @@ -427,12 +436,14 @@ impl LspManager { // Outside the project (library sources, …): reuse a running instance. servers .iter() - .filter(|s| s.name == cfg.name) + .filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key) .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) { + if let Some(s) = + servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key) + { s.add_language(language); return s.clone(); } @@ -479,8 +490,13 @@ impl LspManager { /// to the open documents (and start them). Also attaches servers newly /// enabled in settings. fn attach_fallbacks(&self) { - let open: Vec<(DocId, LanguageId, PathBuf)> = - self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect(); + 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 wanted: Vec<(DocId, Vec>)> = open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect(); let mut start = Vec::new(); @@ -557,7 +573,8 @@ impl LspManager { } tail.set_effective_config(generation, cfg.clone()); let prepare = me.prepare_hook.read().clone(); - if !cancel.is_cancelled() + if cfg.target.is_none() + && !cancel.is_cancelled() && me.launcher.available(&cfg, &tail.root) && let Some(prepare) = prepare { @@ -568,7 +585,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 = !me.launcher.available(&cfg, &root); + let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root); return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { @@ -2765,8 +2782,15 @@ impl LspManager { } fn origin_server(&self, uri: &str) -> Option> { - let id = *self.virtual_origins.lock().get(uri)?; - self.servers.lock().iter().find(|s| s.id == id).cloned() + 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() } /// Contents of a virtual document, from the server that owns `uri`: @@ -2775,6 +2799,29 @@ 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 { + 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()); } @@ -2929,6 +2976,11 @@ 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, read: Box) { + *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) { *self.config_hook.write() = Some(Arc::from(hook)); } @@ -2949,9 +3001,23 @@ impl LspManager { cfg.launch_error = Some("Project is in Restricted Mode".into()); return cfg; } - if let Some(hook) = self.config_hook.read().clone() { + 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() { hook(&mut cfg, &server.root); } + if let Some(target) = &mut cfg.target { + target.origin = Some(server.id); + } cfg } @@ -3022,6 +3088,11 @@ 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 @@ -3042,13 +3113,13 @@ impl LspManager { } } self.attach_fallbacks(); - self.detach_shadowed_fallbacks().await; + self.detach_shadowed_servers().await; self.injection_providers_changed(); } - /// 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) { + /// 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) { 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)> = Vec::new(); @@ -3062,7 +3133,10 @@ impl LspManager { .iter() .filter(|a| { !wanted.iter().any(|w| w.id == a.server.id) - && configs.iter().any(|c| c.fallback && c.name == a.server.name) + && (configs.iter().any(|c| c.fallback && c.name == a.server.name) + || !configs + .iter() + .any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key)) }) .map(|a| a.server.id) .collect(); @@ -3118,7 +3192,11 @@ impl LspManager { let servers: Vec> = self.servers.lock().clone(); let unlisted: Vec> = servers .into_iter() - .filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name))) + .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) + }) + }) .collect(); if unlisted.is_empty() { return; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 59a7a7bda..2a38dc55d 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -368,6 +368,12 @@ 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::>() + )); + } let delay = self.record_crash(); message.push_str(if delay.is_some() { "\nRestarting…" diff --git a/crates/ide-lsp/src/target.rs b/crates/ide-lsp/src/target.rs new file mode 100644 index 000000000..0f1d893d6 --- /dev/null +++ b/crates/ide-lsp/src/target.rs @@ -0,0 +1,95 @@ +//! 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 { + 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"); + } +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index dd2a05337..fe332d9b1 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1,7 +1,9 @@ //! Protocol tests against in-memory mock language servers. use ide_core::event::ServerStatus; -use ide_core::{DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit}; +use ide_core::{ + CancellationToken, DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit, +}; use ide_jsonrpc::{Handler, NoopHandler, Peer}; use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig}; use ide_lsp::types::*; @@ -241,6 +243,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig { tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, + target: None, + target_key: None, } } @@ -5306,3 +5310,75 @@ 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); + impl Launcher for ScopedLauncher { + fn launch( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + stderr: Box, + ) -> Result { + 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; +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 8df943d60..7bd547726 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -327,6 +327,8 @@ 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"); @@ -598,6 +600,8 @@ 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(); @@ -649,6 +653,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option&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::>()); + 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; +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 14c689878..6fe598550 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -1014,6 +1014,8 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, + target: None, + target_key: None, } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index dcef83a8d..9d1761dc9 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -41,6 +41,8 @@ fn server(name: &str) -> LanguageServerConfig { tsserver_project_extras: Default::default(), resync_on_configuration: false, pinned: None, + target: None, + target_key: None, } } diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs index 6e5e67f96..ca1a1cbc2 100644 --- a/crates/ide-workspace/src/documents/workspace_edit.rs +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -179,6 +179,9 @@ fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult { let mut files = Vec::new(); for file in &edit.files { let resolve = |path: &Path, text: bool| -> IdeResult { + if ide_core::is_virtual_uri(&path.to_string_lossy()) { + return Err(IdeError::invalid("library documents are always read-only")); + } if !changed.is_empty() { return after_resources(path, &changed, 0); } diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index aaed427b4..37f9d7c8c 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -191,6 +191,8 @@ impl SettingsStore { let Some(mut layer) = read_layer(&file) else { continue }; if scope == SettingsScope::Project { remove_global_settings(&mut layer); + } else if let Some(layer) = layer.as_object_mut() { + layer.remove("languageServerTargets"); } if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); @@ -216,6 +218,11 @@ impl SettingsStore { /// `null` removes the key from that layer. Unknown keys and values of the /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { + if scope == SettingsScope::Global + && (key == "languageServerTargets" || key.starts_with("languageServerTargets.")) + { + return Err(IdeError::invalid("language server targets belong to project settings")); + } if scope == SettingsScope::Project && (key == "editor.customSchemes" || key.starts_with("editor.customSchemes.") @@ -677,4 +684,26 @@ mod tests { .enabled ); } + #[test] + fn language_server_targets_are_project_only_and_default_to_the_host() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&config).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(config.join("settings.toml"), "[languageServerTargets.php]\ntarget = 'global-target'\n") + .unwrap(); + let settings = SettingsStore::load(&config, Some(&project)); + assert!(settings.effective().language_server_targets.is_empty()); + assert!(settings.set(SettingsScope::Global, "languageServerTargets", json!({})).is_err()); + let selected = settings + .set( + SettingsScope::Project, + "languageServerTargets", + json!({"php":{"target":"container","server":"intelephense"}}), + ) + .unwrap(); + assert_eq!(selected.language_server_targets["php"].target, "container"); + assert_eq!(settings.reload().language_server_targets["php"].server, "intelephense"); + } } diff --git a/plugins/README.md b/plugins/README.md index a758b5ccd..f9ecf9517 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -2415,7 +2415,8 @@ A missing target fails the run rather than silently using the local tool. For each launch the host checks project trust and the wrapper's current process grant and executable pin, including per-project executable choices. Debugging on -plugin targets returns `invalid`; language servers are not run in these targets. +plugin targets returns `invalid`. Language servers can opt into targets with a +`container_root` as described below. There are at most 32 targets per plugin/project, 64 unique command overrides and 256 arguments per prefix (16 KiB each, 256 KiB metadata per registration). @@ -2452,5 +2453,34 @@ or a named target). The `languages.php.runTarget` / `languages.node.runTarget` settings store a selector id, empty for the default, or `local` for local execution. Code-created PHP/Node runs, Composer/npm scripts, PHPUnit and Node tests use that choice through the normal Run flow. Explicit per-configuration targets take precedence. Choosing Local -machine explicitly overrides defaults. Language servers, tool discovery/version -probes and external analysis helpers remain local to the host. +machine explicitly overrides defaults. Tool discovery/version probes and external +analysis helpers remain local to the host. + +Language servers run on the host by default. In the PHP and Node interpreter +pages, **Language server: run in target** selects a plugin target independently +of runs. **Languages & Frameworks › Language server targets** offers the same +choice for every language, including plugin languages. These are project-only +`languageServerTargets. = { target, server }` settings; an empty +`target` uses the host and an empty `server` picks the first configured server. +Only plugin targets with `container_root` are eligible. The selected configured +server is used instead of the language's other servers while this option is on. +Registration and the first run-target prompt never enable it automatically. + +Install that server and its runtime **inside the container**. The host wraps the +language definition's command/arguments (or its Language Servers overrides) with +the target's normal argv construction, using the project root as cwd. It uses +stdio, rechecks the wrapper's process grant, pin and project trust, and does not +substitute IDE-downloaded host binaries or host interpreter paths. A failed +launch shows the exact argv and stderr tail in language-server status, without +falling back. Changing the setting restarts the server; withdrawing the target +returns it to the host. SSH projects use this same path on `openidea-server`. + +Protocol file URIs and paths under the project map bidirectionally to +`container_root`, including workspace edits and configuration replies. Opaque +`data` and command `arguments` remain untouched. File URIs outside the container +project become read-only library documents, fetched with the granted wrapper's +`cat -- ` command (no shell, at most 4 MiB of UTF-8 text, 10-second timeout). +The virtual URI identifies its originating server, including for document links +and diagnostic-related locations. Navigation inside these documents returns to +that server; editing and saving +are disabled. No WIT signature change is needed for this opt-in host setting. diff --git a/src/bindings.ts b/src/bindings.ts index 496ebca77..f30f8dac6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5427,6 +5427,12 @@ export type LanguageServerSettings_Serialize = { codeActionKinds?: string[] | null, }; +export type LanguageServerTarget = { + target?: string, + /** Empty selects the first configured server for the language. */ + server?: string, +}; + export type LanguagesSettings = { /** Explicit plugin formatter, by language id. Empty entries use the normal formatter policy. */ pluginFormatters?: { [key in string]: string }, @@ -7857,6 +7863,8 @@ export type Settings_Deserialize = { * options on top of the language definition, or a server defined here. */ languageServers?: { [key in string]: LanguageServerSettings_Deserialize }, + /** Project language id → server and plugin target. Empty target uses the host. */ + languageServerTargets?: { [key in string]: LanguageServerTarget }, /** Editor › Inspections profiles. */ inspections?: InspectionSettings, /** Editor › Natural Languages › Spelling. */ @@ -7919,6 +7927,8 @@ export type Settings_Serialize = { * options on top of the language definition, or a server defined here. */ languageServers: { [key in string]: LanguageServerSettings_Serialize }, + /** Project language id → server and plugin target. Empty target uses the host. */ + languageServerTargets: { [key in string]: LanguageServerTarget }, /** Editor › Inspections profiles. */ inspections: InspectionSettings, /** Editor › Natural Languages › Spelling. */ diff --git a/src/editor/lsp/links.test.ts b/src/editor/lsp/links.test.ts index 801d3b616..1ff6f758b 100644 --- a/src/editor/lsp/links.test.ts +++ b/src/editor/lsp/links.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { commands } from "@/platform/ipc"; +import { provideEditorService } from "@/workbench/contributions"; +import { describe, expect, it, vi } from "vitest"; import { Text } from "@codemirror/state"; -import { fragmentOffset, linkReference, looksLikePath, resolvePath, textReference } from "./links"; +import { fragmentOffset, followReference, linkReference, looksLikePath, resolvePath, textReference } from "./links"; const at = (src: string, marker = "|") => { const offset = src.indexOf(marker); @@ -90,3 +92,30 @@ describe("links and fragments", () => { expect(fragmentOffset("x", "/i.html", "nope")).toBeNull(); }); }); + +it("opens a container document link in the editor and resolves its fragment through virtual content", async () => { + const uri = "openidea-target://target/7/usr/lib/library.php"; + const openFile = vi.fn(async () => {}); + provideEditorService({ openFile } as never); + const read = vi.spyOn(commands, "lspVirtualDocument").mockResolvedValue({ + status: "ok", + data: { + uri, + text: " { if (ref.kind !== "file") return; let selection: { line: number; col: number } | undefined; if (ref.fragment) { - const text = await call(commands.fsReadText(ref.path)).catch(() => null); + const text = await ( + isVirtualUri(ref.path) + ? call(commands.lspVirtualDocument(ref.path)).then((doc) => doc.text) + : call(commands.fsReadText(ref.path)) + ).catch(() => null); const offset = text === null ? null : fragmentOffset(text, ref.path, ref.fragment); if (text !== null && offset !== null) { const before = text.slice(0, offset); @@ -202,7 +207,7 @@ export async function referenceAt(view: EditorView, doc: EditorDocContext, offse if (!ref || doc.virtual) return ref?.kind === "url" ? ref : null; return resolvePath(ref, doc.path); } - if (await isFile(link.path)) return link; + if (isVirtualUri(link.path) || (await isFile(link.path))) return link; // Servers resolve `/x` against the file system or workspace root; web roots (`public/`) are ours. const own = ref && !doc.virtual ? await resolvePath(ref, doc.path) : null; if (own) return own; diff --git a/src/features/settings/__tests__/languageServerTargets.test.tsx b/src/features/settings/__tests__/languageServerTargets.test.tsx new file mode 100644 index 000000000..40c53c7d9 --- /dev/null +++ b/src/features/settings/__tests__/languageServerTargets.test.tsx @@ -0,0 +1,108 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { usePluginRunTargets } from "@/platform/pluginRunTargets"; +import type { ProjectInfo } from "@/bindings"; +import { PageContext } from "../components/context"; +import { LanguageServerTargetRows } from "../languages/LanguageServerTarget"; +import { DEFAULT_SETTINGS, validateSettings } from "../model/schema"; +import { openSettings, useSettingsDialog } from "../model/staging"; + +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + commands: { + ...actual.commands, + languages: async () => [{ id: "php", name: "PHP", languageServers: ["intelephense", "phpactor"] }], + }, + }; +}); + +beforeEach(() => { + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: { root: "/project" } as ProjectInfo }); + usePluginRunTargets.setState({ + targets: [ + { + id: "mapped", + pluginId: "example", + pluginName: "Example", + prompt: false, + target: { + id: "mapped", + name: "Container", + program: "wrapper", + args: [], + workdirArgs: [], + containerRoot: "/app", + commands: [], + }, + }, + { + id: "unmapped", + pluginId: "example", + pluginName: "Example", + prompt: false, + target: { + id: "unmapped", + name: "No root", + program: "wrapper", + args: [], + workdirArgs: [], + containerRoot: null, + commands: [], + }, + }, + ], + }); + openSettings("languages.php"); +}); +afterEach(() => { + cleanup(); + usePluginRunTargets.setState({ targets: [] }); +}); + +function page() { + render( + + + , + ); +} + +describe("language server targets", () => { + it("defaults to the host, disables unmapped targets, and stages the server choice in the project", async () => { + page(); + expect(screen.getByRole("button", { name: "Language server runs on" })).toHaveTextContent("Local machine"); + fireEvent.click(screen.getByRole("button", { name: "Language server runs on" })); + fireEvent.click(screen.getByRole("option", { name: /No root/ })); + expect(screen.getByRole("button", { name: "Language server runs on" })).toHaveTextContent("Local machine"); + expect(screen.queryByRole("button", { name: "Language server" })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: /No root/ })).toHaveTextContent( + "Requires a container root for path mapping", + ); + fireEvent.click(screen.getByRole("option", { name: /^Container/ })); + await waitFor(() => expect(screen.getByRole("button", { name: "Language server" })).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Language server" })); + fireEvent.click(await screen.findByRole("option", { name: "phpactor" })); + expect(Object.values(useSettingsDialog.getState().staged)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + key: "languageServerTargets", + value: { php: { target: "mapped", server: "phpactor" } }, + }), + ]), + ); + expect( + validateSettings({ languageServerTargets: { php: { target: "mapped", server: "phpactor" } } }) + .languageServerTargets.php, + ).toEqual({ target: "mapped", server: "phpactor" }); + }); + + it("requires an open project", () => { + act(() => useProject.setState({ project: null })); + page(); + expect(screen.getByRole("button", { name: "Language server runs on" })).toBeDisabled(); + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 221727ea1..fa5892f28 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -307,6 +307,7 @@ describe("language pages", () => { "Rust", "TypeScript", "Plugin Formatters", + "Language server targets", ]); }); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index ed0ced3d1..e159ab2e5 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -3,6 +3,7 @@ * Build Tools › Maven: the toolchain each language's server and run configurations * use (applied by `ide-sdk` when language servers start and when run configurations load). */ +import { LanguageServerTargetRows, LanguageServerTargetsPage } from "./LanguageServerTarget"; import { useRunTargets } from "@/platform/pluginRunTargets"; import { tx } from "@/platform/i18n"; import { useEffect, useId, useMemo, useState } from "react"; @@ -123,6 +124,7 @@ export function NodePage() { hint="Runs npm scripts and Node-based language servers (vtsls, eslint, pyright…)." /> + set({ target })} + disabled={!project || newProjects} + aria-label="Language server runs on" + /> + + {selection.target && ( + + ({ value: l.id, label: l.name }))} + onChange={setChosen} + aria-label="Language" + /> + + {selected && } + + ); +} diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index cce7fbb0b..efb0240ca 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -8,7 +8,7 @@ import { validateCMake } from "@/platform/cmakeSettings"; import { SUPPORTED_LOCALES, type Locale } from "@/platform/i18n"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; -import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc"; +import type { AgentSettings, LanguageServerSettings, LanguageServerTarget, Settings } from "@/platform/ipc"; import { DEFAULT_LANGUAGES, DEFAULT_PROJECT_STRUCTURE, @@ -94,6 +94,7 @@ export interface ValidSettings extends WorkflowSettings { tools: Record; /** Settings › Language Servers, by server name (as the backend sends them). */ languageServers: Record; + languageServerTargets: Record; /** Tools › Agents › Chat: the meters in an agent task's header. */ agents: Required; /** Tools › MCP Server (global). */ @@ -165,6 +166,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, tools: {}, languageServers: {}, + languageServerTargets: {}, agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false }, mcpServer: { enabled: false }, ...DEFAULT_WORKFLOW, @@ -280,6 +282,18 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { languageServers: Object.fromEntries( Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]), ), + languageServerTargets: Object.fromEntries( + Object.entries(obj(root.languageServerTargets)).map(([language, value]) => { + const entry = obj(value); + return [ + language, + { + target: typeof entry.target === "string" ? entry.target : "", + server: typeof entry.server === "string" ? entry.server : "", + }, + ]; + }), + ), agents: { showContextUsage: bool(agents.showContextUsage, d.agents.showContextUsage), showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime), diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index bec8ef533..0e1f43a91 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -1,3 +1,4 @@ +import { LanguageServerTargetsPage } from "../languages/LanguageServerTarget"; import { PluginFormattersPage } from "../languages/PluginFormattersPage"; /** * PyCharm's top-level "Python" group (Interpreter), "Project Structure", and @@ -16,6 +17,16 @@ import type { PageSpec } from "./spec"; export const LANGUAGES_GROUP = "Languages & Frameworks"; export const LANGUAGE_PAGES: PageSpec[] = [ + { + id: "languages.serverTargets", + title: "Language server targets", + group: LANGUAGES_GROUP, + projectScope: true, + defaultScope: "project", + order: 451, + keywords: ["lsp", "container", "target"], + component: LanguageServerTargetsPage, + }, { id: "languages.pluginFormatters", title: "Plugin Formatters", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 260ade947..8ab02e483 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -601,5 +601,24 @@ "Invalid data source id": "Invalid data source id", "Database connection failed": "Database connection failed", "Database service unavailable": "Database service unavailable", - "Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources" + "Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources", + "Language server runs on": "Language server runs on", + "Requires a container root for path mapping": "Requires a container root for path mapping", + "Target unavailable; language server runs on the host": "Target unavailable; language server runs on the host", + "Project only. Install the selected server inside the target; IDE downloads are host binaries.": "Project only. Install the selected server inside the target; IDE downloads are host binaries.", + "First configured server": "First configured server", + "Language server targets": "Language server targets", + "target wrapper has no executable pin": "target wrapper has no executable pin", + "language server targets require a container root": "language server targets require a container root", + "container language server is no longer available": "container language server is no longer available", + "invalid container document URI": "invalid container document URI", + "container document reader is unavailable": "container document reader is unavailable", + "container document exceeds the size or time limit": "container document exceeds the size or time limit", + "container document reader failed": "container document reader failed", + "container document exceeds 4 MiB": "container document exceeds 4 MiB", + "container document is not UTF-8 text": "container document is not UTF-8 text", + "plugin run target changed; navigate again": "plugin run target changed; navigate again", + "plugin host is unavailable": "plugin host is unavailable", + "language server targets belong to project settings": "language server targets belong to project settings", + "library documents are always read-only": "library documents are always read-only" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b752f7c82..93cb225df 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6944,5 +6944,24 @@ "Invalid data source id": "数据源标识无效", "Database connection failed": "数据库连接失败", "Database service unavailable": "数据库服务不可用", - "Trust this project to connect plugin data sources": "信任此项目后才能连接插件数据源" + "Trust this project to connect plugin data sources": "信任此项目后才能连接插件数据源", + "Language server runs on": "语言服务器运行于", + "Requires a container root for path mapping": "需要容器根目录以映射路径", + "Target unavailable; language server runs on the host": "目标不可用;语言服务器在主机上运行", + "Project only. Install the selected server inside the target; IDE downloads are host binaries.": "仅限当前项目。请在目标中安装所选服务器;IDE 下载的是主机二进制文件。", + "First configured server": "第一个配置的服务器", + "Language server targets": "语言服务器目标", + "target wrapper has no executable pin": "目标包装程序没有可执行文件固定校验", + "language server targets require a container root": "语言服务器目标需要容器根目录", + "container language server is no longer available": "容器语言服务器已不可用", + "invalid container document URI": "容器文档 URI 无效", + "container document reader is unavailable": "容器文档读取器不可用", + "container document exceeds the size or time limit": "容器文档超过大小或时间限制", + "container document reader failed": "容器文档读取失败", + "container document exceeds 4 MiB": "容器文档超过 4 MiB", + "container document is not UTF-8 text": "容器文档不是 UTF-8 文本", + "plugin run target changed; navigate again": "插件运行目标已更改;请重新导航", + "plugin host is unavailable": "插件宿主不可用", + "language server targets belong to project settings": "语言服务器目标属于项目设置", + "library documents are always read-only": "库文档始终为只读" }