дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(lsp): run language servers in a plugin run target (#2250)
* feat(lsp): run language servers in a plugin run target * test(settings): list the language server targets page
Цей коміт міститься в:
1 джерело
f8e15a7980
коміт
0e81c6aeb1
38 files changed
+1462
-40
No files matched your search
Створено
@@ -4273,6 +4273,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct HostConfig {
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
plugin_host: Arc<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: RwLock::default(),
|
||||
plugin_host: Arc::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
|
||||
@@ -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<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,3 +98,5 @@ impl Host {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod lsp_target;
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ide_run::RunConfig> {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let target = self
|
||||
.shared
|
||||
.run_targets
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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::<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,6 +248,7 @@ 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`],
|
||||
@@ -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<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)
|
||||
@@ -387,6 +415,19 @@ 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
|
||||
@@ -412,7 +453,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, 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<dyn Handler>,
|
||||
close_on_eof: bool,
|
||||
pid: Option<u32>,
|
||||
framing: Framing,
|
||||
options: TransportOptions,
|
||||
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 {
|
||||
@@ -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<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 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;
|
||||
|
||||
@@ -176,6 +176,22 @@ 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)]
|
||||
|
||||
@@ -977,6 +977,7 @@ 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
|
||||
@@ -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());
|
||||
|
||||
@@ -95,6 +95,33 @@ 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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
@@ -183,6 +188,8 @@ 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>>>,
|
||||
@@ -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<Arc<Server>>)> =
|
||||
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<Arc<Server>> {
|
||||
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<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());
|
||||
}
|
||||
@@ -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<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));
|
||||
}
|
||||
@@ -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<ide_core::ServerId>)> = 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<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)))
|
||||
.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;
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
let delay = self.record_crash();
|
||||
message.push_str(if delay.is_some() {
|
||||
"\nRestarting…"
|
||||
|
||||
@@ -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<String> {
|
||||
let path = file_path(&format!("file:///{}", uri.strip_prefix(&virtual_prefix(target))?))?;
|
||||
(path.starts_with('/')
|
||||
&& !path.split('/').any(|s| s == ".." || s == ".")
|
||||
&& target.mapping.map(&file_uri(&path), Direction::ToHost).is_none())
|
||||
.then_some(path)
|
||||
}
|
||||
|
||||
fn suffix(uri: &str) -> &str {
|
||||
uri.find(['?', '#']).map(|i| &uri[i..]).unwrap_or("")
|
||||
}
|
||||
|
||||
pub(crate) struct TargetTransform(pub ServerTarget);
|
||||
|
||||
impl MessageTransform for TargetTransform {
|
||||
fn outgoing(&self, value: &mut Value) {
|
||||
rewrite_strings(value, &|s| {
|
||||
// A container library path may happen to have the host root's spelling.
|
||||
external_path(&self.0, s)
|
||||
.map(|p| format!("{}{}", file_uri(&p), suffix(s)))
|
||||
.or_else(|| self.0.mapping.map(s, Direction::ToTarget))
|
||||
});
|
||||
}
|
||||
|
||||
fn incoming(&self, value: &mut Value) {
|
||||
rewrite_strings(value, &|s| {
|
||||
if let Some(mapped) = self.0.mapping.map(s, Direction::ToHost) {
|
||||
return Some(mapped);
|
||||
}
|
||||
let path = file_path(s)?;
|
||||
if !path.starts_with('/') || path.split('/').any(|p| p == ".." || p == ".") {
|
||||
return None;
|
||||
}
|
||||
let uri = file_uri(&path);
|
||||
Some(format!("{}{}{}", virtual_prefix(&self.0), uri.strip_prefix("file:///")?, suffix(s)))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::path_mapping::PathMapping;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn external_uris_are_virtual_but_plain_outside_paths_and_opaque_values_stay_intact() {
|
||||
let target = ServerTarget {
|
||||
id: "plugin-target:example".into(),
|
||||
origin: Some(ide_core::ServerId(7)),
|
||||
project_root: "/host/project".into(),
|
||||
container_root: "/container".into(),
|
||||
mapping: PathMapping::new("/host/project", "/container"),
|
||||
};
|
||||
let mapper = TargetTransform(target.clone());
|
||||
let mut value = json!({"uri":"file:///container/a%20b.php", "relatedInformation":[{"location":{"uri":"file:///usr/lib/a%20b.php"}}],
|
||||
"data":{"uri":"file:///container/a.php"}, "command":{"arguments":["/container/a.php"]}, "path":"/usr/lib/a.php", "link":"file:///usr/lib/a.php#L12", "alias":"file:///host/project/std.php"});
|
||||
mapper.incoming(&mut value);
|
||||
assert_eq!(value["uri"], "file:///host/project/a%20b.php");
|
||||
let external = value["relatedInformation"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert!(external.starts_with("openidea-target:"));
|
||||
assert_eq!(external_path(&target, external).unwrap(), "/usr/lib/a b.php");
|
||||
let other = ServerTarget { origin: Some(ide_core::ServerId(8)), ..target.clone() };
|
||||
assert!(external_path(&other, external).is_none());
|
||||
assert!(value["link"].as_str().unwrap().ends_with("#L12"));
|
||||
assert_eq!(value["path"], "/usr/lib/a.php");
|
||||
assert_eq!(value["data"]["uri"], "file:///container/a.php");
|
||||
assert_eq!(value["command"]["arguments"][0], "/container/a.php");
|
||||
mapper.outgoing(&mut value);
|
||||
assert_eq!(value["uri"], "file:///container/a%20b.php");
|
||||
assert_eq!(value["relatedInformation"][0]["location"]["uri"], "file:///usr/lib/a%20b.php");
|
||||
assert_eq!(value["link"], "file:///usr/lib/a.php#L12");
|
||||
assert_eq!(value["alias"], "file:///host/project/std.php");
|
||||
let mut configuration =
|
||||
json!({"result":[{"path":"/host/project/vendor","uris":["file:///host/project/vendor/a.php"]}]});
|
||||
mapper.outgoing(&mut configuration);
|
||||
assert_eq!(configuration["result"][0]["path"], "/container/vendor");
|
||||
assert_eq!(configuration["result"][0]["uris"][0], "file:///container/vendor/a.php");
|
||||
}
|
||||
}
|
||||
@@ -1,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<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;
|
||||
}
|
||||
@@ -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<serde_json::Valu
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +102,54 @@ 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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,9 @@ fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let mut files = Vec::new();
|
||||
for file in &edit.files {
|
||||
let resolve = |path: &Path, text: bool| -> IdeResult<PathBuf> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Settings> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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.<language-id> = { 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 -- <path>` 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.
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: "<?php\nfunction library() {}\n",
|
||||
title: "library.php",
|
||||
description: null,
|
||||
languageId: "php",
|
||||
decompiled: false,
|
||||
},
|
||||
});
|
||||
const disk = vi.spyOn(commands, "fsReadText");
|
||||
try {
|
||||
await followReference({ kind: "file", path: uri, fragment: "L2", from: 0, to: 1 });
|
||||
expect(read).toHaveBeenCalledWith(uri);
|
||||
expect(disk).not.toHaveBeenCalled();
|
||||
expect(openFile).toHaveBeenCalledWith(uri, { focus: true, selection: { line: 1, col: 0 } });
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
disk.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type { Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { whenSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { call, commands, isTauri, type DocumentLink } from "@/platform/ipc";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
@@ -179,7 +180,11 @@ export async function followReference(ref: Reference): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(
|
||||
<PageContext.Provider value={{ pageId: "languages.php", scope: "global" }}>
|
||||
<LanguageServerTargetRows language="php" />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -307,6 +307,7 @@ describe("language pages", () => {
|
||||
"Rust",
|
||||
"TypeScript",
|
||||
"Plugin Formatters",
|
||||
"Language server targets",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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…)."
|
||||
/>
|
||||
<RunTargetRow language="node" />
|
||||
<LanguageServerTargetsPage ids={["javascript", "javascriptreact", "typescript", "typescriptreact"]} />
|
||||
<Row label="Package manager" hint="Used by the npm run configurations detected from package.json scripts.">
|
||||
<Select value={pm} options={pmOptions} onChange={setPm} aria-label="Package manager" />
|
||||
</Row>
|
||||
@@ -233,6 +235,7 @@ export function PhpPage() {
|
||||
</Section>
|
||||
<Section title="CLI Interpreter">
|
||||
<RunTargetRow language="php" />
|
||||
<LanguageServerTargetRows language="php" />
|
||||
<SdkRow
|
||||
k="languages.php.interpreter"
|
||||
label="PHP interpreter"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LanguageInfo, LanguageServerTarget } from "@/bindings";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { usePluginRunTargets } from "@/platform/pluginRunTargets";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useLanguageListVersion } from "@/platform/languageList";
|
||||
import { type SelectOption } from "@/ui";
|
||||
import { Row, Section, Select } from "../components/Form";
|
||||
import { useSetting } from "../components/context";
|
||||
import { useSettingsDialog } from "../model/staging";
|
||||
|
||||
function useServerLanguages() {
|
||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||
const version = useLanguageListVersion((s) => s.version);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void commands.languages().then(
|
||||
(list) => alive && setLanguages(list),
|
||||
() => {},
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [version]);
|
||||
return languages.filter((l) => l.languageServers?.length);
|
||||
}
|
||||
|
||||
function TargetField({ language, servers }: { language: string; servers: string[] }) {
|
||||
const [selections, setSelections] = useSetting<Record<string, LanguageServerTarget>>(
|
||||
"languageServerTargets",
|
||||
"project",
|
||||
);
|
||||
const targets = usePluginRunTargets((s) => s.targets);
|
||||
const project = useProject((s) => !!s.project);
|
||||
const newProjects = useSettingsDialog((s) => s.newProjects);
|
||||
const selection = selections?.[language] ?? { target: "", server: "" };
|
||||
const set = (patch: Partial<LanguageServerTarget>) =>
|
||||
setSelections({ ...selections, [language]: { ...selection, ...patch } });
|
||||
const options: SelectOption[] = [
|
||||
{ value: "", label: "Local machine" },
|
||||
...targets.map((t) => ({
|
||||
value: t.id,
|
||||
label: t.target.name,
|
||||
disabled: !t.target.containerRoot,
|
||||
detail: t.target.containerRoot ? t.pluginName : "Requires a container root for path mapping",
|
||||
})),
|
||||
];
|
||||
if (selection.target && !targets.some((t) => t.id === selection.target))
|
||||
options.push({
|
||||
value: selection.target,
|
||||
label: selection.target,
|
||||
detail: "Target unavailable; language server runs on the host",
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Row
|
||||
label="Language server runs on"
|
||||
hint="Project only. Install the selected server inside the target; IDE downloads are host binaries."
|
||||
>
|
||||
<Select
|
||||
value={selection.target ?? ""}
|
||||
options={options}
|
||||
onChange={(target) => set({ target })}
|
||||
disabled={!project || newProjects}
|
||||
aria-label="Language server runs on"
|
||||
/>
|
||||
</Row>
|
||||
{selection.target && (
|
||||
<Row label="Language server">
|
||||
<Select
|
||||
value={selection.server ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "First configured server" },
|
||||
...servers.map((name) => ({ value: name, label: name })),
|
||||
]}
|
||||
onChange={(server) => set({ server })}
|
||||
disabled={!project || newProjects}
|
||||
aria-label="Language server"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguageServerTargetRows({ language }: { language: string }) {
|
||||
const languages = useServerLanguages();
|
||||
const selected = languages.find((l) => l.id === language);
|
||||
return <TargetField language={language} servers={selected?.languageServers ?? []} />;
|
||||
}
|
||||
|
||||
/** All languages, including plugin languages and languages without an interpreter page. */
|
||||
export function LanguageServerTargetsPage({ ids }: { ids?: string[] }) {
|
||||
const languages = useServerLanguages();
|
||||
const [chosen, setChosen] = useState("php");
|
||||
const available = ids ? languages.filter((l) => ids.includes(l.id)) : languages;
|
||||
const selected = available.find((l) => l.id === chosen) ?? available[0];
|
||||
return (
|
||||
<Section title="Language server targets">
|
||||
<Row label="Language">
|
||||
<Select
|
||||
value={selected?.id ?? ""}
|
||||
options={available.map((l) => ({ value: l.id, label: l.name }))}
|
||||
onChange={setChosen}
|
||||
aria-label="Language"
|
||||
/>
|
||||
</Row>
|
||||
{selected && <TargetField language={selected.id} servers={selected.languageServers ?? []} />}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, { path: string }>;
|
||||
/** Settings › Language Servers, by server name (as the backend sends them). */
|
||||
languageServers: Record<string, LanguageServerEntry>;
|
||||
languageServerTargets: Record<string, LanguageServerTarget>;
|
||||
/** Tools › Agents › Chat: the meters in an agent task's header. */
|
||||
agents: Required<AgentSettings>;
|
||||
/** 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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "库文档始终为只读"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача