mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3d6213a4 |
No files matched your search
Generated
+5
-7
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4182,7 +4182,6 @@ dependencies = [
|
||||
"ide-language",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4544,7 +4543,6 @@ dependencies = [
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -8380,7 +8378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9982,9 +9980,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-haskell"
|
||||
version = "0.24.1"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
|
||||
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
|
||||
@@ -229,9 +229,6 @@ pub enum DebugEvent {
|
||||
thread_id: i64,
|
||||
started: bool,
|
||||
},
|
||||
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
|
||||
/// in the debug console reaches its input.
|
||||
ProgramInput,
|
||||
BreakpointsChanged {
|
||||
path: crate::IpcPath,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and run
|
||||
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -140,55 +140,22 @@ pub struct DeploymentMapping {
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Where a run target runs configurations.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunTargetKind {
|
||||
/// Another machine over SSH, the project synchronised to its folder.
|
||||
#[default]
|
||||
Ssh,
|
||||
/// A new container of an image (`docker run --rm`), the project bind-mounted.
|
||||
Docker,
|
||||
/// A new container of a Compose service (`docker compose run --rm`).
|
||||
Compose,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
|
||||
/// or in a container.
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub kind: RunTargetKind,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`] (SSH).
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from (SSH).
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run (SSH).
|
||||
/// Upload changed project files before each run.
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
/// Id of a Docker connection of the Services tool window (Docker, Compose);
|
||||
/// empty = the CLI's own daemon.
|
||||
pub docker_connection: String,
|
||||
/// Image to run (Docker).
|
||||
pub image: String,
|
||||
/// Compose files, relative to the project; empty = the project's default one (Compose).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Service to run (Compose).
|
||||
pub service: String,
|
||||
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
|
||||
/// the service's bind mount of the project (Compose).
|
||||
pub container_root: String,
|
||||
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
|
||||
pub ports: Vec<String>,
|
||||
/// More options for `docker run` / `docker compose run`, shell-like quoted.
|
||||
pub run_options: String,
|
||||
/// Run as the image's user instead of the project folder's owner (Linux).
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -209,24 +176,4 @@ mod deployment_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_target_kind_defaults_to_ssh_and_round_trips() {
|
||||
let legacy: RunTarget =
|
||||
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
|
||||
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
|
||||
let docker = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
docker_connection: "local".into(),
|
||||
compose_files: vec!["compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
ports: vec!["8000:8000".into()],
|
||||
image_user: true,
|
||||
..legacy
|
||||
};
|
||||
let json = serde_json::to_value(&docker).unwrap();
|
||||
assert_eq!(json["kind"], "compose");
|
||||
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
|
||||
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"codelldb",
|
||||
&["--port", PORT_PLACEHOLDER],
|
||||
"tcp",
|
||||
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
|
||||
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
|
||||
),
|
||||
"kotlin-debug-adapter" => cfg(
|
||||
"kotlin-debug-adapter",
|
||||
&[],
|
||||
"stdio",
|
||||
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
&[PORT_PLACEHOLDER, "127.0.0.1"],
|
||||
"tcp",
|
||||
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
|
||||
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
|
||||
),
|
||||
"delve" => cfg(
|
||||
"dlv",
|
||||
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"netcoredbg",
|
||||
&["--interpreter=vscode"],
|
||||
"stdio",
|
||||
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
|
||||
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
|
||||
),
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
@@ -668,7 +668,7 @@ mod tests {
|
||||
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
|
||||
let err = missing(&spec);
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
|
||||
assert!(err.message.contains("netcoredbg/releases"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1266,16 +1266,6 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
|
||||
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let mut program = session.program.lock();
|
||||
let program = program
|
||||
.as_mut()
|
||||
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
|
||||
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: injects the changed sources into the running app.
|
||||
/// Not part of the DAP standard; adapters that do not know the request reject it.
|
||||
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
|
||||
@@ -43,8 +43,6 @@ pub(crate) struct Session {
|
||||
pub adapter_process: Mutex<Option<Child>>,
|
||||
/// Processes started for `runInTerminal`.
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
@@ -92,7 +90,6 @@ impl Session {
|
||||
caps: Mutex::new(Value::Null),
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
@@ -269,9 +266,6 @@ impl Session {
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,25 +451,8 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
}
|
||||
}
|
||||
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
|
||||
/// starts a launcher that gives the program its terminal, and the program reads input only from
|
||||
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
|
||||
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
|
||||
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
let argv: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
@@ -484,25 +461,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let Some((program, rest)) = argv.split_first() else {
|
||||
return Err("runInTerminal without args".into());
|
||||
};
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
None => cmd.env_remove(k),
|
||||
}
|
||||
}
|
||||
for (k, v) in ide_core::process::shell_env() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
Some(cwd) => cmd.cwd(cwd),
|
||||
None => cmd.cwd(&s.spec.cwd),
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(rest)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
if let Some(env) = args.get("env").and_then(Value::as_object) {
|
||||
for (k, v) in env {
|
||||
@@ -512,53 +480,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let pid = child.id();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
forward_output(s, out, "stdout");
|
||||
}
|
||||
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
s.debuggees.lock().push(child);
|
||||
Ok(json!({ "processId": pid }))
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
Ok(_) => pending.len(),
|
||||
Err(e) if e.error_len().is_none() => e.valid_up_to(),
|
||||
Err(_) => pending.len(),
|
||||
};
|
||||
if valid > 0 && pending[valid - 1] == b'\r' {
|
||||
valid -= 1;
|
||||
}
|
||||
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
|
||||
pending.drain(..valid);
|
||||
if !output.is_empty() {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
|
||||
@@ -468,40 +468,6 @@ async fn run_in_terminal_spawns_debuggee() {
|
||||
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_in_terminal_program_reads_typed_input() {
|
||||
let h = harness(Script {
|
||||
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
|
||||
run_in_terminal: Some(vec![
|
||||
"sh".into(),
|
||||
"-c".into(),
|
||||
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
|
||||
"--clear-screen".into(),
|
||||
]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
|
||||
h.manager.send_input(session, "Bob\n").unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
|
||||
let output: String = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
|
||||
Some(output.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!output.contains('\r'), "{output:?}");
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
|
||||
//! configuration does: a launch without a program makes it listen, and every PHP
|
||||
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
|
||||
//! file, through `pathMappings`, until the session is stopped. A second listener on
|
||||
//! the same port fails to start and names the port.
|
||||
//!
|
||||
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
|
||||
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
|
||||
//! (`PHP_BIN` picks the PHP executable, default `php`).
|
||||
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_dap::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
|
||||
/// 0-based line of `$n = 40 + 2;`.
|
||||
const BREAK_LINE: u32 = 2;
|
||||
|
||||
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(e) = events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return e;
|
||||
}
|
||||
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
|
||||
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
|
||||
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
|
||||
LaunchConfig {
|
||||
name: "PHP Remote".into(),
|
||||
adapter: "php-debug".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(local.to_path_buf().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
|
||||
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
|
||||
return;
|
||||
};
|
||||
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The project here, and the copy PHP runs from (a container's /var/www/html).
|
||||
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
|
||||
for d in [&local, &served] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
|
||||
}
|
||||
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
|
||||
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
|
||||
let (local, served) = (resolved(&local), resolved(&served));
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let local_script = local.join("index.php");
|
||||
manager
|
||||
.set_breakpoints(
|
||||
local_script.clone(),
|
||||
vec![Breakpoint {
|
||||
line: BREAK_LINE,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
log_message: None,
|
||||
enabled: true,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
// Requests in a row each stop; the session keeps listening between them.
|
||||
for request in 1..=2 {
|
||||
events.lock().clear();
|
||||
let run = std::process::Command::new(&php)
|
||||
.args([
|
||||
"-dxdebug.mode=debug",
|
||||
"-dxdebug.start_with_request=yes",
|
||||
"-dxdebug.client_host=127.0.0.1",
|
||||
&format!("-dxdebug.client_port={port}"),
|
||||
"index.php",
|
||||
])
|
||||
.current_dir(&served)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
|
||||
let frames = manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
|
||||
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
|
||||
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
|
||||
manager.resume(id, thread_id).await.unwrap();
|
||||
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
|
||||
let out = run.wait_with_output().unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
|
||||
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
|
||||
}
|
||||
|
||||
// Another listener on the port (another IDE): the start fails and says why.
|
||||
let err = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
|
||||
.await
|
||||
.expect_err("a second listener on the same port");
|
||||
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
|
||||
assert_eq!(manager.sessions(None).len(), 1);
|
||||
|
||||
manager.stop(id).await.unwrap();
|
||||
assert!(manager.sessions(None).is_empty());
|
||||
}
|
||||
@@ -440,89 +440,3 @@ pub fn compose_op(
|
||||
});
|
||||
run_streaming(ep, None, &args, cancel, on_output)
|
||||
}
|
||||
|
||||
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
|
||||
/// is this machine's folder. The CLI's default daemon and a context are looked
|
||||
/// up (`DOCKER_HOST`, else the context's endpoint).
|
||||
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
DockerEndpoint::Tcp { host, .. } => {
|
||||
let host = host.trim();
|
||||
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
|
||||
}
|
||||
DockerEndpoint::Default => {
|
||||
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
|
||||
Some(url) if !url.is_empty() => url,
|
||||
_ => context_host(ep, None)?,
|
||||
}
|
||||
}
|
||||
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
|
||||
};
|
||||
Ok(daemon_url_is_local(&url))
|
||||
}
|
||||
|
||||
/// The daemon URL of context `name` (the current one with `None`).
|
||||
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
|
||||
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
|
||||
args.extend(name);
|
||||
Ok(run(ep, &args, QUICK)?.trim().to_owned())
|
||||
}
|
||||
|
||||
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
|
||||
/// this machine; `ssh://` and other hosts are not.
|
||||
pub fn daemon_url_is_local(url: &str) -> bool {
|
||||
let url = url.trim();
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return true };
|
||||
match scheme {
|
||||
"unix" | "npipe" => true,
|
||||
"tcp" | "http" | "https" => {
|
||||
let host = rest.split('/').next().unwrap_or_default();
|
||||
let host = match host.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
|
||||
};
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `docker compose config --format json` of the project at `dir` (`files`
|
||||
/// relative to it; none = the project's default file).
|
||||
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
|
||||
let mut args: Vec<&str> = vec!["compose"];
|
||||
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file", f]);
|
||||
}
|
||||
args.extend(["config", "--format", "json"]);
|
||||
let mut cmd = command(ep, &args)?;
|
||||
cmd.current_dir(dir);
|
||||
let out = process::run(cmd, QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_daemon_urls() {
|
||||
for url in [
|
||||
"unix:///var/run/docker.sock",
|
||||
"/var/run/docker.sock",
|
||||
"npipe:////./pipe/docker_engine",
|
||||
"tcp://localhost:2375",
|
||||
"tcp://127.0.0.1:2376",
|
||||
"tcp://[::1]:2375",
|
||||
"",
|
||||
] {
|
||||
assert!(daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
|
||||
assert!(!daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
|
||||
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.2.0";
|
||||
pub const ENGINE_VERSION: &str = "1.1.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -98,15 +98,7 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(id)) => {
|
||||
if let Some(path) = id.strip_prefix("workspaceContains:")
|
||||
&& (path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.contains('\\')
|
||||
|| path.split('/').any(|s| s == ".."))
|
||||
{
|
||||
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
|
||||
}
|
||||
Some(Tok::Id(_)) => {
|
||||
*p += 1;
|
||||
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
|
||||
*p += 1;
|
||||
@@ -141,23 +133,4 @@ mod tests {
|
||||
assert!(validate_when(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_contains_paths_stay_in_the_project() {
|
||||
for ok in [
|
||||
"workspaceContains:pom.xml",
|
||||
"workspaceContains:.devcontainer/devcontainer.json",
|
||||
"!workspaceContains:a/b && editorFocus",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"workspaceContains:",
|
||||
"workspaceContains:/etc/passwd",
|
||||
"workspaceContains:../x",
|
||||
"workspaceContains:a/../../x",
|
||||
] {
|
||||
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,33 +92,6 @@ fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRan
|
||||
}
|
||||
}
|
||||
|
||||
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
|
||||
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
|
||||
fn php_cs_fixer_edits(
|
||||
tools: &Tools,
|
||||
php: Option<&Path>,
|
||||
fixer: &formatter::PhpCsFixer,
|
||||
path: &Path,
|
||||
unsaved: &str,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let copy = dir.path().join(name);
|
||||
std::fs::write(©, unsaved)?;
|
||||
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, ©)?, &Default::default())?;
|
||||
let formatted = std::fs::read_to_string(©)?;
|
||||
let text = ide_text::Document::new(unsaved);
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -130,34 +103,6 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let edits = tokio::task::spawn_blocking(move || {
|
||||
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if range.is_some() {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "PHP-CS-Fixer".into(),
|
||||
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
|
||||
});
|
||||
}
|
||||
return Ok(edits);
|
||||
}
|
||||
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
|
||||
if let Some(tool) = selected
|
||||
&& options.tool.is_none()
|
||||
@@ -328,59 +273,6 @@ mod tests {
|
||||
assert_eq!(rust.message, "oxfmt cannot format Rust files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("src/Http")).unwrap();
|
||||
let file = root.join("src/Http/Kernel.php");
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None);
|
||||
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
|
||||
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
|
||||
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
|
||||
let found = formatter::php_cs_fixer(&file).unwrap();
|
||||
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
|
||||
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
|
||||
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
|
||||
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
|
||||
std::fs::write(&fixer.config, "<?php").unwrap();
|
||||
let php = root.join("php");
|
||||
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
|
||||
std::fs::write(
|
||||
&php,
|
||||
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
|
||||
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let file = root.join("src/Kernel.php");
|
||||
let tools = Tools::new(&root.join("tools"));
|
||||
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, " ");
|
||||
assert!(!file.exists(), "the file on disk is left alone");
|
||||
|
||||
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
|
||||
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
|
||||
assert!(
|
||||
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_ranges_are_one_based_unicode_columns() {
|
||||
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
|
||||
|
||||
@@ -245,7 +245,6 @@ impl Host {
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -258,7 +257,6 @@ impl Host {
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
retired: Option<Arc<Project>>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
@@ -268,10 +266,6 @@ impl Host {
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Dropping the project stops its file watcher and index worker, which first finish the
|
||||
// batch they are applying (many file events while a build writes its output), so it
|
||||
// isn't done on the closing caller's thread (the UI thread for a window close).
|
||||
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
@@ -286,9 +280,6 @@ impl Host {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Some(retired) = retired {
|
||||
let _ = retired.join();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -328,7 +319,6 @@ impl Host {
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
@@ -432,37 +422,3 @@ impl Host {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_close_releases_the_project_before_a_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
|
||||
host.close_project_detached("main");
|
||||
assert!(host.project("main").is_err());
|
||||
// The project (its file watcher and index database) is dropped off the closing thread, but
|
||||
// before opening a project waits out the close.
|
||||
host.wait_detached_closes();
|
||||
assert!(project.upgrade().is_none(), "the closed project is dropped");
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -162,11 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Types `text` into the session's program (a `runInTerminal` program).
|
||||
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
|
||||
host.debugger.send_input(session, &text)
|
||||
}
|
||||
|
||||
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_reload(session).await
|
||||
}
|
||||
@@ -386,9 +381,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_send_input", |c| async move {
|
||||
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
|
||||
@@ -257,28 +257,6 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
|
||||
/// search for the rest (Java: the project's, before the dependencies') arrive on
|
||||
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
|
||||
pub async fn lsp_goto_implementation(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
@@ -768,10 +746,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add("lsp_goto_implementation", |c| async move {
|
||||
let on_partial = c.sender("onPartial")?;
|
||||
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_resolve_class_file",
|
||||
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::RunTargetKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::time::Duration;
|
||||
@@ -141,33 +140,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH, or in a container.
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
|
||||
}
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
// this machine's toolchains and `PYTHONPATH` don't apply.
|
||||
let config = container::prepare(host, &project.root, target, config).await?;
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
// A folder on this machine: not in the container.
|
||||
ruby_reporter: None,
|
||||
};
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
@@ -293,5 +271,4 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub use commands::*;
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Docker and Compose run targets: the configuration becomes a local run of the
|
||||
//! docker CLI (see `ide_remote::docker`).
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_docker::DockerEndpoint;
|
||||
use ide_remote::docker::{
|
||||
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
|
||||
};
|
||||
use ide_run::RunConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config` rewritten to run in a new container of `target`'s image or service.
|
||||
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
|
||||
if config.kind == "dotnet" || config.kind == "dotnet-test" {
|
||||
return Err(IdeError::invalid(".NET Project requires the local machine"));
|
||||
}
|
||||
let endpoint = endpoint(&host.data_dir, &target)?;
|
||||
let root = root.to_path_buf();
|
||||
host.blocking(move || {
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
container_config(
|
||||
&config,
|
||||
&ContainerRun {
|
||||
target: &target,
|
||||
root: &root,
|
||||
container_root: &container_root,
|
||||
docker: &docker.to_string_lossy(),
|
||||
endpoint_env: &ide_docker::run_env(&endpoint),
|
||||
file_env: &file_env,
|
||||
name: &name,
|
||||
user: user(&root, &target),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
}
|
||||
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|
||||
|| {
|
||||
IdeError::not_found(format!(
|
||||
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
|
||||
target.name
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The configuration's `.env` files, read here: the container gets their variables.
|
||||
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let extras = &config.extras;
|
||||
if extras.env_files.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let paths: Vec<PathBuf> = extras
|
||||
.env_files
|
||||
.iter()
|
||||
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
|
||||
.collect();
|
||||
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
|
||||
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
|
||||
}
|
||||
|
||||
/// On Linux the container writes files as the project folder's owner, unless the
|
||||
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
|
||||
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if !target.image_user {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
|
||||
}
|
||||
let _ = (root, target);
|
||||
None
|
||||
}
|
||||
|
||||
/// Distinguishes runs of the same configuration.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -96,33 +96,6 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComposerInstallState {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub reason: ide_sdk::php::ComposerInstall,
|
||||
}
|
||||
|
||||
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
|
||||
pub async fn sdk_php_composer_install(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<ComposerInstallState>> {
|
||||
let project = host.project(window)?;
|
||||
if !project.is_trusted() {
|
||||
return Ok(None);
|
||||
}
|
||||
let root = project.root.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::php::composer_install(&path, &root)
|
||||
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -238,10 +211,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add(
|
||||
"sdk_php_composer_install",
|
||||
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -611,7 +611,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
|
||||
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
|
||||
/// reports `report`.
|
||||
#[cfg(unix)]
|
||||
struct Fixture {
|
||||
_dir: tempfile::TempDir,
|
||||
@@ -634,8 +634,8 @@ mod tests {
|
||||
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
|
||||
let php = base.join("php/php8.5");
|
||||
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
|
||||
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
|
||||
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
|
||||
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
std::fs::write(&php, script).unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
|
||||
//! (`OPENIDEA_DOCKER`) that prints what it was given.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(target: &str, root: &Path) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
name: "app".into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: vec![root.join("main.py").to_string_lossy().into_owned()],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
async fn output_of(host: &Host, config: RunConfig) -> String {
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docker_targets_run_through_the_docker_cli() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let docker = base.join("docker");
|
||||
std::fs::write(
|
||||
&docker,
|
||||
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
|
||||
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
// SAFETY: the only test of this binary, before any thread looks docker up.
|
||||
unsafe {
|
||||
std::env::set_var("OPENIDEA_DOCKER", &docker);
|
||||
std::env::remove_var("DOCKER_HOST");
|
||||
}
|
||||
|
||||
let root = base.join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&base.join("app"));
|
||||
std::fs::create_dir_all(&host.data_dir).unwrap();
|
||||
std::fs::write(
|
||||
host.data_dir.join("docker.json"),
|
||||
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
|
||||
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = output_of(&host, config("py", &root)).await;
|
||||
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
|
||||
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
|
||||
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
|
||||
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
|
||||
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
|
||||
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
|
||||
|
||||
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
|
||||
assert!(err.message.contains("another machine"), "{}", err.message);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//! Docker and Compose run targets against a real daemon. Ignored by default:
|
||||
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
|
||||
//! (on this machine, or at the same path in a VM such as OrbStack's), and
|
||||
//! `docker` with the Compose plugin must be on `PATH`.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: args.iter().map(|a| a.to_string()).collect(),
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
type Output = Arc<Mutex<String>>;
|
||||
|
||||
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
|
||||
let out: Output = Arc::default();
|
||||
let sink = out.clone();
|
||||
let info = ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(info.id, out)
|
||||
}
|
||||
|
||||
async fn finished(out: &Output, secs: u64) -> String {
|
||||
for _ in 0..secs * 10 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
fn containers(label: &str) -> String {
|
||||
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn runs_in_docker_and_compose_containers() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let name = root.file_name().unwrap().to_string_lossy().into_owned();
|
||||
std::fs::write(
|
||||
root.join("main.py"),
|
||||
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
|
||||
open('out.txt', 'w').write('ok')\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("compose.yaml"),
|
||||
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
|
||||
)
|
||||
.unwrap();
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let host = host(state.path());
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
|
||||
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
|
||||
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
let written = root.join("out.txt");
|
||||
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
|
||||
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
|
||||
}
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
|
||||
|
||||
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
|
||||
// Stop: `docker stop` ends the container; `--rm` removes it.
|
||||
let (id, out) =
|
||||
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
|
||||
.await;
|
||||
for _ in 0..600 {
|
||||
if out.lock().contains("up\n") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
|
||||
ops::run::stop(&host, "main", id).unwrap();
|
||||
let out = finished(&out, 30).await;
|
||||
assert!(out.contains(" stop openidea-sleep-"), "{out}");
|
||||
for _ in 0..100 {
|
||||
if containers("label=openidea.run=sleep").is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
|
||||
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0"
|
||||
tree-sitter-make = "1.1.1"
|
||||
tree-sitter-cmake = "0.7.4"
|
||||
tree-sitter-xml = "0.7.0"
|
||||
tree-sitter-haskell = "0.24.1"
|
||||
tree-sitter-haskell = "0.23.1"
|
||||
tree-sitter-dart = "0.2.0"
|
||||
tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(header module: (module) @name) @outline.module
|
||||
(data_type name: (name) @name) @def.enum
|
||||
(newtype name: (name) @name) @def.struct
|
||||
(type_synonym name: (name) @name) @def.type
|
||||
(type_synomym name: (name) @name) @def.type
|
||||
(type_family name: (name) @name) @def.type
|
||||
(class name: (name) @name) @def.interface
|
||||
(instance name: (name) @name patterns: (_) @detail) @outline.namespace
|
||||
|
||||
+3
-103
@@ -315,27 +315,6 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
|
||||
|
||||
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
|
||||
/// sources, so no dependency can implement it.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SourceImplementations {
|
||||
locations: Vec<lsp::Location>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// Receives the implementations found so far, before a slower search for the rest.
|
||||
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
|
||||
|
||||
/// `first`, then those of `more` not in it (same file and start).
|
||||
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
|
||||
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
|
||||
out
|
||||
}
|
||||
|
||||
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
|
||||
match kind {
|
||||
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
|
||||
@@ -1250,22 +1229,7 @@ impl LspManager {
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, None, None).await
|
||||
}
|
||||
|
||||
/// Go to Implementation, with the implementations found first passed to `partial` while
|
||||
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
|
||||
/// dependency jar for a type declared in one. Returns them all, the first ones first.
|
||||
pub async fn goto_implementation(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
cancel: CancellationToken,
|
||||
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let (method, cap, feature) = goto_method(GotoKind::Implementation);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
|
||||
self.goto_in(&snap, &targets, method, pos, None).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at each of `positions` (gutter line
|
||||
@@ -1282,7 +1246,7 @@ impl LspManager {
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
let requests = positions.iter().copied().map(|pos| {
|
||||
let (snap, targets, cancel) = (&snap, &targets, &cancel);
|
||||
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
|
||||
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
|
||||
});
|
||||
let results: Vec<IdeResult<Vec<Location>>> =
|
||||
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
|
||||
@@ -1295,46 +1259,6 @@ impl LspManager {
|
||||
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
|
||||
}
|
||||
|
||||
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
|
||||
/// and whether they are all there are. Its standard request builds the type hierarchy over
|
||||
/// every dependency jar too, which takes long in a project with many dependencies.
|
||||
async fn java_source_implementations(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> (Vec<Location>, bool) {
|
||||
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
let arguments =
|
||||
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
|
||||
let result: IdeResult<SourceImplementations> = self
|
||||
.call(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(found) => {
|
||||
let locs =
|
||||
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
(locs, found.complete)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn goto_in(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
@@ -1342,24 +1266,9 @@ impl LspManager {
|
||||
method: &str,
|
||||
pos: Pos,
|
||||
cancel: Option<&CancellationToken>,
|
||||
partial: Partial<'_>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
// Found in the sources before the standard request searches the dependencies too.
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
{
|
||||
partial(locs.clone());
|
||||
first = locs;
|
||||
}
|
||||
}
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
@@ -1375,19 +1284,12 @@ impl LspManager {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
let locs = merge_locations(first, locs);
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
}
|
||||
Ok(None) if !first.is_empty() => return Ok(first),
|
||||
Ok(None) => out.value = Some(Vec::new()),
|
||||
// Those shown stay valid when the rest can't be found.
|
||||
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
|
||||
tracing::debug!("go to implementation in the dependencies: {}", e.message);
|
||||
return Ok(first);
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
}
|
||||
@@ -1571,9 +1473,7 @@ impl LspManager {
|
||||
return Vec::new();
|
||||
}
|
||||
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
|
||||
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
|
||||
};
|
||||
let (results, declarations) = futures::join!(join_all(requests), declarations);
|
||||
let mut out = Outcome::new();
|
||||
|
||||
@@ -1804,71 +1804,6 @@ async fn implementations_per_position() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_implementations_come_from_sources_first() {
|
||||
let caps = json!({
|
||||
"textDocumentSync": 1,
|
||||
"implementationProvider": true,
|
||||
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
|
||||
});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let uri = uri_of(&path);
|
||||
let at = move |lines: &[u32]| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
_ => None,
|
||||
}));
|
||||
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
|
||||
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(lines(&found), vec![1]);
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
|
||||
assert_eq!(lines(&markers[0]), vec![1]);
|
||||
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
assert_eq!(asked[0]["command"], "openidea.java.implementations");
|
||||
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
|
||||
|
||||
// A dependency's type: those in the sources first, then the dependencies' too.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*partial.lock(), vec![vec![1]]);
|
||||
assert_eq!(lines(&found), vec![1, 3]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 1);
|
||||
// Without a receiver for the first ones (gutter markers), just all of them.
|
||||
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(partial.lock().is_empty());
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navigation_rename_format_and_decorations() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -33,7 +33,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.2";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
60d67ab91d3c42127500481510c43d829e07babcab872f94a6189d27c901234a
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6e9aab775c231987a73d2e424f8cd389e429f525327fac4f5aba550ebc53afe6
|
||||
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ab066ebab16ba774c2f649a72469bcd25aaa1ffaee77f70ea67395ea26ddee02
|
||||
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
|
||||
+2
-2
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.2.0;
|
||||
export openidea:plugin/providers@1.2.0;
|
||||
include openidea:plugin/plugin-syntax@1.1.0;
|
||||
export openidea:plugin/providers@1.1.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-remote"
|
||||
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
|
||||
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -10,7 +10,6 @@ ide-core.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-run.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
//! Run configurations on Docker and Compose run targets: rewritten into a local
|
||||
//! `docker run --rm` / `docker compose run --rm` of the configuration's program,
|
||||
//! with the project bind-mounted and its paths and interpreters swapped for the
|
||||
//! container's. The console, stdin, Stop and test parsers then work as for any
|
||||
//! local run.
|
||||
|
||||
use crate::target::remote_command;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// What the rewrite needs besides the configuration and its target.
|
||||
pub struct ContainerRun<'a> {
|
||||
pub target: &'a RunTarget,
|
||||
/// The project on this machine.
|
||||
pub root: &'a Path,
|
||||
/// Where the project is in the container.
|
||||
pub container_root: &'a str,
|
||||
/// The docker executable.
|
||||
pub docker: &'a str,
|
||||
/// The connection's variables for the docker CLI (`ide_docker::run_env`).
|
||||
pub endpoint_env: &'a BTreeMap<String, String>,
|
||||
/// Variables of the configuration's `.env` files, already read.
|
||||
pub file_env: &'a BTreeMap<String, String>,
|
||||
/// Container name, unique per run.
|
||||
pub name: &'a str,
|
||||
/// `--user uid:gid`; `None` keeps the image's user.
|
||||
pub user: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The container folder of the project when a Docker target sets none.
|
||||
pub fn default_container_root(root: &Path) -> String {
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name: String =
|
||||
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect();
|
||||
format!("/workspaces/{}", if name.is_empty() { "project" } else { &name })
|
||||
}
|
||||
|
||||
/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
|
||||
pub fn container_name(config_id: &str, unique: &str) -> String {
|
||||
let id: String = config_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' })
|
||||
.take(40)
|
||||
.collect();
|
||||
format!("openidea-{}-{unique}", id.trim_matches('-'))
|
||||
}
|
||||
|
||||
/// `config` as a local run of the docker CLI that runs it in a new container.
|
||||
pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<RunConfig> {
|
||||
let target = run.target;
|
||||
let command = remote_command(config, target, run.root, run.container_root);
|
||||
// Variables go by name (`-e NAME`) so their values stay out of the command line
|
||||
// echoed in the console; the docker CLI reads them from its own environment.
|
||||
let mut env: BTreeMap<String, String> = run.file_env.clone();
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
|
||||
&& !command.env.contains_key("PYTHONUNBUFFERED")
|
||||
{
|
||||
env.insert("PYTHONUNBUFFERED".into(), "1".into());
|
||||
}
|
||||
env.extend(command.env.clone());
|
||||
let pty = config.extras.emulate_terminal;
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
match target.kind {
|
||||
RunTargetKind::Docker => {
|
||||
if target.image.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
|
||||
}
|
||||
args.extend(["run", "--rm", "--interactive", "--init"].map(String::from));
|
||||
if pty {
|
||||
args.push("--tty".into());
|
||||
}
|
||||
let root = run.root.to_string_lossy();
|
||||
args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]);
|
||||
}
|
||||
RunTargetKind::Compose => {
|
||||
if target.service.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
|
||||
}
|
||||
args.push("compose".into());
|
||||
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(["run", "--rm"].map(String::from));
|
||||
if !pty {
|
||||
args.push("--no-TTY".into());
|
||||
}
|
||||
}
|
||||
RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")),
|
||||
}
|
||||
args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]);
|
||||
args.extend(["--workdir".into(), command.cwd.clone()]);
|
||||
let mut docker_env = run.endpoint_env.clone();
|
||||
for (key, value) in &env {
|
||||
if run.endpoint_env.contains_key(key) {
|
||||
// `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's.
|
||||
args.extend(["--env".into(), format!("{key}={value}")]);
|
||||
} else {
|
||||
args.extend(["--env".into(), key.clone()]);
|
||||
docker_env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
|
||||
args.extend(["--publish".into(), port.to_owned()]);
|
||||
}
|
||||
if let Some((uid, gid)) = run.user {
|
||||
args.extend(["--user".into(), format!("{uid}:{gid}")]);
|
||||
}
|
||||
let options = target.run_options.trim();
|
||||
if !options.is_empty() {
|
||||
args.extend(ide_run::import::split_args(options));
|
||||
}
|
||||
args.push(match target.kind {
|
||||
RunTargetKind::Compose => target.service.trim().to_owned(),
|
||||
_ => target.image.trim().to_owned(),
|
||||
});
|
||||
match command.shell_line {
|
||||
Some(line) => args.extend(["sh".into(), "-c".into(), line]),
|
||||
None => {
|
||||
args.push(command.program);
|
||||
args.extend(command.args);
|
||||
}
|
||||
}
|
||||
let mut out = config.clone();
|
||||
out.command = run.docker.to_owned();
|
||||
out.args = args;
|
||||
out.cwd = Some(run.root.to_path_buf().into());
|
||||
out.env = docker_env;
|
||||
let extras = &mut out.extras;
|
||||
extras.target = None;
|
||||
extras.env_files.clear();
|
||||
// The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`.
|
||||
extras.include_system_env = true;
|
||||
// `docker stop` signals the container's process (the CLI may not forward a signal).
|
||||
extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `word` for this machine's shell (the stop command runs there): the docker CLI found
|
||||
/// outside `PATH` (Docker.app's bundle) may contain spaces.
|
||||
fn shell_word(word: &str) -> String {
|
||||
if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() {
|
||||
word.to_owned()
|
||||
} else if cfg!(windows) {
|
||||
format!("\"{word}\"")
|
||||
} else {
|
||||
format!("'{}'", word.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's folder in the container of a Compose `service`, from the JSON of
|
||||
/// `docker compose config --format json`: the target of a bind mount of the project
|
||||
/// or a folder above it.
|
||||
pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult<String> {
|
||||
let model: serde_json::Value = serde_json::from_str(config_json)
|
||||
.map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?;
|
||||
let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else {
|
||||
return Err(IdeError::not_found(format!("the Compose files have no service '{service}'")));
|
||||
};
|
||||
let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten();
|
||||
let mut best: Option<(usize, String)> = None;
|
||||
for m in mounts {
|
||||
if m.get("type").and_then(|t| t.as_str()) != Some("bind") {
|
||||
continue;
|
||||
}
|
||||
let (Some(source), Some(target)) =
|
||||
(m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(rel) = root.strip_prefix(source) else { continue };
|
||||
let depth = Path::new(source).components().count();
|
||||
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
|
||||
let rel = crate::mapping::posix(rel);
|
||||
let target = target.trim_end_matches('/');
|
||||
let folder = match (target.is_empty(), rel.is_empty()) {
|
||||
(true, true) => "/".to_owned(),
|
||||
(_, true) => target.to_owned(),
|
||||
_ => format!("{target}/{rel}"),
|
||||
};
|
||||
best = Some((depth, folder));
|
||||
}
|
||||
}
|
||||
best.map(|(_, folder)| folder).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn config(command: &str, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
name: "app".into(),
|
||||
kind: "python".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run<'a>(
|
||||
target: &'a RunTarget,
|
||||
endpoint: &'a BTreeMap<String, String>,
|
||||
files: &'a BTreeMap<String, String>,
|
||||
) -> ContainerRun<'a> {
|
||||
ContainerRun {
|
||||
target,
|
||||
root: Path::new("/work/proj"),
|
||||
container_root: "/workspaces/proj",
|
||||
docker: "/usr/local/bin/docker",
|
||||
endpoint_env: endpoint,
|
||||
file_env: files,
|
||||
name: "openidea-app-1",
|
||||
user: Some((501, 20)),
|
||||
}
|
||||
}
|
||||
|
||||
// Unix paths are not absolute on Windows.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn docker_target_runs_the_program_in_a_new_container() {
|
||||
let target = RunTarget {
|
||||
kind: RunTargetKind::Docker,
|
||||
name: "py".into(),
|
||||
image: "python:3.12".into(),
|
||||
ports: vec!["8000:8000".into(), " ".into()],
|
||||
run_options: r#"--network host --label "a b""#.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let endpoint = BTreeMap::from([
|
||||
("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()),
|
||||
("PATH".to_owned(), "/usr/local/bin".to_owned()),
|
||||
]);
|
||||
let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]);
|
||||
let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]);
|
||||
c.env.insert("PATH".into(), "/opt/bin".into());
|
||||
c.extras.target = Some("t".into());
|
||||
c.extras.env_files = vec![".env".into()];
|
||||
let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap();
|
||||
assert_eq!(out.command, "/usr/local/bin/docker");
|
||||
assert_eq!(
|
||||
out.args.join(" "),
|
||||
"run --rm --interactive --init --volume /work/proj:/workspaces/proj \
|
||||
--name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \
|
||||
--env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \
|
||||
--publish 8000:8000 --user 501:20 --network host --label a b \
|
||||
python:3.12 python3 -u /workspaces/proj/main.py"
|
||||
);
|
||||
assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped");
|
||||
assert_eq!(out.env["SECRET"], "s3");
|
||||
assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH");
|
||||
assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack");
|
||||
assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj")));
|
||||
assert_eq!(out.extras.target, None);
|
||||
assert!(out.extras.env_files.is_empty() && out.extras.include_system_env);
|
||||
assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1"));
|
||||
assert_eq!(out.kind, "python", "test and output parsers still apply");
|
||||
|
||||
let mut c = config("npm run dev", &[]);
|
||||
c.kind = "npm".into();
|
||||
c.extras.emulate_terminal = true;
|
||||
let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() };
|
||||
let mut r = run(&target, &endpoint, &files);
|
||||
r.user = None;
|
||||
let out = container_config(&c, &r).unwrap();
|
||||
assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from)));
|
||||
assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string()));
|
||||
assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from)));
|
||||
|
||||
let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() };
|
||||
assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn compose_target_runs_in_a_new_container_of_the_service() {
|
||||
let target = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let none = BTreeMap::new();
|
||||
let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]);
|
||||
c.kind = "django-test".into();
|
||||
c.cwd = Some(PathBuf::from("/work/proj/backend").into());
|
||||
let mut r = run(&target, &none, &none);
|
||||
r.container_root = "/app";
|
||||
let out = container_config(&c, &r).unwrap();
|
||||
assert_eq!(
|
||||
out.args.join(" "),
|
||||
"compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \
|
||||
--name openidea-app-1 --label openidea.run=app --workdir /app/backend \
|
||||
--env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test"
|
||||
);
|
||||
assert_eq!(out.env["DATA"], "/app/data");
|
||||
|
||||
let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
|
||||
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn compose_container_root_follows_the_project_bind_mount() {
|
||||
let json = r#"{"services": {
|
||||
"web": {"volumes": [
|
||||
{"type": "volume", "source": "data", "target": "/data"},
|
||||
{"type": "bind", "source": "/work", "target": "/src"},
|
||||
{"type": "bind", "source": "/work/proj", "target": "/app/"}
|
||||
]},
|
||||
"worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]},
|
||||
"db": {}
|
||||
}}"#;
|
||||
let root = Path::new("/work/proj");
|
||||
assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins");
|
||||
assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj");
|
||||
assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount"));
|
||||
assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_default_folder() {
|
||||
assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x");
|
||||
assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_quotes_the_docker_cli_for_the_shell() {
|
||||
assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker");
|
||||
assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
|
||||
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
|
||||
//! configurations on run targets ([`target`]).
|
||||
|
||||
pub mod deploy;
|
||||
pub mod docker;
|
||||
pub mod import;
|
||||
pub mod mapping;
|
||||
pub mod service;
|
||||
|
||||
@@ -338,9 +338,6 @@ impl RemoteService {
|
||||
) -> IdeResult<RunInfo> {
|
||||
let target_id = config.extras.target.as_deref().unwrap_or_default();
|
||||
let target = run_target(settings, target_id)?;
|
||||
if target.kind != ide_core::remote_settings::RunTargetKind::Ssh {
|
||||
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
|
||||
}
|
||||
let session = self.ssh.session(&target.ssh_config).await?;
|
||||
let fs = RemoteFs::new(session.sftp().await?);
|
||||
let folder = if target.project_root.trim().is_empty() {
|
||||
|
||||
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
|
||||
|
||||
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
|
||||
/// (like `parseArgs` in `src/platform/commandLine.ts`).
|
||||
pub fn split_args(line: &str) -> Vec<String> {
|
||||
pub(crate) fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
|
||||
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.5.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -52,43 +52,6 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
|
||||
}
|
||||
|
||||
/// Why `composer install` is due in a Composer project.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposerInstall {
|
||||
/// No `vendor/autoload.php`.
|
||||
Missing,
|
||||
/// A package of `composer.lock` is not installed, or at another version.
|
||||
Outdated,
|
||||
}
|
||||
|
||||
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
|
||||
/// whether its dependencies need `composer install`.
|
||||
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
|
||||
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
|
||||
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
|
||||
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
|
||||
if !vendor.join("autoload.php").is_file() {
|
||||
return Some((dir.to_path_buf(), ComposerInstall::Missing));
|
||||
}
|
||||
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
|
||||
let lock = read(dir.join("composer.lock"))?;
|
||||
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
|
||||
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
|
||||
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
|
||||
let versions: std::collections::HashMap<&str, &str> =
|
||||
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
|
||||
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
|
||||
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
|
||||
let outdated = sections
|
||||
.iter()
|
||||
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
|
||||
.any(|(name, version)| versions.get(name) != Some(&version));
|
||||
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
|
||||
/// with in that storage, and reuses them after the version changes. Without a version, or
|
||||
@@ -263,39 +226,6 @@ fn lower_bound(alternative: &str) -> Option<Version> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let app = root.join("app");
|
||||
std::fs::create_dir_all(app.join("src")).unwrap();
|
||||
let file = app.join("src/Kernel.php");
|
||||
assert_eq!(composer_install(&file, root), None, "not a Composer project");
|
||||
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
|
||||
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
|
||||
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
|
||||
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
|
||||
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
|
||||
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
|
||||
std::fs::write(app.join("composer.lock"), lock).unwrap();
|
||||
let installed = |packages: &str, dev: bool| {
|
||||
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
|
||||
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
|
||||
composer_install(&file, root)
|
||||
};
|
||||
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
|
||||
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
|
||||
assert_eq!(installed(&format!("{a},{t}"), true), None);
|
||||
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
|
||||
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
|
||||
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
|
||||
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
|
||||
// Composer 1 lists the packages at the top level.
|
||||
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
|
||||
assert_eq!(composer_install(&file, root), None);
|
||||
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
|
||||
}
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
@@ -29,24 +29,6 @@ pub fn display_name(tool: &str) -> &str {
|
||||
crate::registry::find(tool).map_or(tool, |t| t.display)
|
||||
}
|
||||
|
||||
/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PhpCsFixer {
|
||||
pub config: PathBuf,
|
||||
pub bin: PathBuf,
|
||||
}
|
||||
|
||||
/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as
|
||||
/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`.
|
||||
/// `None` without both: Reformat Code keeps the language server.
|
||||
pub fn php_cs_fixer(file: &Path) -> Option<PhpCsFixer> {
|
||||
let config = file.parent()?.ancestors().find_map(|dir| {
|
||||
[".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file())
|
||||
})?;
|
||||
let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?;
|
||||
Some(PhpCsFixer { config, bin })
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
|
||||
let name = display_name(tool);
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
@@ -177,33 +159,6 @@ pub fn arguments(
|
||||
args
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the
|
||||
/// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`.
|
||||
pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult<Command> {
|
||||
let php = match php {
|
||||
Some(php) => php.to_path_buf(),
|
||||
None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?,
|
||||
};
|
||||
let mut cmd = self.command(php);
|
||||
cmd.arg(&fixer.bin)
|
||||
.args(["fix", "--config"])
|
||||
.arg(&fixer.config)
|
||||
.args(["--using-cache=no", "--no-interaction", "--show-progress=none"])
|
||||
.arg(target);
|
||||
if let Some(dir) = fixer.config.parent() {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place.
|
||||
pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel)
|
||||
.map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim())))
|
||||
}
|
||||
|
||||
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.5.0
|
||||
Bundle-Version: 1.3.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -75,14 +75,6 @@ unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
|
||||
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
|
||||
Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's
|
||||
source folders, before the standard request that also searches every dependency jar); its
|
||||
reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`.
|
||||
Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources
|
||||
(no dependency implements it), otherwise the app also runs the standard request and adds the
|
||||
dependencies' implementations after the first ones. The search also covers the project's Kotlin
|
||||
classes attached by the Kotlin bridge; its reproducible SHA-256 is
|
||||
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
@@ -112,7 +104,6 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
python3 java/java-usages/tests/implementations.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
|
||||
Binary file not shown.
@@ -7,9 +7,6 @@
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package ai.openidea.implementations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
import org.eclipse.lsp4j.Location;
|
||||
|
||||
/**
|
||||
* Implementations of the type or method at a position, looked up in the workspace's own code
|
||||
* only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard
|
||||
* request builds the type hierarchy over every dependency jar too, which takes long in a project
|
||||
* with many dependencies. {@code complete} when the type is declared in the sources, which no
|
||||
* dependency extends; otherwise the client also asks the standard request for the dependencies'
|
||||
* implementations.
|
||||
*/
|
||||
public final class ImplementationsCommand implements IDelegateCommandHandler {
|
||||
/** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */
|
||||
private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin";
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
List<Location> locations = new ArrayList<>();
|
||||
boolean complete = find(arguments, locations, monitor);
|
||||
return Map.of("locations", locations, "complete", complete);
|
||||
}
|
||||
|
||||
/** Adds the implementations to {@code result}; whether they are all there are. */
|
||||
private static boolean find(List<Object> arguments, List<Location> result, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) arguments.get(0);
|
||||
int line = ((Number) arguments.get(1)).intValue();
|
||||
int column = ((Number) arguments.get(2)).intValue();
|
||||
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
|
||||
if (root == null) return false;
|
||||
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
|
||||
IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
|
||||
IMethod method = null;
|
||||
IType type;
|
||||
if (element instanceof IMethod m) {
|
||||
int flags = m.getFlags();
|
||||
if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false;
|
||||
method = m;
|
||||
type = m.getDeclaringType();
|
||||
} else if (element instanceof IType t) {
|
||||
type = t;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (type == null || type.getJavaProject() == null) return false;
|
||||
IRegion sources = JavaCore.newRegion();
|
||||
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
|
||||
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
|
||||
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
|
||||
sources.add(fragmentRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
|
||||
for (IType subtype : hierarchy.getAllSubtypes(type)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
IMember found;
|
||||
if (method == null) {
|
||||
found = subtype.isInterface() ? null : subtype;
|
||||
} else {
|
||||
// Seen from the subtype, so its type arguments substitute the method's type variables.
|
||||
IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method);
|
||||
found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding;
|
||||
}
|
||||
Location location = found == null ? null : JDTUtils.toLocation(found);
|
||||
if (location != null) result.add(location);
|
||||
}
|
||||
return !type.isBinary();
|
||||
}
|
||||
|
||||
/** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */
|
||||
private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException {
|
||||
if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false;
|
||||
IClasspathEntry entry = root.getRawClasspathEntry();
|
||||
if (entry == null) return false;
|
||||
for (IClasspathAttribute attribute : entry.getExtraAttributes()) {
|
||||
if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home."""
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
home = pathlib.Path(sys.argv[1]).resolve()
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-implementations-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
(project / 'src/zoo').mkdir(parents=True)
|
||||
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them.
|
||||
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="lib" path="kotlin"><attributes><attribute name="ai.openidea.kotlin" value="true"/></attributes></classpathentry><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
sources = {
|
||||
'Repo.java': '''package zoo;
|
||||
|
||||
public interface Repo<T> {
|
||||
void save(T item);
|
||||
|
||||
default String name() {
|
||||
return "repo";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'SubRepo.java': '''package zoo;
|
||||
|
||||
public interface SubRepo extends Repo<String> {
|
||||
}
|
||||
''',
|
||||
'Base.java': '''package zoo;
|
||||
|
||||
public abstract class Base implements Repo<Integer> {
|
||||
public abstract void save(Integer item);
|
||||
}
|
||||
''',
|
||||
'Numbers.java': '''package zoo;
|
||||
|
||||
public class Numbers extends Base {
|
||||
public void save(Integer item) {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Words.java': '''package zoo;
|
||||
|
||||
public class Words implements SubRepo {
|
||||
public void save(String item) {
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return "words";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Task.java': '''package zoo;
|
||||
|
||||
public class Task implements Runnable {
|
||||
public void run() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
void use(Repo<String> repo, Runnable task) {
|
||||
repo.save("x");
|
||||
task.run();
|
||||
}
|
||||
|
||||
void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
for name, text in sources.items():
|
||||
(project / 'src/zoo' / name).write_text(text)
|
||||
uri = lambda name: (project / 'src/zoo' / name).as_uri()
|
||||
# Stands in for a Kotlin class of the project implementing the Java interface.
|
||||
(work / 'kotlin-src/zoo').mkdir(parents=True)
|
||||
(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo<Long> {\n public void save(Long item) {\n }\n}\n')
|
||||
subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True)
|
||||
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
|
||||
messages = queue.Queue()
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def send(message):
|
||||
data = json.dumps(message).encode()
|
||||
with lock:
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def read():
|
||||
while True:
|
||||
headers = {}
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
return
|
||||
if line == b'\r\n':
|
||||
break
|
||||
k, v = line.decode().split(':', 1)
|
||||
headers[k.lower()] = v.strip()
|
||||
message = json.loads(proc.stdout.read(int(headers['content-length'])))
|
||||
if 'method' in message and 'id' in message:
|
||||
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
|
||||
else:
|
||||
messages.put(message)
|
||||
|
||||
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
seq = 0
|
||||
|
||||
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if msg.get('id') == seq:
|
||||
if 'error' in msg:
|
||||
raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def position(name, needle):
|
||||
lines = sources[name].splitlines()
|
||||
line = next(i for i, t in enumerate(lines) if needle in t)
|
||||
return line, lines[line].index(needle)
|
||||
|
||||
|
||||
def command(name, *args):
|
||||
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
|
||||
|
||||
|
||||
|
||||
|
||||
def implementations(file, needle):
|
||||
line, col = position(file, needle)
|
||||
found = command('openidea.java.implementations', uri(file), line, col)
|
||||
names = []
|
||||
for loc in found['locations']:
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name
|
||||
names.append(f"{name}:{loc['range']['start']['line']}")
|
||||
return sorted(names), found['complete']
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['commands']
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
for name, text in sources.items():
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
|
||||
request('java/buildWorkspace', True)
|
||||
# (implementations in the sources, whether they are all there are)
|
||||
cases = {
|
||||
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
|
||||
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
|
||||
# Through the type arguments; not the abstract re-declaration.
|
||||
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Repo.java', 'name()'): (['Words.java:6'], True),
|
||||
# A library type's implementations in the sources; the library has more.
|
||||
('Use.java', 'Runnable task'): (['Task.java:2'], False),
|
||||
('Use.java', 'run();'): (['Task.java:3'], False),
|
||||
('Use.java', 'alone()'): ([], True),
|
||||
}
|
||||
failures = []
|
||||
for (file, needle), expected in cases.items():
|
||||
actual = implementations(file, needle)
|
||||
print(f'{file} {needle!r}: {actual}')
|
||||
if actual != expected:
|
||||
failures.append((file, needle, expected, actual))
|
||||
assert not failures, failures
|
||||
print('ok')
|
||||
finally:
|
||||
proc.kill()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls"
|
||||
adapter = "netcoredbg"
|
||||
command = "netcoredbg"
|
||||
args = ["--interpreter=vscode"]
|
||||
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
|
||||
@@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true }
|
||||
adapter = "netcoredbg"
|
||||
command = "netcoredbg"
|
||||
args = ["--interpreter=vscode"]
|
||||
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
|
||||
@@ -59,4 +59,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
@@ -58,4 +58,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
@@ -32,4 +32,4 @@ install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kot
|
||||
[debugger]
|
||||
adapter = "kotlin-debug-adapter"
|
||||
command = "kotlin-debug-adapter"
|
||||
install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH"
|
||||
+1
-1
@@ -29,4 +29,4 @@ install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usag
|
||||
[debugger]
|
||||
adapter = "php-debug"
|
||||
command = "php-debug-adapter"
|
||||
install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug"
|
||||
install-hint = "Install vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug"
|
||||
+1
-1
@@ -23,4 +23,4 @@ adapter = "codelldb"
|
||||
command = "codelldb"
|
||||
args = ["--port", "${port}"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH"
|
||||
@@ -17,4 +17,4 @@ install-hint = "npm i -g bash-language-server (optionally install shellcheck and
|
||||
[debugger]
|
||||
adapter = "bash-debug"
|
||||
command = "bash-debug-adapter"
|
||||
install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)"
|
||||
@@ -69,4 +69,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
@@ -58,4 +58,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
+1
-1
@@ -16,4 +16,4 @@ adapter = "codelldb"
|
||||
command = "codelldb"
|
||||
args = ["--port", "${port}"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
|
||||
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)"
|
||||
+1
-8
@@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.2.0`, world
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world
|
||||
`plugin`).
|
||||
|
||||
## 1. Quick start
|
||||
@@ -152,13 +152,6 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's
|
||||
declared commands. A plugin shortcut never takes a keystroke the IDE (or the
|
||||
user) already uses; the user can rebind it in the keymap editor.
|
||||
|
||||
A `when` clause (on commands and menus) can test whether the open project
|
||||
contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`.
|
||||
The path is exact (no globs) and relative to the project root; in a workspace the
|
||||
key is true when any of its projects contains the path. It updates when
|
||||
files change. An IDE that doesn't know the key treats it as
|
||||
false: the plugin still installs and the key reads as false there.
|
||||
|
||||
### Activation
|
||||
|
||||
Code is instantiated lazily: when one of the plugin's commands runs, or when
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0
|
||||
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505
|
||||
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b
|
||||
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1
|
||||
66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
e7cdaeb4f482f698026542c1545f3a1f4734f78f7e9ca0a83cdbc805669c2c7c
|
||||
04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286
|
||||
674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
786fd1808d786dd6abd2671b567db1e37075c08610ac4715c93c0624dbe396d6
|
||||
ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63
|
||||
b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7
|
||||
6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7
|
||||
56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea
|
||||
ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722
|
||||
@@ -89,7 +89,7 @@ pub mod providers_bindings {
|
||||
export_macro_name: "__export_editor_providers_world",
|
||||
default_bindings_module: "openidea_plugin::providers_bindings",
|
||||
with: {
|
||||
"openidea:plugin/types@1.2.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,16 +106,16 @@ pub mod views_bindings {
|
||||
export_macro_name: "__export_views_world",
|
||||
default_bindings_module: "openidea_plugin::views_bindings",
|
||||
with: {
|
||||
"openidea:plugin/types@1.2.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace@1.2.0": crate::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor@1.2.0": crate::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation@1.2.0": crate::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace@1.1.0": crate::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor@1.1.0": crate::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation@1.1.0": crate::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The plugin API version this SDK targets.
|
||||
pub const API_VERSION: &str = "1.2";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
|
||||
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
|
||||
/// it is disabled, crashes or is reclaimed when idle).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/// or processes beyond WASI's clocks and randomness; everything else goes
|
||||
/// through the imports below, gated by the capabilities granted in
|
||||
/// `plugin.toml` (a missing one returns `error::denied`).
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error};
|
||||
|
||||
+1
-4
@@ -200,10 +200,7 @@ async function smoke(language: string) {
|
||||
}
|
||||
|
||||
// Tests, with the intentionally failing one switched on: one failure, located, the rest passed.
|
||||
// Switched on in the saved configuration, as reruns launch the configuration as it is saved now.
|
||||
const failing = c.tests.failing(config(c.tests.config));
|
||||
useRun.setState((s) => ({ configs: s.configs.map((x) => (x.id === failing.id ? failing : x)) }));
|
||||
const { id, output } = await runToEnd(failing);
|
||||
const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config)));
|
||||
const tests = runtime(id)!.tests!;
|
||||
const log = `\n--- output ---\n${output.slice(-80).join("\n")}`;
|
||||
expect(tests.counts(), log).toMatchObject({ passed: c.tests.passed, failed: 1 });
|
||||
|
||||
@@ -129,8 +129,7 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
|
||||
Ok(project_info(&project))
|
||||
}
|
||||
|
||||
// Not on the main thread: closing stops the project's language servers, runs and file watcher.
|
||||
#[tauri::command(async)]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn close_project(window: Window, state: State<'_, AppState>) {
|
||||
crate::windows::close_auxiliary_windows(window.app_handle(), window.label());
|
||||
|
||||
@@ -92,13 +92,6 @@ pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, t
|
||||
ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
/// Types `text` into the session's program (a `runInTerminal` program).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_send_input(state: State<'_, AppState>, session: DebugSessionId, text: String) -> IdeResult<()> {
|
||||
ide_host::ops::debug::debug_send_input(&state.host, "", session, text).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
|
||||
@@ -80,23 +80,6 @@ pub async fn lsp_goto(
|
||||
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Go to Implementation of the symbol at `pos`. Implementations found before a
|
||||
/// slower search for the rest arrive on `on_partial`; the result has them all.
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_goto_implementation(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
on_partial: tauri::ipc::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let on_partial = super::sender(on_partial);
|
||||
ide_host::ops::lsp::lsp_goto_implementation(&state.host, window.label(), request, doc, pos, on_partial).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at `positions`, one list per position (the
|
||||
/// editor's "is implemented / overridden" gutter markers). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
|
||||
@@ -219,7 +219,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
lsp::lsp_signature_help,
|
||||
lsp::lsp_goto,
|
||||
lsp::lsp_implementations,
|
||||
lsp::lsp_goto_implementation,
|
||||
lsp::lsp_virtual_document,
|
||||
lsp::lsp_resolve_class_file,
|
||||
lsp::lsp_references,
|
||||
@@ -382,7 +381,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_completions,
|
||||
debug::debug_resume,
|
||||
debug::debug_pause,
|
||||
debug::debug_send_input,
|
||||
debug::debug_hot_reload,
|
||||
debug::debug_hot_restart,
|
||||
debug::debug_step,
|
||||
@@ -402,7 +400,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_effective,
|
||||
sdk::sdk_php_language_level,
|
||||
sdk::sdk_php_composer_install,
|
||||
sdk::sdk_project_libraries,
|
||||
sdk::sdk_locate_tools,
|
||||
sdk::sdk_python_create_venv,
|
||||
|
||||
@@ -42,17 +42,6 @@ pub async fn sdk_php_language_level(
|
||||
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_php_composer_install(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<ide_host::ops::sdk::ComposerInstallState>> {
|
||||
ide_host::ops::sdk::sdk_php_composer_install(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -10,8 +10,6 @@ mod dev;
|
||||
mod events;
|
||||
mod jump_list;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_webkit;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_window_state;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -70,8 +68,6 @@ pub fn run() {
|
||||
// Before the first window: one taskbar identity for every launch path.
|
||||
jump_list::set_app_id(&context.config().identifier);
|
||||
logging::init(&context.config().identifier);
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_webkit::configure();
|
||||
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
||||
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! WebKitGTK's DMA-BUF renderer fails on Wayland with the NVIDIA driver: the window
|
||||
//! never shows and GDK logs "Error 71 (Protocol error) dispatching to Wayland display".
|
||||
//! On that combination the renderer is turned off before the first web view is created.
|
||||
|
||||
const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// Run before other threads start (`set_var`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn configure() {
|
||||
let nvidia = ["/proc/driver/nvidia/version", "/sys/module/nvidia"].iter().any(|p| std::path::Path::new(p).exists());
|
||||
if disable_dmabuf(|k| std::env::var(k).ok(), nvidia) {
|
||||
tracing::info!("Wayland with the NVIDIA driver: {DISABLE_DMABUF}=1");
|
||||
unsafe { std::env::set_var(DISABLE_DMABUF, "1") };
|
||||
}
|
||||
}
|
||||
|
||||
/// A Wayland session on the NVIDIA driver, unless the user already set the variable.
|
||||
fn disable_dmabuf(var: impl Fn(&str) -> Option<String>, nvidia: bool) -> bool {
|
||||
if !nvidia || var(DISABLE_DMABUF).is_some() {
|
||||
return false;
|
||||
}
|
||||
let wayland_display = var("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty());
|
||||
// GDK tries the backends of `GDK_BACKEND` in order.
|
||||
let gdk_wayland =
|
||||
var("GDK_BACKEND").is_none_or(|b| matches!(b.split(',').next().map(str::trim), Some("wayland" | "*" | "")));
|
||||
wayland_display && gdk_wayland
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decide(vars: &[(&str, &str)], nvidia: bool) -> bool {
|
||||
disable_dmabuf(|k| vars.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string()), nvidia)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_the_dmabuf_renderer_off_on_wayland_with_nvidia_only() {
|
||||
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0")], true));
|
||||
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "wayland,x11")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0")], false));
|
||||
assert!(!decide(&[("DISPLAY", ":0")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "x11")], true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_users_own_setting() {
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), (DISABLE_DMABUF, "0")], true));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user