Compare commits

..
Author SHA1 Message Date
l0ng-ai b550be9685 feat(plugins): open plugin API 1.2
API 1.1.0 shipped with 0.1.37 and its WIT is frozen in the released
snapshot, so additions now go into 1.2.0. The package version in wit/
and the SDK's copy, ENGINE_VERSION and both API_VERSION constants move to
1.2; the official plugins and test fixtures are rebuilt against it.

No interface, type or function changes: 1.0 and 1.1 components keep
linking. The release that ships 1.2 copies wit/ over
sdk/rust/openidea-plugin/tests/api/released/.
2026-11-06 09:29:03 +08:00
l0ng-ai a6474539b8 fix(remote): build the Docker run tests' helpers only where they run (#1645)
The container command tests are Unix-only, so on Windows their helpers and the PathBuf import were unused and clippy -D warnings failed the Rust (windows-latest) job.
2026-11-06 08:35:22 +08:00
l0ng-ai 401b4a9441 fix(java): list dependencies' implementations after the project's in Go to Implementation (#1644)
Go to Implementation on a type or method declared in a dependency listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}, complete when the type is declared in the sources, and also searches the Kotlin bridge's classes. When not complete, the chooser opens with the project's implementations (searching…) through the new lsp_goto_implementation channel and fills in the dependencies' when the standard request ends; closing it cancels the search.
2026-11-06 08:16:13 +08:00
l0ng-ai 086c89df5b fix(settings): fit long labels in aligned settings forms (#1643)
The aligned settings forms had a fixed 130px label column and labels don't wrap, so "Project folder on target" and longer translations ran under their field. The aligned rows of a section now share one subgrid label column, at least 130px and as wide as its widest label (Run Targets, SSH Configurations, CMake, Tools).
2026-11-06 08:11:50 +08:00
l0ng-ai 5325d98c3f fix(vim): save Normal-mode Vim edits (#1642)
codemirror-vim labels its own edits input.type.compose to group them for undo; the Normal-mode IME extender matched that label alone and marked them transient, so the document model and saves missed them. Only transactions of a view with an IME composition in progress are transient now.
2026-11-06 08:04:25 +08:00
Andrea Basileandandreabasile 589cf81bae feat(run): Docker and Compose run targets (#1640)
A run target can be a Docker image or a Compose service: the configuration runs as a local docker run --rm / docker compose run --rm with the project bind-mounted, so the console, PTY, stdin, Stop and test parsers work unchanged. Only daemons on this machine are accepted; on Linux the container runs as the project folder's owner; Stop runs docker stop with the same docker CLI. Debugging, coverage and profiling stay on this machine. Refs #1083 (step A1).

Co-authored-by: andreabasile <[email protected]>
2026-11-06 07:34:00 +08:00
l0ng-ai a5d1b16e59 fix(debugger): debug PHP with php-debug on the configured PHP (#1641)
php-debug had no launch template: the generic one sent the configuration's command as the program, so artisan serve failed or ran the PHP binary as a script, and PHPUnit skipped breakpoints unless php.ini enabled Xdebug's debug mode. The template runs the script on the PHP the command names or the one from Settings, enables debug mode with -d options on the port php-debug picks, and PHP files and php scripts become debuggable. php-debug returns to the configuration form's Debug adapter list.
2026-11-06 07:27:22 +08:00
Daniel AdámekandDanielADK b450f4028b fix(debugger): finish PHP Remote Debug: taken port, waiting state, Port field (#1639)
A taken port now says which port is in use instead of Node's EADDRINUSE text; a listening PHP Remote Debug session shows "Listening for Xdebug on port N" instead of "Running…"; clearing the only field of a debug-only remote type keeps the form and shows the field as required; php-debug leaves the generic Debug adapter list until it has a launch template (#1623).

Co-authored-by: DanielADK <[email protected]>
2026-11-06 06:56:51 +08:00
Deng Liuanddengliu 1f76e71621 fix(viewers): yield image viewer to registered text editor wrappers (#1638)
A plugin's side-by-side custom editor for an image-extension subtype (such as *.excalidraw.svg) was intercepted by the image viewer. The image viewer now yields to a matching text editor wrapper, and an image tab already open switches to the text editor when such a wrapper registers later (after plugins load).

Co-authored-by: dengliu <[email protected]>
2026-11-06 06:54:57 +08:00
l0ng-ai e6b800971a fix(debugger): offer to install Bash Debug, and point debugger hints to Settings (#1637)
Bash Debug had no entry in the missing-adapter table, so a shell script debug session without it failed with a plain error pointing at mason.nvim. The install hints of the debuggers OpenIDEA installs itself (CodeLLDB, vscode-js-debug, Kotlin Debug Adapter, netcoredbg, Bash Debug) now point to Settings › Languages & Frameworks › Language Servers, in languages/*.toml and in ide-dap's built-in definitions.
2026-11-06 06:35:01 +08:00
l0ng-ai a6cf4442d8 fix(editor): auto-save when the app is deactivated while focus is in a frame (#1636)
With focus in a plugin's web view, the window's blur fires when the frame takes focus, before any edit, and not again when the user switches applications, so edits made in the frame weren't auto-saved. The editor now also saves on the application-deactivated event.
2026-11-06 06:33:12 +08:00
Vadim LazukandInf1mum 83d2f67b4f feat(debugger): add PHP Remote Debug (Xdebug) run configuration (#1635)
A debug-only PHP Remote Debug (Xdebug) configuration: php-debug listens on the port (9003 by default) with optional path mappings, and the form shows how to start the remote side. php-debug is also offered as a debug adapter in the configuration form.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:28:02 +08:00
Vadim LazukandInf1mum 3ec30fff3f fix(debugger): offer to install php-debug when it is missing (#1633)
php-debug had no entry in the missing-adapter table, so a PHP debug session without it failed with a plain error pointing at mason.nvim. It now offers Install and Debug like debugpy, Delve or js-debug, and the hint points to Settings › Languages & Frameworks › Language Servers.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:57 +08:00
Vadim LazukandInf1mum 009bb21f0b fix(editor): pad the Reformat File dialog (#1634)
Dialog bodies have no padding of their own; the Reformat File dialog put its groups straight into the body, so they sat against the edges. They are now wrapped in a padded column like the other dialogs' forms.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:53 +08:00
l0ng-ai 43bb10048d test(phpstan): read the pause before logging the run (#1632)
The fake PHP logged its run before reading the pause file, so a test that reset the pause on seeing the run could make that run skip its pause on a slow machine and finish before the next save cancelled it.
2026-11-06 05:58:13 +08:00
l0ng-ai 1b4ca3cf1f test(smoke): switch the failing test on in the saved configuration (#1631)
Reruns launch the configuration as it is saved now (#1579), so Rerun Failed Tests dropped the switch the smoke test applied only to the first run and the failing test was skipped. The switched-on configuration is now put into the configuration list before the run.
2026-11-06 05:55:56 +08:00
NealimeKennaandNealimeKenna 08f2d9756d fix(language): update tree-sitter-haskell to 0.24.1 (#1630)
tree-sitter-haskell 0.23.1's array.h is miscompiled by GCC 16 under strict aliasing, so Haskell parsing aborted the process (glibc "corrupted size vs. prev_size"); 0.24 fixes it. 0.24 renames the node type_synomym to type_synonym, so the Haskell outline query follows.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-06 05:53:07 +08:00
l0ng-ai b4bd0ec1e2 feat(debugger): Reload Changed Classes in the editor context menu (#1627)
Shown on a Java file while a debug session can reload classes (java-debug hot code replace); it runs the existing debug.reloadChangedClasses action.
2026-11-06 05:35:43 +08:00
l0ng-ai 7634ae81f1 fix(linux): start on Wayland with the NVIDIA driver (#1625)
WebKitGTK's DMA-BUF renderer fails on a Wayland session with the NVIDIA driver ("Error 71 (Protocol error) dispatching to Wayland display") and the app exits before showing a window. On that combination WEBKIT_DISABLE_DMABUF_RENDERER=1 is set at startup unless the user already set it.
2026-11-06 05:32:58 +08:00
Arnaud Ritti 7bd96a998b feat(plugins): when clauses that test whether the project contains a file (#1620)
Plugin when clauses can use workspaceContains:<path>, a path relative to the project root (any project of a workspace). Installation refuses empty, absolute and .. paths. The keys are re-checked on plugin changes, project switches, created or removed files and changes in ignored folders; plugins that don't use the key do no IPC. The key regex avoids lookbehind for WebKit.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 05:27:50 +08:00
Vadim LazukandInf1mum 4830f42168 feat(vcs): add Preview Diff toggle to the Commit tool window (#1615)
A Preview Diff toggle in the Commit tool window's changes toolbar (and View Options › Show Diff Preview) shows the selected file's working-tree diff in an embedded, resizable pane: beside the changes and the commit message when the window is bottom-docked, below them when side-docked, as in the Local Changes tab. Off by default and remembered; empty states when nothing or several files are selected, or there are no changes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 03:18:25 +08:00
l0ng-ai 800949b7b5 fix(agents): wrap a permission request line in a narrow conversation (#1617)
A permission request line kept the title, "Waiting for your answer" and the answers on one row, so in a narrow agent conversation a long command title squeezed the answers until they were cut off. Title, state and answers now wrap next to the icon: a long title takes its own line and the state and answers go below it, in line with the title.
2026-11-06 03:14:41 +08:00
l0ng-ai 6026374973 perf(java): find implementations in the project sources first (#1616)
JDT LS answers textDocument/implementation by building the type hierarchy over every dependency jar, so Go to Implementation and the implemented/overridden gutter markers were slow in projects with many dependencies. The app-owned JDT LS bundle (1.4.0) adds openidea.java.implementations: subtypes, or overriding methods with type arguments substituted and abstract ones left out, within the workspace's source folders only. ide-lsp asks it first and falls back to the standard request when the sources have none or the command is missing.
2026-11-06 03:12:18 +08:00
l0ng-ai 0a47a685d9 fix(debugger): type input for the program in the debug console (#1614)
A program started through runInTerminal had stdin from /dev/null, and CodeLLDB's launcher only hands the program a terminal it can read from when it runs on one. runInTerminal now starts the command on a pseudo-terminal, forwards its output to the console (CRLF as LF, without CodeLLDB's clear-screen request) and reports that the program takes input; while it runs, the console's input line goes to it, while paused it evaluates as before. debugpy and js-debug programs started in a terminal get the same.
2026-11-06 03:02:42 +08:00
Vadim LazukandInf1mum 653f2ff276 feat(ui): close tabs and panel headers with middle mouse button (#1613)
A middle click on a tool window header hides the window, as its Hide button does; presses on the header's tabs and buttons keep their own behaviour. Editor tabs already closed on a middle click, except when Settings › Editor Tabs hides the close button: that marked the tabs as not closable, so a middle click (and Delete) did nothing. A tab item now hides only its close button and still closes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 02:55:26 +08:00
l0ng-ai 8689271e7c feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611)
PHP files and composer.json of a Composer project show a banner when vendor/autoload.php is missing or a package of composer.lock is not installed at its locked version (packages-dev only unless the install used --no-dev; config.vendor-dir honoured). Install runs composer install in the Run tool window with the PHP of Settings › PHP first on PATH; Ignore hides it until the project is reopened. Rechecked when composer.json, composer.lock or vendor change; not shown in an untrusted project. A Composer Install action does the same.
2026-11-06 02:43:22 +08:00
kjct0s_andKJ-AIML 7ad02da722 fix(window): cover the whole screen with a full-screen project window group on notched Macs (#1608)
A project tab group in full screen uses simple fullscreen and keeps its title bar for the window buttons. AppKit's constrainFrameRect:toScreen: keeps a titled window below the camera housing whenever it is placed, shown or resized, so on a notched display the group sat 32 pt low with the status bar off-screen. Windows in simple fullscreen now keep the frame they ask for; every other window still goes through AppKit's constraint.

Co-authored-by: KJ-AIML <[email protected]>
2026-11-06 02:33:46 +08:00
l0ng-ai 1dda103bf0 fix(app): close a project window without blocking the UI thread (#1610)
close_project was a synchronous command, so it ran on the UI thread and dropped the project there: stopping its file watcher and index worker waits for the batch of file events they are applying, many while a build or rust-analyzer writes output. The command now runs off the main thread, and the project is dropped on the project shutdown thread, which a project opened next still waits for (the index database is released first). The window Destroyed path gets the same.
2026-11-06 02:29:45 +08:00
l0ng-ai 844cc93b90 feat(php): reformat PHP files with the project's PHP-CS-Fixer configuration (#1606)
When a .php file has a .php-cs-fixer.php or .php-cs-fixer.dist.php above it and vendor/bin/php-cs-fixer is installed, Reformat Code and format on save run PHP-CS-Fixer on a temporary copy of the unsaved text with that configuration and apply the result as edits. PHP comes from the PHP interpreter setting, or PATH. A selection formats the whole file, with a note. Without a configuration, the language server keeps formatting.
2026-11-06 02:00:33 +08:00
l0ng-ai c95a525235 feat(php): New › PHP File and PHP Class with the namespace from composer.json (#1605)
New › PHP File writes <?php and the caret; New › PHP Class offers Class, Interface, Trait, Enum and PHPUnit Test with the namespace taken from the nearest composer.json's autoload / autoload-dev PSR-4 map (longest mapped folder wins; no namespace line outside every mapping). The New dialog shows 'namespace …', and Create New Test… on a .php file opens PHP Class with PHPUnit Test.
2026-11-06 01:52:01 +08:00
171 changed files with 4390 additions and 234 deletions

No files matched your search

Generated
+7 -5
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -4182,6 +4182,7 @@ dependencies = [
"ide-language",
"memchr",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4543,6 +4544,7 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -8378,7 +8380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -9980,9 +9982,9 @@ dependencies = [
[[package]]
name = "tree-sitter-haskell"
version = "0.23.1"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
dependencies = [
"cc",
"tree-sitter-language",
+3
View File
@@ -229,6 +229,9 @@ 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,
},
+59 -6
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,22 +140,55 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
/// 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.
#[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`].
/// [`SshConfiguration::id`] (SSH).
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from.
/// Remote folder the project is synchronised to and run from (SSH).
pub project_root: String,
/// Upload changed project files before each run.
/// Upload changed project files before each run (SSH).
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)]
@@ -176,4 +209,24 @@ 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);
}
}
+1
View File
@@ -16,6 +16,7 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[dev-dependencies]
+5 -5
View File
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › 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 vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
),
"delve" => cfg(
"dlv",
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
),
"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("netcoredbg/releases"));
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
}
#[tokio::test]
+10
View File
@@ -1266,6 +1266,16 @@ 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<()> {
+86 -19
View File
@@ -43,6 +43,8 @@ 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>,
@@ -90,6 +92,7 @@ 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),
@@ -266,6 +269,9 @@ 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();
}
}
}
@@ -451,8 +457,25 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
/// 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.
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)
@@ -461,16 +484,25 @@ 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 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);
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),
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -480,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
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");
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();
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
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();
+34
View File
@@ -468,6 +468,40 @@ 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();
+140
View File
@@ -0,0 +1,140 @@
//! 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());
}
+86
View File
@@ -440,3 +440,89 @@ 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());
}
}
+1 -1
View File
@@ -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.1.0";
pub const ENGINE_VERSION: &str = "1.2.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.
+28 -1
View File
@@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(_)) => {
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}"));
}
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -133,4 +141,23 @@ 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}");
}
}
}
+108
View File
@@ -92,6 +92,33 @@ 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(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
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,
@@ -103,6 +130,34 @@ 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()
@@ -273,6 +328,59 @@ 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");
+44
View File
@@ -245,6 +245,7 @@ impl Host {
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
Some(project),
))
}
@@ -257,6 +258,7 @@ 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();
@@ -266,6 +268,10 @@ 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;
@@ -280,6 +286,9 @@ impl Host {
debug.stop_key(&key).await;
}
});
if let Some(retired) = retired {
let _ = retired.join();
}
})
}
@@ -319,6 +328,7 @@ 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()) {
@@ -422,3 +432,37 @@ 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");
}
}
+8
View File
@@ -162,6 +162,11 @@ 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
}
@@ -381,6 +386,9 @@ 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
});
+26
View File
@@ -257,6 +257,28 @@ 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> {
@@ -746,6 +768,10 @@ 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 },
+25 -2
View File
@@ -1,5 +1,6 @@
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;
@@ -140,12 +141,33 @@ 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.
// Settings › Run Targets: over SSH, or in a container.
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 config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
if let Some(id) = config.extras.target.as_deref().filter(|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
@@ -271,4 +293,5 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
+108
View File
@@ -0,0 +1,108 @@
//! 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))
}
+31
View File
@@ -96,6 +96,33 @@ 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>> {
@@ -211,6 +238,10 @@ 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
+3 -3
View File
@@ -611,7 +611,7 @@ mod tests {
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`.
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
#[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\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
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";
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();
+122
View File
@@ -0,0 +1,122 @@
//! 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);
}
@@ -0,0 +1,159 @@
//! 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();
}
+1 -1
View File
@@ -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.23.1"
tree-sitter-haskell = "0.24.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_synomym name: (name) @name) @def.type
(type_synonym 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
+103 -3
View File
@@ -315,6 +315,27 @@ 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"),
@@ -1229,7 +1250,22 @@ 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).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
}
/// Implementations of the symbols at each of `positions` (gutter line
@@ -1246,7 +1282,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)).await }
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
@@ -1259,6 +1295,46 @@ 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,
@@ -1266,9 +1342,24 @@ 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,
@@ -1284,12 +1375,19 @@ 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),
}
}
@@ -1473,7 +1571,9 @@ 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)).await.unwrap_or_default()
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
.await
.unwrap_or_default()
};
let (results, declarations) = futures::join!(join_all(requests), declarations);
let mut out = Outcome::new();
+65
View File
@@ -1804,6 +1804,71 @@ 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())]);
+1 -1
View File
@@ -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.1";
pub const API_VERSION: &str = "1.2";
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 @@
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
60d67ab91d3c42127500481510c43d829e07babcab872f94a6189d27c901234a
Binary file not shown.
@@ -1 +1 @@
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
6e9aab775c231987a73d2e424f8cd389e429f525327fac4f5aba550ebc53afe6
Binary file not shown.
@@ -1 +1 @@
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
ab066ebab16ba774c2f649a72469bcd25aaa1ffaee77f70ea67395ea26ddee02
+2 -2
View File
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.1.0;
export openidea:plugin/providers@1.1.0;
include openidea:plugin/plugin-syntax@1.2.0;
export openidea:plugin/providers@1.2.0;
}
",
path: "../../../../../wit",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-remote"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,6 +10,7 @@ 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
+350
View File
@@ -0,0 +1,350 @@
//! 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'");
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
//! configurations on run targets ([`target`]).
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
pub mod deploy;
pub mod docker;
pub mod import;
pub mod mapping;
pub mod service;
+3
View File
@@ -338,6 +338,9 @@ 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() {
+1 -1
View File
@@ -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(crate) fn split_args(line: &str) -> Vec<String> {
pub fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
+1 -1
View File
@@ -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.3.0.jar");
let extension = data_dir.join("java-usages-1.5.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);
+70
View File
@@ -52,6 +52,43 @@ 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
@@ -226,6 +263,39 @@ 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)
}
+45
View File
@@ -29,6 +29,24 @@ 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"))?)
@@ -159,6 +177,33 @@ 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))
}
+1 -1
View File
@@ -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.3.0
Bundle-Version: 1.5.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,
+10 -1
View File
@@ -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.3.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.5.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,6 +75,14 @@ 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
@@ -104,6 +112,7 @@ 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.
+3
View File
@@ -7,6 +7,9 @@
<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"/>
@@ -0,0 +1,92 @@
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;
}
}
+198
View File
@@ -0,0 +1,198 @@
"""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)
+1 -1
View File
@@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls"
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true }
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -59,4 +59,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
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`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
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`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -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 https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH"
install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -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 vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug"
install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug"
+1 -1
View File
@@ -23,4 +23,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -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 https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)"
install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -69,4 +69,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
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`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
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`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -16,4 +16,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
+8 -1
View File
@@ -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.1.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.2.0`, world
`plugin`).
## 1. Quick start
@@ -152,6 +152,13 @@ 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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d
91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114
e7cdaeb4f482f698026542c1545f3a1f4734f78f7e9ca0a83cdbc805669c2c7c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777
ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b
786fd1808d786dd6abd2671b567db1e37075c08610ac4715c93c0624dbe396d6
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25
500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf
2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2
db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722
fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea
+6 -6
View File
@@ -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.1.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/types@1.2.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.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,
"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,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.1";
pub const API_VERSION: &str = "1.2";
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
/// it is disabled, crashes or is reclaimed when idle).
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
interface lifecycle {
use types.{context, error};
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
+1 -1
View File
@@ -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.1.0;
package openidea:plugin@1.2.0;
world plugin {
import log;
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.2.0;
interface workspace {
use types.{error};
+4 -1
View File
@@ -200,7 +200,10 @@ async function smoke(language: string) {
}
// Tests, with the intentionally failing one switched on: one failure, located, the rest passed.
const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config)));
// 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 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 });
+2 -1
View File
@@ -129,7 +129,8 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
Ok(project_info(&project))
}
#[tauri::command]
// Not on the main thread: closing stops the project's language servers, runs and file watcher.
#[tauri::command(async)]
#[specta::specta]
pub fn close_project(window: Window, state: State<'_, AppState>) {
crate::windows::close_auxiliary_windows(window.app_handle(), window.label());
+7
View File
@@ -92,6 +92,13 @@ 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<()> {
+17
View File
@@ -80,6 +80,23 @@ 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)`.
+3
View File
@@ -219,6 +219,7 @@ 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,
@@ -381,6 +382,7 @@ 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,
@@ -400,6 +402,7 @@ 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,
+11
View File
@@ -42,6 +42,17 @@ 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]
+4
View File
@@ -10,6 +10,8 @@ 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")]
@@ -68,6 +70,8 @@ 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.
+51
View File
@@ -0,0 +1,51 @@
//! 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