Compare commits

...
Author SHA1 Message Date
l0ng-ai f43b43aadd Merge main 2026-11-02 20:18:16 +08:00
misswellandmisswell f8105e5473 fix(maven): list every goal a plugin declares, not just the bound one (#841)
Plugin goals came from a built-in table plus the goals bound in <executions>, so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring <localRepository> of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:17:37 +08:00
HEOJUNFO 53b5052bbb fix(editor): let the scrollbar thumb drag where overview marks cover it (#807)
The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line.

Fixes #752

Co-authored-by: HEOJUNFO <[email protected]>
2026-11-02 20:17:29 +08:00
kevinh 4ea7d32074 fix(settings): confirm before exiting the IDE by default (#834)
Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off.

Fixes #749

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:16:07 +08:00
ntbang0901 502a9dc7dd fix(kotlin): compile a folder of sibling Maven projects in the Kotlin bridge (#842)
compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback.

Fixes #814

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 20:15:59 +08:00
misswellandmisswell 78b23df7b5 fix(host): close a project's background processes with it (#852)
Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:15:51 +08:00
kkmihaiandkkMihai 362ae3535c fix(trust): keep LightEdit sessions restricted (#857)
LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before.

Fixes #849

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:15:42 +08:00
kkmihaiandkkMihai f6fbc0e5db fix(run): preserve console output selection (#854)
The console virtualizes its rows, so a selection that scrolled out of the rendered window lost its rows and copying returned only part of it. Selected rows (and, while dragging, the rows between anchor and pointer) now stay mounted, auto-follow pauses while selecting, and each row ends with a line break so multi-line copies keep their newlines. Covered by unit tests and Playwright selection tests.

Fixes #839

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:13:44 +08:00
kevinh 2a6cc686eb feat(project-view): show Maven and Gradle module folders in bold (#835)
Module folders below the project root are shown in bold in the Project view, from the same build presentation the module icons use; the root and plain folders keep their style.

Fixes #813

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:13:31 +08:00
kkmihaiandkkMihai 64b8b5cea9 fix(code-insight): keep usage highlights readable (#855)
Usage previews rendered the matched occurrence through tokenSpans, so the token colour rules outranked the inherited --fg-search-match and left syntax colours on the search-match background (about 1.1:1 in Islands Light). Render the match as plain text so it takes the search-match foreground; syntax colours stay on the surrounding code. Covered by unit tests and a Playwright contrast check across light, dark, high-contrast and mixed editor schemes.

Fixes #851

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:09:27 +08:00
arookieofcanda-rookie-of-C-language e3f8932e21 test(sdk): match the too-old-JDK path as test_jdk writes it (#840)
The jdtls server-JDK test asserted the "/jdk21" literal, but test_jdk now materializes real fixture directories, so the message carries platform-native separators and the assertion failed on Windows. Match the fixture's own home path instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 20:07:48 +08:00
l0ng-ai 3be40bd57c fix(java): run the language server on a JDK so the debugger can start (#815)
The managed runtime for the Java language server was a Temurin JRE, which lacks jdk.jdi. java-debug runs inside the jdtls JVM, so Debug failed with NoClassDefFoundError com/sun/jdi/VMDisconnectedException and "debug adapter did not answer initialize in time" while Run worked. Provision a full JDK instead and replace an already-downloaded JRE runtime (Temurin's JRE lists jdk.jdi in release MODULES although the image omits it, so suitability checks IMAGE_TYPE, javac and the modules); auto-picked installed runtimes must also carry jdk.compiler, jdk.jdi and jdk.unsupported, and a user-chosen language-server JDK without them gets a clear warning. Fixes #765.
2026-11-02 15:08:20 +08:00
l0ng-ai 7375f30422 fix(vcs): keep merge editor panes scrollable while they scroll together (#758)
Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744.
2026-11-02 14:58:23 +08:00
ntbang0901 a8e12ff7c4 feat(run): reuse the configuration of a main class run from its context (#783)
Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:15 +08:00
ntbang0901 47f68862c8 feat(project-view): restore the persisted expansion state as the user left it (#782)
The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:11 +08:00
ntbang0901 193e6a4840 feat(run): colour the console of a Spring Boot run (#781)
Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:07 +08:00
arookieofcanda-rookie-of-C-language 95188d9f40 fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787)
Git before 2.41 refuses `blame --contents - <revision>` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:14 +08:00
arookieofcanda-rookie-of-C-language 6184330876 test(tools): compare the oxfmt script path by components on every platform (#786)
package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:11 +08:00
l0ng-ai 8e8a998abe feat(vcs): inline blame for the current line (#778)
Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742.
2026-11-02 12:11:12 +08:00
misswellandmisswell 743612d4ec fix(java): let jdtls read jars JDK 25 rejects (#771)
jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties.

Co-authored-by: misswell <[email protected]>
2026-11-02 12:05:25 +08:00
d0whc3r 44c603f7db feat: add oxfmt/oxlint support for JS and TS projects (#717)
Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool.

Co-authored-by: d0whc3r <[email protected]>
2026-11-02 11:22:49 +08:00
l0ng-ai e2f8e8d5bc fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770)
Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692.
2026-11-02 11:14:27 +08:00
l0ng-ai 8ca7efc411 fix(java): say when a configured JDK path no longer exists
A JDK chosen in Settings › Languages & Frameworks › Java that was later
removed (e.g. a version manager replaced it on upgrade) was reported as
"is not a JDK home", which reads as if the folder had the wrong layout.
The language server JDK error and the project JDK warning now say the path
does not exist and how to fix it.
2026-11-02 10:49:16 +08:00
l0ng-ai ff97d7724c fix(ui): give AsciiDoc files their own icon (#763)
AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it.
2026-11-02 10:47:30 +08:00
kkmihaiandkkMihai 239753df9f fix(editor): speed up rapid file opening (#734)
Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724.

Co-authored-by: kkMihai <[email protected]>
2026-11-02 10:38:02 +08:00
arookieofcanda-rookie-of-C-language c8cc69a56e feat(language): add D language support (#728)
Register D (.d/.di) with comments, indentation, the built-in D highlighting mode, a file icon, and serve-d as its language server (dub.json/dub.sdl roots). Closes #727.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:04:03 +08:00
arookieofcanda-rookie-of-C-language c5ff0de1ee fix(terminal): open the file a link names, not a same-named one (#711)
resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:03:59 +08:00
Bl0ck fb55847afa feat(editor): recognize AsciiDoc files with basic highlighting (#731)
Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708.

Co-authored-by: Bl0ck154 <[email protected]>
2026-11-02 10:01:04 +08:00
misswellandmisswell 2653021389 fix(java): pass the IDE proxy to language-server JVMs (#745)
A JVM reads none of HTTP_PROXY/HTTPS_PROXY, so jdtls went direct and a repository that accepts the connection but never answers stalled the Maven import. ProxySettings::jvm_args turns the proxy configuration into JVM system properties (useSystemProxies for Auto, http/https/nonProxyHosts for a manual HTTP proxy, socks* for SOCKS), passed to jdtls as --jvm-arg; a property the user set themselves (matched by exact name) wins. Maven sync keeps Maven's normal snapshot update policy.

Co-authored-by: misswell <[email protected]>
2026-11-02 10:00:32 +08:00
arookieofcanda-rookie-of-C-language e7f00ee7ce fix(tools): pick the platform archive for kotlin-lsp (#726)
kotlin-lsp releases renamed their archives to kotlin-server-*, so no rule matched and the loose kotlin-*.zip fallback picked the Windows zip on every OS. Match the current names per platform: .sit (a zip archive) on macOS, .tar.gz on Linux, .win.zip on Windows; extract .sit as zip; drop the loose fallback.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 09:59:54 +08:00
Jasmeet Singhandjasi381 4015a5b71f fix(maven): read Maven 4 <subprojects> as modules (#700)
Maven 4 (model 4.1.0) declares child projects in <subprojects>/<subproject> instead of <modules>/<module>. The POM reader only read <modules>, so the children of a Maven 4 aggregator were missing. Projects and profiles now read both.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:46:36 +08:00
Dimas Victoranddvictor357 88619505c3 feat(terminal): paste clipboard images into remote SSH terminals (#699)
In a remote-project window, pasting a clipboard image into the terminal uploads it to the SSH host that owns the terminal (new term_store_image command over the existing remote RPC) and inserts the POSIX-quoted path after a successful upload, without a newline. Images go to a private per-user terminal-images directory (0700/0600, UUID names, no-follow descriptor-relative writes) and expire after 24 hours on the next upload. Failures notify and insert nothing; text paste and local terminals are unchanged. Images over 20 MiB are rejected in both the frontend and the host.

Co-authored-by: dvictor357 <[email protected]>
2026-11-01 21:42:15 +08:00
Jasmeet Singhandjasi381 0d0de01831 fix(java): re-sync jdtls after a Gradle sync so new dependencies resolve (#694)
After a Gradle sync, build_sync did not send java/projectConfigurationsUpdate to jdtls (only the Maven path did), so jdtls kept its cached classpath and dependencies newly added to build.gradle stayed unresolved. The update is now sent for both Maven and Gradle, listing each project's build file.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:03:17 +08:00
arookieofcanda-rookie-of-C-language 9ce3c4cd7e test(inspections): expect the Cargo group (#695)
The dependency-update inspection added a Cargo built-in group; the inspections tree test still expected the previous group list.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 20:46:03 +08:00
arookieofcandl0ng-ai 74062497bd fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks (#690)
* fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks

`RegGetValueW` reports the unexpanded size of a `REG_EXPAND_SZ` value, so
a user `Path` that grows when its `%VAR%` references are expanded was
silently dropped. And when the terminating NUL leaked into the read
buffer with a stray UTF-16 unit behind it, `trim_end_matches('\0')`
left that unit in the string and `env::set_var("PATH", ...)` panicked
("strings passed to WinAPI cannot contain NULs", #683).

Re-read at the size the failed call reports, and cut the decoded string
at the first NUL instead of trimming trailing ones. Covered by a test
against a throwaway key under HKCU\Software.

* test: cover registry terminator corruption and refresh inspection groups

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-01 20:31:32 +08:00
l0ng-ai 8d7ce81249 fix(macos): open project windows as tabs per the system tab preference (#693)
Project windows only joined a tab group when merged explicitly or restored at startup; the macOS "Prefer tabs when opening documents" setting was never read, so opening another project or a workspace always created a separate window.

New project windows now follow that preference: with Always they join the active project window's tab group (forming one if needed) and are selected; with In Full Screen they do so only while the active window is full screen; with Manually they stay separate. This covers every path that creates a project window — Open, recent projects, workspaces, drag and drop, and opening from the command line or Finder — while LightEdit windows and restored groups keep their behaviour. New Workspace now opens through the same Open project in policy as Open Workspace, and a race when toggling full screen during the join is fixed.

Fixes #678
2026-11-01 20:14:08 +08:00
l0ng-ai b3abc8edd1 fix(remote): browse remote directories without an open project (#691)
Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project.

remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified.

Fixes #672
2026-11-01 20:09:08 +08:00
l0ng-ai 245fe3cdf4 feat(run): .NET project run and debug configurations (#686)
.NET projects had no run configurations.

Projects from .csproj files (and those listed in .sln/.slnx) now get configurations: executable projects (OutputType Exe/WinExe, or the Web SDK) get a .NET Project configuration running dotnet run, one per Project launch profile in Properties/launchSettings.json with its application URL and environment shown; test projects (Microsoft.NET.Test.Sdk) get dotnet test. The form edits project, target framework, launch profile, program arguments and environment, which override the profile's values. Debug builds the project, resolves the output assembly through MSBuild and launches it under netcoredbg, offering to install netcoredbg when missing. The source-text catalog test now ignores Rust character literals when collecting backend strings.

Part of #615
2026-11-01 19:34:31 +08:00
l0ng-ai b5dbd3c020 feat(cargo): dependency version inspections and updates (#684)
Cargo.toml had no way to tell that a dependency had a newer release.

Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent.

Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app.

Fixes #479
2026-11-01 19:30:28 +08:00
l0ng-ai 4034853f91 fix(windows): keep maximized windows within the work area (#644)
* fix(windows): keep maximized windows within the work area

* fix(windows): constrain final maximized outer placement

* Revert "fix(windows): constrain final maximized outer placement"

This reverts commit e481c900ca.
2026-11-01 19:21:20 +08:00
l0ng-ai 93328d7895 Store rebuildable Windows data in Local (#652)
* fix: store rebuildable Windows data in Local

* fix(windows): preserve relocated tool wrappers and local tool consumers
2026-11-01 19:18:37 +08:00
l0ng-ai b9d3ce7723 fix(java): align debug JVM encoding with Run (#674) 2026-11-01 19:16:25 +08:00
l0ng-ai be7e2ff710 fix(debugger): keep execution highlights synchronized (#681)
Inline debugger values looked up source identifiers in a plain object, so a name like toString on a nearby line resolved to an inherited Object.prototype member and crashed the editor's inline-values plugin. The execution-point subscription lived in that plugin, so after the crash the highlighted line stayed on the old position while the stack had moved on, and resume or stop did not clear it.

Locals are now kept in a prototype-free dictionary and looked up by own property, and the execution point is synced by its own plugin so an inline-value failure can't freeze it; hidden editors resync when remounted. Stepping, resume, run to cursor, reset frame and smart step clear the old execution point as soon as the request is sent, stale stack/locals responses from a previous stop are ignored, and another thread's continued event no longer clears the selected thread's execution point.

Fixes #668
2026-11-01 19:04:04 +08:00
l0ng-ai 210f9a78ac feat(run): project CMake profiles (#679)
CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories.

Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-<type>), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change.

Refs #655
2026-11-01 19:02:00 +08:00
212 changed files with 9401 additions and 526 deletions

No files matched your search

Generated
+5
View File
@@ -4086,6 +4086,7 @@ dependencies = [
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
@@ -4241,6 +4242,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
@@ -4250,6 +4252,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
@@ -4683,6 +4686,7 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -6207,6 +6211,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
+13
View File
@@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -814,6 +816,17 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
+248 -13
View File
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -285,6 +287,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
}
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
/// which Maven 4 (model 4.1.0) uses instead.
fn module_names(e: &Element) -> Vec<String> {
let mut names = texts(e, "modules", "module");
names.extend(texts(e, "subprojects", "subproject"));
names
}
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
@@ -308,7 +318,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: texts(project, "modules", "module"),
modules: module_names(project),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -329,7 +339,7 @@ fn read_raw(project: &Element) -> RawPom {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
modules: texts(p, "modules", "module"),
modules: module_names(p),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -448,13 +458,20 @@ struct Resolver {
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
{
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
return None;
}
Some(m2.join("repository"))
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
}
impl Resolver {
@@ -680,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// Goals of well-known plugins (their plugin descriptors are not read).
/// Goals of every plugin jar read so far, by the path of that jar.
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
/// is not in the local repository yet.
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
// between runs, and a test's empty repository must not answer for another's.
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
// every platform, Windows' separator included.
let mut dir = repo.to_path_buf();
dir.extend(group_id.split('.'));
dir.push(artifact_id);
dir.push(version);
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
let key = jar.to_string_lossy().into_owned();
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return cached.clone();
}
// A failed read is not remembered: the jar may be half written by Maven right now.
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
goals
}
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
if exact.is_file() {
return Some(exact);
}
let prefix = format!("{artifact_id}-");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
continue;
}
if name.ends_with("-javadoc.jar") {
continue;
}
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
}
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
let file = std::fs::File::open(jar).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
let root = xml::parse(&text)?;
let mut goals: Vec<String> =
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
goals.retain(|g| !g.is_empty());
goals.sort();
goals.dedup();
Some(goals)
}
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -832,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
/// still shows every goal it offers instead of only the bound one.
pub(crate) fn plugin(
group_id: &str,
artifact_id: &str,
version: Option<String>,
goals: &[String],
local_repo: Option<&Path>,
) -> MavenPlugin {
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
for g in descriptor_goals(repo, group_id, artifact_id, version) {
if !all.contains(&g) {
all.push(g);
}
}
}
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -999,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
})
.collect();
@@ -1111,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
parse_effective_pom_in(text, None)
}
/// The same, with the local repository the project actually builds against (a
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
/// rather than from the user's default one.
pub fn parse_effective_pom_in(
text: &str,
local_repo: Option<&Path>,
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1122,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.collect();
let deps = raw
.deps
@@ -1253,6 +1367,107 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
mod tests {
use super::*;
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
std::fs::create_dir_all(&dir).unwrap();
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
}
#[test]
fn a_plugin_lists_the_goals_its_jar_declares() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
write_plugin_jar(
repo,
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
"3.3.1",
&["checkstyle", "checkstyle-aggregate", "help", "check"],
);
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(repo),
);
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join("settings.xml");
std::fs::write(
&settings,
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
)
.unwrap();
let repo = local_repository_of(&settings).unwrap();
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
assert!(!repo.to_string_lossy().contains("${user.home}"));
// A settings file that names none leaves Maven's default alone.
let plain = tmp.path().join("plain.xml");
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
assert!(local_repository_of(&plain).is_none());
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
}
#[test]
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
let tmp = tempfile::tempdir().unwrap();
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
</plugin></plugins></build></project>";
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
// Without that repository the plugin is unknown: only what the project binds.
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
}
#[test]
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
std::fs::create_dir_all(&dir).unwrap();
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
// A sibling sources jar must not win.
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
assert_eq!(p.goals, ["run"]);
}
#[test]
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
let tmp = tempfile::tempdir().unwrap();
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(tmp.path()),
);
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
let known =
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -1276,6 +1491,26 @@ mod tests {
</profiles>
</project>"#;
#[test]
fn maven_4_subprojects_are_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<subprojects><subproject>core</subproject></subprojects>
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
</project>"#,
);
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
let model = load_with_repo(root, None);
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
+74 -4
View File
@@ -48,6 +48,11 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
/// Bumped whenever what the cache holds stops matching what the model would be
/// built as now (plugin goals read from a descriptor, say): an older cache is
/// dropped instead of shadowing the model.
#[serde(default)]
version: u32,
hash: String,
at_ms: f64,
error: Option<String>,
@@ -66,6 +71,7 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -121,6 +127,9 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
if cache.version != MAVEN_CACHE_VERSION {
return; // built by an older IDE: what it holds no longer matches the model
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
@@ -211,8 +220,31 @@ pub fn sync_maven(
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(root, &model)),
verbose: true,
..MavenCache::default()
};
// Where the build resolves its jars, and therefore where the plugin goals are read
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
// then the user's default repository (which `plugin()` resolves itself).
let local_repo: Option<PathBuf> =
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|| {
let mut args = options.maven_options.iter();
while let Some(arg) = args.next() {
let path = match arg.as_str() {
"-s" | "--settings" => args.next().map(String::as_str),
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
};
if let Some(path) = path {
return maven::local_repository_of(Path::new(path));
}
}
None
},
);
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -241,8 +273,9 @@ pub fn sync_maven(
});
}
})?;
let effective_projects =
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
let effective_projects = std::fs::read_to_string(&effective)
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
.unwrap_or_default();
let trees = maven::parse_dependency_tree(&out.text);
for (key, plugins, deps) in effective_projects {
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
@@ -332,6 +365,7 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -343,6 +377,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +403,41 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let mut model = maven::load_with_repo(&root, None);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -19,4 +19,4 @@ tokio-util.workspace = true
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
+6
View File
@@ -82,6 +82,12 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
+25
View File
@@ -0,0 +1,25 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
+59 -10
View File
@@ -215,13 +215,21 @@ fn fix_kind(
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: Option<&str>,
windows: bool,
) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
let added = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -446,12 +454,53 @@ mod tests {
#[test]
fn java_debug_launches_write_what_java_debug_decodes() {
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
}
fn filtered(chunks: &[&str]) -> String {
+133 -38
View File
@@ -245,10 +245,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
@@ -259,45 +256,53 @@ pub fn user_path() -> Option<OsString> {
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
let value = read_registry_string(key, &wide("Path"));
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
/// against the current environment. `None` when the value is missing or unreadable.
///
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
/// that grows when expanded is re-read at the size the failed call reports. And the
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
/// the first NUL, not just stripped of trailing ones.
#[cfg(windows)]
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
let mut kind = 0u32;
let mut len = 0u32;
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
let status =
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let buf = loop {
let mut buf = vec![0u16; len as usize / 2 + 1];
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
let status = unsafe {
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
};
if status == ERROR_SUCCESS {
break buf;
}
if status != ERROR_MORE_DATA || len == 0 {
return None;
}
};
let text = String::from_utf16_lossy(&buf);
let end = text.find('\0').unwrap_or(text.len());
Some(text[..end].to_owned())
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
@@ -334,6 +339,96 @@ mod tests {
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
+80
View File
@@ -207,6 +207,58 @@ impl ProxySettings {
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
/// System properties that make a JVM follow this configuration. A JVM reads none of
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
/// HTTP client) goes direct without these, and a repository its proxy answers in
/// milliseconds can then hang it instead.
pub fn jvm_args(&self) -> Vec<String> {
match self.mode {
// macOS and Windows JVMs read the system proxy with this one.
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
ProxyMode::None => Vec::new(),
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
ProxyMode::Manual => Vec::new(),
}
}
fn manual_jvm_args(&self) -> Vec<String> {
let (host, port) = (self.host.trim(), self.port);
match self.kind {
ProxyKind::Http => {
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
let mut args = vec![
format!("-Dhttp.proxyHost={host}"),
format!("-Dhttp.proxyPort={port}"),
format!("-Dhttps.proxyHost={host}"),
format!("-Dhttps.proxyPort={port}"),
];
let hosts = self.non_proxy_hosts();
if !hosts.is_empty() {
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
}
args
}
// SOCKS proxies every TCP connection, so there is one property pair.
ProxyKind::Socks => vec![
format!("-DsocksProxyHost={host}"),
format!("-DsocksProxyPort={port}"),
"-Djava.net.useSystemProxies=false".into(),
],
}
}
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
fn non_proxy_hosts(&self) -> Vec<String> {
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
for entry in self.no_proxy_entries() {
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
hosts.push(entry);
}
}
hosts
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
@@ -292,6 +344,34 @@ mod tests {
assert!(p.child_env().is_empty());
}
#[test]
fn jvm_args_carry_the_proxy_as_system_properties() {
// A JVM reads none of `child_env`: a Java language server needs these to not go
// direct while curl, git and Node follow the environment.
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
assert_eq!(
manual("proxy.corp", 3128).jvm_args(),
[
"-Dhttp.proxyHost=proxy.corp",
"-Dhttp.proxyPort=3128",
"-Dhttps.proxyHost=proxy.corp",
"-Dhttps.proxyPort=3128",
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
]
);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
let p = ProxySettings {
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
..manual("proxy.corp", 3128)
};
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
// A manual proxy without a host connects directly, and so does its JVM.
assert!(manual(" ", 80).jvm_args().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
+57 -1
View File
@@ -58,6 +58,43 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -154,6 +191,7 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -197,7 +235,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: false,
confirm_exit: true,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -765,6 +803,24 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
+3
View File
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -71,6 +73,7 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+10
View File
@@ -1389,6 +1389,16 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
+7
View File
@@ -51,4 +51,11 @@ uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+1
View File
@@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
+474
View File
@@ -0,0 +1,474 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+40 -12
View File
@@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
@@ -44,16 +54,19 @@ pub async fn format(
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
@@ -69,13 +82,13 @@ pub async fn format(
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
});
}
edits.map_err(|e| e.with_tool("prettier"))
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
@@ -104,4 +117,19 @@ mod tests {
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
+45 -5
View File
@@ -3,6 +3,7 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -33,7 +34,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -49,10 +53,14 @@ pub struct HostConfig {
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
@@ -76,8 +84,17 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
@@ -86,8 +103,11 @@ impl Host {
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, data_dir.clone()),
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
)),
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
processes: ProcessManager::new(),
@@ -96,6 +116,7 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
@@ -155,12 +176,21 @@ impl Host {
}
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -170,6 +200,8 @@ impl Host {
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
@@ -187,6 +219,12 @@ impl Host {
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
@@ -208,6 +246,8 @@ impl Host {
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.data_dir)
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
+21 -14
View File
@@ -133,22 +133,29 @@ pub async fn build_sync(
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& let Some(maven) = &model.maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
{
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436).
if let Some(bridge) = &project.kotlin_bridge {
bridge.configure_maven(offline, bridge_profiles);
}
let lsp = project.lsp.clone();
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
bridge.configure_maven(offline, bridge_profiles);
}
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
}
+19 -4
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
@@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
};
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
let encoding = extra.get("encoding").and_then(|v| v.as_str());
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
@@ -452,7 +458,7 @@ mod tests {
program: None,
args: vec![],
cwd: None,
env: Default::default(),
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
extra_json: Some(extra.to_string()),
}
}
@@ -468,7 +474,7 @@ mod tests {
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
assert_eq!(
vm_args(&fixed),
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
@@ -481,4 +487,13 @@ mod tests {
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
&format!("https://index.crates.io/{path}"),
Some(16 * 1024 * 1024),
cancel,
)?;
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
}
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
window: &str,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dependency in cargo::dependencies(&snap.text) {
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
continue;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
let update = cargo::update(&dependency.requirement, &versions);
out.push(DependencyVersions { dependency, versions, update });
}
Ok(out)
})
.await;
host.end_request(request);
result
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("dependency_versions", |c| async move {
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use std::sync::Arc;
struct Source;
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["1.10.0".into(), "0.2.1".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
let path = root.join("Cargo.toml");
std::fs::write(&path, text).unwrap();
let host = 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(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
for package in ["tokio", "serde"] {
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
}
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
assert!(all[1].update.is_none());
let offset = all[1].dependency.from;
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
assert_eq!(at_cursor.len(), 1);
assert_eq!(at_cursor[0].dependency.package, "serde");
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
host.cancel_request(RequestId(413));
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
let other = root.join("other.toml");
std::fs::write(&other, text).unwrap();
let info = project.documents.open(&other).unwrap();
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
}
+1
View File
@@ -30,6 +30,7 @@ impl Fixture {
let host = 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(move |_| {
+1 -1
View File
@@ -61,7 +61,7 @@ async fn resolve_launch(
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
+2
View File
@@ -8,6 +8,7 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -69,6 +70,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
analysis::register(d);
syntax::register(d);
spell::register(d);
dependencies::register(d);
history::register(d);
coverage::register(d);
build::register(d);
+5 -4
View File
@@ -50,10 +50,11 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
+20
View File
@@ -5,6 +5,23 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
|| config.extras.target.as_ref().is_some_and(|s| !s.is_empty())
{
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let project = host.project(window)?;
let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?;
if debug {
let root = host.project(window)?.root.clone();
let target = ide_run::dotnet::target_path(&root, &config).await?;
config.extras.params.insert("targetPath".into(), target);
}
Ok(config)
}
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await
});
d.add("run_application_build", |c| async move {
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
});
+48 -1
View File
@@ -3,6 +3,21 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
mod images;
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
pub async fn store_image(host: &Host, id: TerminalId, data: Vec<u8>, mime: String) -> IdeResult<String> {
if data.len() > MAX_IMAGE_BYTES {
return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)"));
}
if !host.terminals.contains(id) {
return Err(ide_core::IdeError::invalid("terminal session not found"));
}
let data_dir = host.data_dir.clone();
host.blocking(move || images::store(&data_dir, &data, &mime)).await
}
pub fn spawn(
host: &Host,
window: &str,
@@ -25,7 +40,8 @@ pub fn spawn(
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
let key = host.owner_window(window).to_owned();
let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
}
@@ -47,3 +63,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
host.terminals.is_busy(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use ide_core::{NullSink, error::ErrorKind};
use std::sync::Arc;
#[tokio::test]
async fn image_size_limit_is_checked_before_storage() {
let root = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let id = TerminalId::next();
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
assert_eq!(error.message, "terminal session not found");
assert!(!host.data_dir.join("terminal-images").exists());
}
}
+220
View File
@@ -0,0 +1,220 @@
use ide_core::{IdeError, IdeResult};
use std::path::Path;
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
use std::fs::File;
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::time::{Duration, SystemTime};
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const DIRECTORY: &str = "terminal-images";
fn extension(mime: &str) -> IdeResult<&str> {
match mime {
"image/png" => Ok("png"),
"image/jpeg" => Ok("jpg"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/bmp" => Ok("bmp"),
"image/svg+xml" => Ok("svg"),
"image/tiff" => Ok("tiff"),
"image/x-icon" => Ok("ico"),
_ => Err(IdeError::invalid("unsupported image type")),
}
}
fn open_directory(data_dir: &Path) -> io::Result<File> {
if !data_dir.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
// The configured data directory and its ancestors are the host's trust boundary.
let parent =
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
Err(e) => return Err(e.into()),
}
let dir: File = fs::openat(
&parent,
DIRECTORY,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into();
let meta = dir.metadata()?;
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal image directory is not private or owned by the current user",
));
}
Ok(dir)
}
fn owned_name(name: &str) -> bool {
let name = name.strip_prefix(".partial-").unwrap_or(name);
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
id.len() == 32
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
}
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
for entry in Dir::read_from(dir)? {
let entry = entry?;
let name = entry.file_name();
if !name.to_str().is_ok_and(owned_name) {
continue;
}
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
let fd = match fs::openat(
dir,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => fd,
Err(_) => continue,
};
let file = File::from(fd);
let meta = file.metadata()?;
if meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::unlinkat(dir, name, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let dir = open_directory(data_dir)?;
cleanup(&dir, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let temporary = format!(".partial-{name}");
let fd = fs::openat(
&dir,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(io::Error::from)?;
let mut file = File::from(fd);
if let Err(error) = write(&mut file, data) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(error.into());
}
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(io::Error::from(error).into());
}
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn stores_bytes_with_private_modes_and_unpredictable_names() {
let root = tempfile::tempdir().unwrap();
let first = store(root.path(), b"image bytes", "image/png").unwrap();
let second = store(root.path(), b"other", "image/jpeg").unwrap();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn expiry_only_removes_owned_regular_files_at_boundary() {
let root = tempfile::tempdir().unwrap();
let dir = open_directory(root.path()).unwrap();
let path = root.path().join(DIRECTORY);
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.png",
"image-00000000000000000000000000000003.png",
"unrelated.png",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000004.png");
symlink(path.join(names[0]), &link).unwrap();
cleanup(&dir, now).unwrap();
assert!(!path.join(names[0]).exists());
for name in &names[1..] {
assert!(path.join(name).exists());
}
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn rejects_symlink_and_insecure_directory() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join(DIRECTORY);
symlink(outside.path(), &target).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
fs::remove_file(&target).unwrap();
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
}
}
#[cfg(unix)]
pub(super) use unix::store;
#[cfg(not(unix))]
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
+4 -4
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.data_dir.clone();
let data = host.local_data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (host.global_settings.effective(), Default::default()),
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
/// Where Download JDK puts JDKs unless told otherwise.
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
@@ -130,7 +130,7 @@ pub async fn jdk_download(
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+12 -12
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
&self.local_data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
@@ -127,7 +127,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
@@ -214,7 +214,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
@@ -255,8 +255,8 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.data_dir.join("cache").join(&project_key).join("build"),
&self.data_dir,
self.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
@@ -385,7 +385,7 @@ impl Host {
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
@@ -404,9 +404,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.data_dir.join("cache").join(&project_key).join("build"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
@@ -509,11 +509,11 @@ impl Host {
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
settings,
@@ -526,9 +526,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.data_dir.join("cache").join(&project_key).join("build"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
+3
View File
@@ -231,6 +231,9 @@ impl Dispatcher {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_store_image", |c| async move {
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
+3 -3
View File
@@ -135,9 +135,7 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
self.debugger.stop_key(&window).await;
}
}
project.lsp.reconfigure().await;
@@ -222,6 +220,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_symbols"
| "mark_recent"
@@ -243,6 +242,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "remote_list"
| "remote_open"
| "remote_of"
+54
View File
@@ -0,0 +1,54 @@
//! Closing a project takes the background processes of its window with it, as IDEA does,
//! and leaves the other windows' processes alone.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
cwd: root.to_path_buf().into(),
// A fixed shell: the test must not depend on the login shell of this machine.
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
}
}
#[test]
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().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,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let project_shell =
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
assert_eq!(host.terminals.list().len(), 2);
host.close_project("main");
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
assert!(left.contains(&other_shell.id), "another window keeps its shell");
host.terminals.kill_key("other");
});
}
+39
View File
@@ -0,0 +1,39 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
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,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
+102
View File
@@ -9,6 +9,7 @@ fn host(base: &std::path::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)),
@@ -77,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_windows_stay_restricted_when_folder_trust_changes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
let host = host(&tmp.path().join("app"));
host.global_settings
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
.unwrap();
let light = host.open_light("main", &root.join("one.py")).unwrap();
assert!(!light.is_trusted());
assert!(light.require_trust().is_err());
host.set_project_trust(&root, true, false).await.unwrap();
assert!(!light.is_trusted());
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
assert!(!trusted_folder.is_trusted());
let again = host.open_light("main", &other.join("two.js")).unwrap();
assert!(Arc::ptr_eq(&light, &again));
assert!(!again.is_trusted());
host.set_project_trust(&root, true, true).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
}
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
let error = ops::lsp::lsp_format(
&host,
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("Restricted Mode"));
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(!project.light);
assert!(project.is_trusted());
assert!(project.require_trust().is_ok());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let python = bin.join("python");
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = root.join("analysis.ipynb");
std::fs::write(
&file,
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
)
.unwrap();
let host = host(&tmp.path().join("app"));
host.open_light("main", &file).unwrap();
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
let error = ops::notebook::execute(
&host,
"main",
notebook.id,
vec![notebook.cells[0].id.clone()],
None,
ide_core::RequestId(849),
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
+1
View File
@@ -20,6 +20,7 @@ fn host(base: &Path) -> Host {
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)),
+1
View File
@@ -10,6 +10,7 @@ fn host(base: &std::path::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)),
+1
View File
@@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
let 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(move |_| sink_events.clone() as SharedSink),
+3
View File
@@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() {
let 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(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
@@ -261,6 +262,7 @@ async fn local_deployment_runs_on_the_project_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
@@ -328,6 +330,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
+1
View File
@@ -6,6 +6,7 @@ fn host(base: &std::path::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)),
@@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+48
View File
@@ -0,0 +1,48 @@
#![cfg(unix)]
use ide_core::{NullSink, TerminalId};
use ide_host::{Host, HostConfig, ops};
use ide_terminal::TerminalOptions;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn storage_requires_a_registered_terminal() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: data_dir.clone(),
local_data_dir: data_dir.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
assert!(!data_dir.join("terminal-images").exists());
let terminal = host
.terminals
.spawn(
TerminalOptions {
cwd: root.path().into(),
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
"main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
.unwrap();
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
host.terminals.kill(terminal.id).unwrap();
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
}
+61
View File
@@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> 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)),
@@ -55,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"db_console_path",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
@@ -74,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_git_version",
"docker_compose_op",
"db_execute",
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
"deploy_download",
"deploy_delete_remote",
"deploy_sync",
"unknown_command",
] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
@@ -108,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
host.shutdown().await;
}
#[tokio::test]
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
use ide_core::remote_settings::{SshAuth, SshConfiguration};
use ide_ssh::{FsConnection, SshFiles, SshManager};
let tmp = tempfile::tempdir().unwrap();
let server_root = tmp.path().join("server");
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
tmp.path().join("secrets.json"),
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
));
ssh.set_configs(vec![SshConfiguration {
id: "selected-host".into(),
host: "127.0.0.1".into(),
port: Some(port),
user: "dev".into(),
auth: SshAuth::Password,
..Default::default()
}]);
ssh.set_secret("selected-host", Some("secret".into()), false);
assert!(ssh.session("selected-host").await.is_err());
ssh.trust_host("selected-host").await.unwrap();
let mut host = host(&tmp.path().join("app"), false);
let state = Arc::get_mut(&mut host).unwrap();
state.ssh = ssh;
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
assert!(host.project("welcome").is_err());
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
let entries = host.remote.list(&connection, "/projects").await.unwrap();
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
host.authorize_command("welcome", "deploy_test_connection").unwrap();
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
assert!(host.project("welcome").is_err());
host.shutdown().await;
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
@@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let 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)),
+1
View File
@@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = 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)),
+1
View File
@@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() {
let host = 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)),
+1
View File
@@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
let host = 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)),
+28 -1
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("bat", include_str!("../../../languages/bat.toml")),
("c", include_str!("../../../languages/c.toml")),
("clojure", include_str!("../../../languages/clojure.toml")),
@@ -15,6 +16,7 @@ const BUILTIN: &[(&str, &str)] = &[
("cpp", include_str!("../../../languages/cpp.toml")),
("csharp", include_str!("../../../languages/csharp.toml")),
("css", include_str!("../../../languages/css.toml")),
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
@@ -373,6 +375,7 @@ mod tests {
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
/// (the UI resolves `ui-mode` against these).
const UI_MODES: &[&str] = &[
"AsciiDoc",
"Batch",
"C",
"C++",
@@ -532,6 +535,12 @@ mod tests {
let reg = reg();
assert_eq!(detect(&reg, "src/main.rs"), "rust");
assert_eq!(detect(&reg, "a/b.TS"), "typescript");
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
assert_eq!(detect(&reg, path), "asciidoc");
}
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -567,7 +576,7 @@ mod tests {
};
// After the TypeScript fallback (an available server before it would hide
// it); before the HTML server (hover, rename and symbols take the first).
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -577,6 +586,24 @@ mod tests {
}
}
#[test]
fn oxlint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
}
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
+126
View File
@@ -0,0 +1,126 @@
//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks.
use crate::{LspManager, Server};
use ide_core::IdeEvent;
use std::path::Path;
pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.";
fn is_apple_clangd(path: &Path) -> bool {
if path == Path::new("/usr/bin/clangd") {
return true;
}
if !path.ends_with("usr/bin/clangd") {
return false;
}
path.starts_with("/Library/Developer/CommandLineTools")
|| path.ancestors().any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "Toolchains")
&& ancestor.parent().is_some_and(|developer| {
developer.ends_with("Contents/Developer")
&& developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| {
let name = name.to_string_lossy();
name.starts_with("Xcode") && name.ends_with(".app")
})
})
})
}
fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool {
server_root.join(".clang-tidy").is_file()
|| file
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|dir| dir.starts_with(project_root))
.any(|dir| dir.join(".clang-tidy").is_file())
}
impl LspManager {
pub(crate) async fn check_clang_tidy(&self, server: &Server) {
if server.name != "clangd"
|| !cfg!(target_os = "macos")
|| self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into()))
{
return;
}
let binary = server.resolved_binary.read().clone();
let Some(binary) = binary else { return };
let files: Vec<_> = self
.docs
.lock()
.values()
.filter(|doc| {
matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp")
&& doc.servers.iter().any(|attached| attached.server.id == server.id)
})
.map(|doc| doc.path.clone())
.collect();
let root = server.root.clone();
let Some(me) = self.me.upgrade() else { return };
let _ = tokio::task::spawn_blocking(move || {
if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) {
return;
}
if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire)
&& files.iter().any(|file| has_config(&root, &me.project_root_for(file), file))
&& me.first_message("clangd", MESSAGE)
{
me.sink.emit(IdeEvent::LanguageServerWarning {
name: "clangd".into(),
message: MESSAGE.into(),
install_tool: "clangd".into(),
});
}
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_paths() {
for path in [
"/usr/bin/clangd",
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Library/Developer/CommandLineTools/usr/bin/clangd",
] {
assert!(is_apple_clangd(Path::new(path)), "{path}");
}
for path in [
"/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd",
"/opt/homebrew/opt/llvm/bin/clangd",
"/usr/local/bin/clangd",
"/usr/bin/clangd-custom",
"/Library/Developer/CommandLineTools-custom/usr/bin/clangd",
"/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd",
] {
assert!(!is_apple_clangd(Path::new(path)), "{path}");
}
}
#[test]
fn config_in_server_root_or_file_ancestors_stays_inside_project() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project");
let server = project.join("build");
let source = project.join("src/nested");
let file = source.join("main.cpp");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&server).unwrap();
std::fs::write(dir.path().join(".clang-tidy"), "").unwrap();
assert!(!has_config(&server, &project, &file));
for root in [&server, &source, &project.join("src"), &project] {
let config = root.join(".clang-tidy");
std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap();
assert!(has_config(&server, &project, &file), "{}", root.display());
std::fs::remove_file(config).unwrap();
}
std::fs::create_dir(source.join(".clang-tidy")).unwrap();
assert!(!has_config(&server, &project, &file));
}
}
+26 -1
View File
@@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static {
self.launch(config, root, handler, on_stderr)
}
/// Starts a server and reports its executable when the launcher runs a local process.
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None))
}
/// Whether `config` could start without a `Missing` error.
fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool {
true
@@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer)
}
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b));
@@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher {
handler,
on_stderr,
cancel,
);
)
.map(|peer| (peer, None));
}
let binary = binary.ok_or(LaunchError::Missing)?;
Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr)
.map(|peer| (peer, Some(binary.clone())))
.map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))
}
+13 -2
View File
@@ -8,6 +8,7 @@
mod angular;
mod caps;
mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
@@ -472,13 +473,16 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok(peer) => peer,
Ok((peer, binary)) => {
*server.resolved_binary.write() = binary;
peer
}
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
@@ -587,6 +591,7 @@ impl LspManager {
}
}
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
self.spawn_supervisor(server, generation, peer);
@@ -810,6 +815,12 @@ impl LspManager {
}
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
}
}
+2
View File
@@ -79,6 +79,7 @@ pub struct Server {
pub config: LanguageServerConfig,
/// `config` with the settings hook applied, as the current instance was launched.
effective: Mutex<Option<LanguageServerConfig>>,
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
@@ -114,6 +115,7 @@ impl Server {
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
start_cancel: Mutex::new(CancellationToken::new()),
+78
View File
@@ -21,6 +21,7 @@ use std::time::Duration;
struct MockState {
caps: Mutex<Value>,
missing: std::sync::atomic::AtomicBool,
binary: Mutex<Option<PathBuf>>,
launches: AtomicUsize,
notifications: Mutex<Vec<(String, Value)>>,
requests: Mutex<Vec<(String, Value)>>,
@@ -148,6 +149,18 @@ impl MockLauncher {
}
impl Launcher for MockLauncher {
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &ide_core::CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel)
.map(|peer| (peer, self.state(&config.name).binary.lock().clone()))
}
fn launch(
&self,
config: &LanguageServerConfig,
@@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() {
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() {
for language in ["c", "cpp", "objective-c", "objective-cpp"] {
let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("main.src", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("clangd", ServerStatus::Ready).await;
h.open("other.src", "int *p = 0;");
h.mgr.shutdown_all().await;
let events = h.events.lock();
let warnings: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)),
_ => None,
})
.collect();
assert_eq!(warnings.len(), 1, "{language}");
assert_eq!(warnings[0].0, "clangd");
assert!(warnings[0].1.contains(".clang-tidy"));
assert_eq!(warnings[0].2, "clangd");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_warning_requires_apple_binary_and_a_config() {
for (binary, config) in [
("/usr/bin/clangd", false),
("/opt/homebrew/opt/llvm/bin/clangd", true),
("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true),
] {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary));
if config {
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
}
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
h.mgr.shutdown_all().await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })));
std::fs::create_dir(h.root.join("nested")).unwrap();
std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("nested/other.cpp", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
+11 -1
View File
@@ -4,6 +4,7 @@
//! Detection never fails: unreadable or malformed files simply contribute nothing.
pub(crate) mod cmake;
mod dotnet;
use crate::RunConfig;
use serde_json::Value as Json;
@@ -16,6 +17,14 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
}
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
detect_with_profiles(root, excluded_poms, None)
}
pub(crate) fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
@@ -24,7 +33,8 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, &mut out);
cmake::detect(root, profiles, &mut out);
dotnet::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+172 -5
View File
@@ -2,6 +2,7 @@
use super::{ConfigExt, config};
use crate::{BeforeLaunchTask, RunConfig};
use ide_core::settings::CMakeProfile;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
@@ -19,10 +20,16 @@ fn before(config: &mut RunConfig, id: &str) {
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
}
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
if !root.join("CMakeLists.txt").is_file() {
return;
}
if let Some(profiles) = profiles {
for (index, profile) in profiles.iter().enumerate() {
detect_profile(root, profile, index, profiles.len() > 1, out);
}
return;
}
let mut builds = Vec::new();
if let Ok(entries) = fs::read_dir(root) {
builds.extend(entries.flatten().filter_map(|e| {
@@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, out);
targets(root, &build, &dir, &prefix, None, out);
}
}
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
let dir = if profile.build_dir.trim().is_empty() {
format!("cmake-build-{}", profile.build_type.to_lowercase())
} else {
profile.build_dir.clone()
};
let build = root.join(&dir);
let prefix = format!("cmake:profile:{index}");
let configure_id = format!("{prefix}:configure");
let build_id = format!("{prefix}:build");
let start = out.len();
let mut configure =
config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]);
if !profile.generator.trim().is_empty() {
configure.args.extend(["-G".into(), profile.generator.clone()]);
}
configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type));
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
configure.args.extend_from_slice(&profile.cmake_args);
configure.extras.params.insert("buildDir".into(), dir.clone());
out.push(configure);
let mut all = config(
&build_id,
format!("CMake Build ({dir})"),
"cmake",
"cmake",
&["--build", &dir, "--config", &profile.build_type],
);
all.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut all, &configure_id);
}
out.push(all);
let mut test = config(
format!("{prefix}:test"),
format!("CTest ({dir})"),
"cmake",
"ctest",
&["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"],
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, Some(profile), out);
for config in &mut out[start..] {
config.env = profile.env.clone();
if multiple {
config.name = format!("{} [{}]", config.name, profile.name);
}
}
}
fn targets(
root: &Path,
build: &Path,
dir: &str,
prefix: &str,
profile: Option<&CMakeProfile>,
out: &mut Vec<RunConfig>,
) {
let reply = build.join(".cmake/api/v1/reply");
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
entries
@@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
for configuration in configurations {
let name = configuration["name"].as_str().unwrap_or("");
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
continue;
}
let Some(targets) = configuration["targets"].as_array() else { continue };
for target in targets {
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
@@ -130,8 +198,17 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
if !name.is_empty() {
args.extend(["--config", name]);
}
let build_config =
let mut build_config =
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
if let Some(profile) = profile {
if name.is_empty() {
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
}
build_config.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut build_config, &format!("{prefix}:configure"));
}
}
out.push(build_config);
let binary = build.join(artifact);
let mut run =
@@ -165,9 +242,99 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
&& config.args.first().is_some_and(|a| a == "-S")
&& let Some(dir) = config.extras.params.get("buildDir")
{
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
let build = cwd.join(dir);
let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str());
if let Some(generator) = generator
&& let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt"))
&& let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL="))
&& cached != generator
{
fs::remove_file(build.join("CMakeCache.txt"))?;
let files = build.join("CMakeFiles");
if files.is_dir() {
fs::remove_dir_all(files)?;
}
}
let query = build.join(".cmake/api/v1/query/client-openidea");
fs::create_dir_all(&query)?;
fs::write(query.join("codemodel-v2"), "")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profiles_select_file_api_configuration_and_apply_target_options() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let reply = root.path().join("output/.cmake/api/v1/reply");
fs::create_dir_all(&reply).unwrap();
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
write(
"index-1.json",
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
);
write(
"model.json",
serde_json::json!({"paths":{"source":root.path()},"configurations":[
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
]}),
);
for name in ["debug", "release"] {
write(
&format!("{name}.json"),
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
);
}
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
build_dir: "output".into(),
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
env: [("FOO".into(), "bar".into())].into(),
..Default::default()
};
let mut configs = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
let run = configs.iter().find(|c| c.kind == "native").unwrap();
assert!(run.command.ends_with("release/app"));
assert_eq!(run.env, profile.env);
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
assert_eq!(
build.args,
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
);
assert_eq!(build.env, profile.env);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
let mut configured = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
for id in ["cmake:profile:0:build", build.id.as_str()] {
let build = configured.iter().find(|c| c.id == id).unwrap();
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
}
}
#[test]
fn generator_change_clears_only_cmake_state_at_launch() {
let root = tempfile::tempdir().unwrap();
let build = root.path().join("cmake-build-debug");
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
fs::write(build.join("keep.txt"), "artifact").unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let mut configs = Vec::new();
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
assert!(build.join("CMakeCache.txt").exists());
prepare(&configs[0], root.path()).unwrap();
assert!(!build.join("CMakeCache.txt").exists());
assert!(!build.join("CMakeFiles").exists());
assert!(build.join("keep.txt").exists());
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Runnable .NET projects and launch profiles, without a solution model.
use crate::{
RunConfig,
dotnet::{parse_project, profiles, solution_projects},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
let mut dirs = vec![root.to_path_buf()];
let mut projects = BTreeSet::new();
while let Some(dir) = dirs.pop() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
if !name.starts_with('.')
&& !matches!(
name.as_str(),
"bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages"
)
{
dirs.push(path);
}
} else if entry.file_type().is_ok_and(|t| t.is_file()) {
match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() {
"csproj" => {
projects.insert(path);
}
"sln" | "slnx" => {
if let Ok(src) = fs::read_to_string(&path) {
for project in solution_projects(
&src,
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")),
) {
projects.insert(dir.join(project));
}
}
}
_ => {}
}
}
}
}
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into());
let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect();
for path in projects {
let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue };
let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let name = path.file_stem().unwrap_or_default().to_string_lossy();
let profiles = profiles(&path);
let mut params = BTreeMap::from([
("project".into(), rel.clone()),
("framework".into(), project.frameworks.first().cloned().unwrap_or_default()),
("frameworks".into(), project.frameworks.join(";")),
("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()),
("launchProfile".into(), String::new()),
("programArgs".into(), String::new()),
]);
if project.executable && !project.test {
out.push(detected(&rel, &name, "", params.clone(), false));
for profile in profiles.keys() {
params.insert("launchProfile".into(), profile.clone());
out.push(detected(&rel, &name, profile, params.clone(), false));
}
}
if project.test {
params.insert("launchProfile".into(), String::new());
out.push(detected(&rel, &name, "", params, true));
}
}
}
fn detected(project: &str, name: &str, profile: &str, params: BTreeMap<String, String>, test: bool) -> RunConfig {
let op = if test { "test" } else { "run" };
let mut config = super::config(
format!("dotnet:{project}:{op}:{profile}"),
if test {
format!("dotnet test ({name})")
} else if profile.is_empty() {
format!("{name} (.NET Project)")
} else {
format!("{name}: {profile}")
},
if test { "dotnet-test" } else { "dotnet" },
"dotnet",
&[],
);
config.args = command_args(&params, test);
config.is_test = test;
config.debug_adapter = (!test).then(|| "netcoredbg".into());
config.extras.params = params;
if !test {
config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() });
}
config
}
fn command_args(params: &BTreeMap<String, String>, test: bool) -> Vec<String> {
let mut args = if test {
vec!["test".into(), params.get("project").cloned().unwrap_or_default()]
} else {
vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()]
};
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.extend(["--framework".into(), framework.clone()]);
}
if !test {
if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) {
args.extend(["--launch-profile".into(), profile.clone()]);
} else {
args.push("--no-launch-profile".into());
}
}
args
}
+257
View File
@@ -0,0 +1,257 @@
//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs.
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use quick_xml::{Reader, XmlVersion, events::Event};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, PartialEq)]
pub struct Project {
pub executable: bool,
pub frameworks: Vec<String>,
pub test: bool,
}
#[derive(Default)]
struct Element {
name: String,
attrs: BTreeMap<String, String>,
text: String,
conditional: bool,
}
fn elements(xml: &str) -> Option<Vec<Element>> {
let mut reader = Reader::from_str(xml);
let mut out: Vec<Element> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
loop {
let event = reader.read_event().ok()?;
let empty = matches!(&event, Event::Empty(_));
match event {
Event::Start(tag) | Event::Empty(tag) => {
let attrs: BTreeMap<_, _> = tag
.attributes()
.map(|a| {
let a = a.ok()?;
Some((
a.key.as_ref().to_owned(),
a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(),
))
})
.collect::<Option<_>>()?;
let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional);
out.push(Element {
name: tag.local_name().as_ref().to_owned(),
attrs,
conditional,
..Default::default()
});
// Empty elements have no corresponding End event.
if !empty {
stack.push(out.len() - 1);
}
}
Event::End(_) => {
stack.pop()?;
}
Event::Text(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0));
}
}
Event::CData(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(text.as_ref());
}
}
Event::GeneralRef(reference) => {
let name = reference.as_ref();
let escaped = format!("&{name};");
if let Some(i) = stack.last() {
out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?);
}
}
Event::Eof => return stack.is_empty().then_some(out),
_ => {}
}
}
}
pub fn parse_project(xml: &str) -> Option<Project> {
let tags = elements(xml)?;
if tags.first()?.name != "Project" {
return None;
}
let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim());
let web = tags.iter().any(|t| {
t.attrs
.get("Sdk")
.or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten())
.is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web")))
});
let executable = match property("OutputType") {
Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"),
None => web,
};
let frameworks = property("TargetFramework")
.filter(|v| !v.is_empty())
.or_else(|| property("TargetFrameworks"))
.unwrap_or_default()
.split(';')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect();
let test = tags.iter().any(|t| {
t.name == "PackageReference"
&& t.attrs
.get("Include")
.or_else(|| t.attrs.get("Update"))
.is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk"))
});
Some(Project { executable, frameworks, test })
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", default)]
pub struct Profile {
pub command_name: String,
pub command_line_args: String,
pub working_directory: String,
pub application_url: String,
pub environment_variables: BTreeMap<String, String>,
}
pub fn parse_profiles(json: &str) -> BTreeMap<String, Profile> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(&ide_core::jsonc::strip(json)) else {
return BTreeMap::new();
};
json["profiles"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(name, value)| {
let profile: Profile = serde_json::from_value(value.clone()).ok()?;
(profile.command_name == "Project").then(|| (name.clone(), profile))
})
.collect()
}
pub fn solution_projects(src: &str, xml: bool) -> Vec<PathBuf> {
let paths: Vec<String> = if xml {
elements(src)
.unwrap_or_default()
.into_iter()
.filter(|e| e.name == "Project")
.filter_map(|mut e| e.attrs.remove("Path"))
.collect()
} else {
src.lines()
.filter_map(|line| {
let line = line.trim();
if !line.starts_with("Project(") {
return None;
}
Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned())
})
.collect()
};
paths
.into_iter()
.filter(|p| p.to_ascii_lowercase().ends_with(".csproj"))
.map(|p| PathBuf::from(p.replace('\\', "/")))
.collect()
}
pub(crate) fn profiles(project: &Path) -> BTreeMap<String, Profile> {
project
.parent()
.and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok())
.map(|s| parse_profiles(&s))
.unwrap_or_default()
}
/// Resolves the selected profile afresh so changing project/profile never reuses stale values.
pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult<RunConfig> {
let params = &config.extras.params;
let project = params
.get("project")
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let project = dunce::canonicalize(root.join(project))?;
let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?;
let profile_name = params.get("launchProfile").cloned().unwrap_or_default();
let profile = if profile_name.is_empty() {
Profile::default()
} else {
profiles(&project).remove(&profile_name).ok_or_else(|| {
IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile"))
})?
};
let mut env = profile.environment_variables;
if !profile.application_url.is_empty() {
env.insert("ASPNETCORE_URLS".into(), profile.application_url);
}
if !profile_name.is_empty() {
env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name);
}
env.extend(config.env);
config.env = env;
config.cwd = Some(
config
.cwd
.map(|p| root.join(&*p))
.unwrap_or_else(|| {
if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) }
})
.into(),
);
config.extras.params.insert("project".into(), project.to_string_lossy().into_owned());
config.extras.params.insert("profileArgs".into(), profile.command_line_args);
Ok(config)
}
/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties.
pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult<String> {
let params = &config.extras.params;
let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let mut args = vec![
"msbuild".to_owned(),
root.join(project).to_string_lossy().into_owned(),
"-nologo".into(),
"-getProperty:TargetPath".into(),
"-p:Configuration=Debug".into(),
];
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.push(format!("-p:TargetFramework={framework}"));
}
let mut command = std::process::Command::new(&config.command);
ide_core::process::host_env(&mut command);
command.args(args).current_dir(root);
if !config.extras.include_system_env {
command.env_clear();
} else {
command.envs(ide_core::process::shell_env());
}
command.envs(crate::resolved_env(root, config)?);
let mut command = tokio::process::Command::from(command);
command.kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??;
if !output.status.success() {
return Err(IdeError::invalid(format!(
".NET output resolution failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)));
}
let target = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if target.is_empty() || !Path::new(&target).is_file() {
return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging"));
}
Ok(target)
}
+10
View File
@@ -2,6 +2,7 @@
mod config;
mod detect;
pub mod dotnet;
pub mod envfile;
pub mod go;
pub mod gradle_jvm;
@@ -201,6 +202,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
detect::detect_excluding(root, excluded_poms)
}
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
pub fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
detect::detect_with_profiles(root, excluded_poms, profiles)
}
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
config::load(root)
+69
View File
@@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() {
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
}
#[test]
fn cmake_profiles_control_commands_names_and_environment() {
use ide_core::settings::CMakeProfile;
let dir = project(&[("CMakeLists.txt", "project(app)")]);
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
generator: generator.into(),
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
build_dir: "output dir".into(),
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
env: [("MY_ENV".into(), "yes".into())].into(),
};
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
let configure = find(&configs, "cmake:profile:0:configure");
let mut expected = vec!["-S", ".", "-B", "output dir"];
if !generator.is_empty() {
expected.extend(["-G", generator]);
}
expected.extend([
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DFEATURE=ON",
"-DMESSAGE=two words",
"-DQUOTE=\"hello world\"",
"",
]);
assert_eq!(configure.args, expected);
assert_eq!(configure.name, "CMake Configure (output dir)");
let build = find(&configs, "cmake:profile:0:build");
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
assert_eq!(build.extras.before_launch[0].target, configure.id);
assert!(configs.iter().all(|c| c.env == profile.env));
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
}
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
}
#[test]
fn cmake_profile_build_configures_only_without_a_cache() {
use ide_core::settings::CMakeProfile;
let root = project(&[("CMakeLists.txt", "project(app)")]);
for dir in ["", "custom output"] {
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
for cached in [false, true] {
if cached {
fs::create_dir_all(&build_dir).unwrap();
// Generator changes are handled by an explicit Configure, not every Build.
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
}
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
let build = find(&configs, "cmake:profile:0:build");
if cached {
assert!(build.extras.before_launch.is_empty());
} else {
assert_eq!(build.extras.before_launch.len(), 1);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
}
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
}
}
}
+138
View File
@@ -0,0 +1,138 @@
use ide_run::{
detect,
dotnet::{parse_profiles, parse_project, resolve, solution_projects},
};
use std::fs;
use std::path::PathBuf;
const CONSOLE: &str = r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net10.0; net9.0</TargetFrameworks></PropertyGroup></Project>"#;
const PROFILES: &str = r#"{
// Only Project profiles can be run with dotnet.
"profiles": {
"http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}},
"https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}},
"IIS Express": {"commandName":"IISExpress"},
"external": {"commandName":"Executable"},
"broken": {"commandName":"Project", "environmentVariables":7}
}
}"#;
fn write(root: &std::path::Path, file: &str, src: &str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, src).unwrap();
}
#[test]
fn project_properties_and_sdk_defaults() {
let p = parse_project(CONSOLE).unwrap();
assert!(p.executable && !p.test);
assert_eq!(p.frameworks, ["net10.0", "net9.0"]);
for (sdk, output, executable) in [
("Microsoft.NET.Sdk", "", false),
("Microsoft.NET.Sdk", "<OutputType>WinExe</OutputType>", true),
("Microsoft.NET.Sdk.Web", "", true),
("Microsoft.NET.Sdk.Web", "<OutputType>Library</OutputType>", false),
] {
let xml = format!(
r#"<Project Sdk="{sdk}"><PropertyGroup>{output}<TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"#
);
let p = parse_project(&xml).unwrap();
assert_eq!(p.executable, executable, "{xml}");
assert_eq!(p.frameworks, ["net10.0"]);
}
let p = parse_project(r#"<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><Sdk Name="Microsoft.NET.Sdk.Web"/><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><PropertyGroup Condition="'$(Configuration)'=='Release'"><OutputType>Library</OutputType></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0" /></ItemGroup></Project>"#).unwrap();
assert!(p.executable && p.test);
assert!(parse_project("<Project><PropertyGroup>").is_none());
assert!(parse_project("garbage").is_none());
assert!(
parse_project(
r#"<Project><ItemGroup><PackageReference Update="microsoft.net.test.sdk" /></ItemGroup></Project>"#
)
.unwrap()
.test
);
assert!(
!parse_project(r#"<Project><!-- <PackageReference Include="Microsoft.NET.Test.Sdk"/> --></Project>"#)
.unwrap()
.test
);
}
#[test]
fn profiles_filter_and_preserve_launch_properties() {
let p = parse_profiles(PROFILES);
assert_eq!(p.keys().map(String::as_str).collect::<Vec<_>>(), ["http", "https"]);
assert_eq!(p["http"].application_url, "http://localhost:5165");
assert_eq!(p["http"].environment_variables["DEMO"], "profile");
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
assert_eq!(p["http"].working_directory, "work");
assert!(parse_profiles("invalid").is_empty());
}
#[test]
fn solution_paths_support_windows_separators_folders_and_entities() {
let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\"";
assert_eq!(
solution_projects(sln, false),
[PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")]
);
let slnx = r#"<Solution><Folder Name="/src/"><Project Path="src\A&amp;B\App.csproj" /></Folder><Project Path="other.vbproj" /></Solution>"#;
assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]);
assert!(solution_projects("<Solution><Project", true).is_empty());
}
#[test]
fn detection_deduplicates_projects_and_generates_commands() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
write(root, "src/App/App.csproj", CONSOLE);
write(root, "src/App/Properties/launchSettings.json", PROFILES);
write(
root,
"demo.slnx",
r#"<Solution><Project Path="src/App/App.csproj"/><Project Path="../external/Missing.csproj"/></Solution>"#,
);
write(
root,
"src/Test/Test.csproj",
r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk"/></ItemGroup></Project>"#,
);
write(root, "bin/Ignored.csproj", CONSOLE);
write(root, "obj/Ignored.csproj", CONSOLE);
write(root, "node_modules/Ignored.csproj", CONSOLE);
write(root, "Lib.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\" />");
let configs = detect(root);
assert_eq!(configs.len(), 4);
let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap();
assert_eq!(
app.args,
["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"]
);
assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg"));
assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild");
let test = configs.iter().find(|c| c.is_test).unwrap();
assert_eq!(test.kind, "dotnet-test");
assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]);
assert!(test.debug_adapter.is_none());
let mut edited = app.clone();
edited.env.insert("DEMO".into(), "override".into());
let resolved = resolve(root, edited.clone()).unwrap();
assert_eq!(resolved.env["DEMO"], "override");
assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165");
assert!(resolved.cwd.unwrap().ends_with("src/App/work"));
edited.extras.params.insert("launchProfile".into(), "https".into());
let resolved = resolve(root, edited).unwrap();
assert!(!resolved.env.contains_key("ASPNETCORE_URLS"));
assert!(resolved.cwd.unwrap().ends_with("src/App"));
}
#[test]
fn solution_references_can_reach_projects_outside_the_open_directory() {
let temp = tempfile::tempdir().unwrap();
write(temp.path(), "external/App.csproj", CONSOLE);
write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\"");
let configs = detect(&temp.path().join("open"));
assert_eq!(configs.len(), 1);
assert!(configs[0].args[2].ends_with("external/App.csproj"));
}
+17 -5
View File
@@ -78,7 +78,7 @@ fn jdk_setting(root: &Path, value: &str) -> Option<Result<crate::java::Jdk, Path
/// The project JDK (`languages.java.jdk`), else the JDK the project pins for mise / asdf.
pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
match jdk_setting(root, &s.languages.java.jdk) {
Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())).ok(),
Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {}", not_a_jdk(p))).ok(),
None => pinned::pinned_install(root, Tool::Java).and_then(|(dir, _)| pinned::jdk_in_install(&dir)),
}
}
@@ -88,11 +88,23 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result<Option<crate::java::Jdk>, String> {
match jdk_setting(root, &s.languages.java.jdtls_jdk) {
None => Ok(None),
Some(Ok(jdk)) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA)))
}
Some(Ok(jdk)) => Ok(Some(jdk)),
Some(Err(p)) => Err(format!(
"The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).",
p.display()
)),
Some(Err(p)) => {
Err(format!("The language server JDK {} (Settings › Languages & Frameworks › Java).", not_a_jdk(&p)))
}
}
}
/// Why the configured JDK path `p` is not used: gone (e.g. a version manager removed
/// it on upgrade) or not a JDK.
fn not_a_jdk(p: &Path) -> String {
if p.exists() {
format!("{} is not a JDK home", p.display())
} else {
format!("{} does not exist; choose another JDK or clear the setting to detect one", p.display())
}
}
+213 -33
View File
@@ -16,6 +16,13 @@ pub struct Jdk {
pub home: PathBuf,
}
pub(crate) fn server_jdk_incomplete_message(jdk: &Jdk, min_java: u32) -> String {
format!(
"The language server JDK ({}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {min_java} or newer. Download JDK {min_java}, choose a full JDK in {SERVER_JDK_SETTING}, or set it to Auto.",
jdk.home.display()
)
}
/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file.
pub fn parse_release(release: &str) -> Option<u32> {
let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?;
@@ -199,7 +206,7 @@ pub const JDTLS_MIN_JAVA: u32 = 21;
/// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins).
pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
jdks.iter().find(|j| j.major >= min_java)
jdks.iter().find(|j| j.major >= min_java && ide_core::java_runtime::supports_java_bundles(&j.home))
}
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
@@ -313,8 +320,36 @@ fn text_key(text: &str) -> String {
format!("{h:016x}")
}
/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it
/// carries: a value of theirs wins, and a second call adds nothing.
fn set_jvm_property(args: &mut Vec<String>, arg: &str) {
let key = arg.split('=').next().unwrap_or(arg);
let taken = args.iter().enumerate().any(|(i, a)| {
let value =
a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str()));
value.is_some_and(|a| a.split('=').next() == Some(key))
});
if !taken {
args.push(format!("--jvm-arg={arg}"));
}
}
fn add_jdtls_proxy_args(args: &mut Vec<String>, proxy: &ide_core::proxy_settings::ProxySettings) {
for arg in proxy.jvm_args() {
set_jvm_property(args, &arg);
}
}
const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot";
/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools
/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is
/// not a valid ZIP file", the project using it gets a build path error, and every project
/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven
/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told
/// to behave the same.
const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true";
/// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data
/// directory, one per server root, and no Eclipse metadata is written into
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
@@ -367,6 +402,14 @@ pub fn configure_jdtls(
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
}
crate::jdtls::add_default_vm_args(&mut cfg.args);
// jdtls resolves Maven dependencies through its own HTTP client, and a JVM reads none
// of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system
// properties. One the user set in their own JVM arguments wins.
add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy());
// ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25
// refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21
// accepts the same file.
set_jvm_property(&mut cfg.args, ZIP64_LENIENCY);
let managed = language_server_runtimes_dir(data_dir);
let managed = dunce::canonicalize(&managed).unwrap_or(managed);
let project_jdks: Vec<_> = project_jdk
@@ -385,6 +428,9 @@ pub fn configure_jdtls(
if !cfg.env.contains_key("JAVA_HOME") {
let runtime = match server_jdk {
Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)),
Some(jdk) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(server_jdk_incomplete_message(jdk, min_java))
}
Some(jdk) => Ok(jdk),
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
};
@@ -394,6 +440,10 @@ pub fn configure_jdtls(
}
Err(e) => cfg.launch_error = Some(e),
}
} else if let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
&& !ide_core::java_runtime::supports_java_bundles(&jdk.home)
{
cfg.launch_error = Some(server_jdk_incomplete_message(&jdk, min_java));
}
let mut runtimes = runtimes(&project_jdks);
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
@@ -537,6 +587,19 @@ fn parse_java_version(v: &str) -> Option<u32> {
mod tests {
use super::*;
fn test_jdk(root: &Path, major: u32, name: &str) -> Jdk {
let home = root.join(name.trim_start_matches('/'));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(
home.join("release"),
format!("JAVA_VERSION=\"{major}\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n"),
)
.unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
Jdk { major, home }
}
#[test]
fn unmanaged_source_models_have_separate_jdtls_workspaces() {
let data = tempfile::tempdir().unwrap();
@@ -563,8 +626,13 @@ mod tests {
let project_root = data.path().join("tools/jdks");
let project_home = project_root.join("temurin-21");
std::fs::create_dir_all(project_home.join("bin")).unwrap();
std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\n").unwrap();
std::fs::write(
project_home.join("release"),
"JAVA_VERSION=\"21.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(java_executable(&project_home), "").unwrap();
std::fs::write(project_home.join("bin").join(crate::exe("javac")), "").unwrap();
let project = jdk_at(&project_home).unwrap();
let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::<Vec<_>>();
let installed = discover();
@@ -572,8 +640,13 @@ mod tests {
assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none());
let home = language_server_runtimes_dir(data.path()).join("temurin-25");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
let candidates = runtime_candidates(&installed, data.path());
assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25);
assert_eq!(jdtls_runtime(&candidates, 21), Some(&project));
@@ -592,11 +665,47 @@ mod tests {
);
}
#[test]
fn jdtls_rejects_jre_for_auto_explicit_and_managed_runtimes() {
let tmp = tempfile::tempdir().unwrap();
let jre = test_jdk(tmp.path(), 25, "jre");
std::fs::write(jre.home.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base java.compiler\"\n").unwrap();
let jdk = test_jdk(tmp.path(), 25, "jdk");
let installed = [jre.clone(), jdk.clone()];
assert_eq!(jdtls_runtime(&installed, 25), Some(&jdk));
assert!(jdtls_runtime(std::slice::from_ref(&jre), 25).is_none());
let managed = language_server_runtimes_dir(tmp.path()).join("temurin-25");
std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
std::fs::rename(&jre.home, &managed).unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_none());
let jre = jdk_at(&managed).unwrap();
for env_override in [false, true] {
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
if env_override {
cfg.env.insert("JAVA_HOME".into(), jre.home.to_string_lossy().into_owned());
}
configure_jdtls(&mut cfg, tmp.path(), tmp.path(), &installed[1..], None, Some(&jre), 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(
err.contains("full JDK with jdk.compiler, jdk.jdi and jdk.unsupported")
&& err.contains("Download JDK 25")
&& err.contains(SERVER_JDK_SETTING),
"{err}"
);
}
std::fs::write(
managed.join("release"),
"JAVA_VERSION=\"25\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_some());
}
#[test]
fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() {
let installed =
[Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }];
let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") };
let fixtures = tempfile::tempdir().unwrap();
let installed = [test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b")];
let server = test_jdk(fixtures.path(), 25, "/custom-server-runtime");
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None);
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
@@ -606,7 +715,7 @@ mod tests {
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
}])
);
assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime");
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
}
#[test]
@@ -619,15 +728,16 @@ mod tests {
#[test]
fn jdtls_workspace_and_runtimes() {
let fixtures = tempfile::tempdir().unwrap();
if cfg!(windows) {
return; // Unix paths and shells
}
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
let jdks = [
Jdk { major: 25, home: PathBuf::from("/jdk25") },
Jdk { major: 21, home: PathBuf::from("/jdk21a") },
Jdk { major: 21, home: PathBuf::from("/jdk21b") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
test_jdk(fixtures.path(), 25, "/jdk25"),
test_jdk(fixtures.path(), 21, "/jdk21a"),
test_jdk(fixtures.path(), 21, "/jdk21b"),
test_jdk(fixtures.path(), 8, "/jdk8"),
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(
@@ -647,13 +757,22 @@ mod tests {
assert_eq!(cfg.args[n], "-data");
assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]);
assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
assert!(
cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"),
"jars JDK 25 rejects must still load: {:?}",
cfg.args
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25", "default": true }));
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls");
assert_eq!(
cfg.env["JAVA_HOME"],
fixtures.path().join("jdk25").to_string_lossy(),
"first JDK new enough to run jdtls"
);
assert_eq!(cfg.launch_error, None);
// Deterministic (reconfigure compares configs) and idempotent.
@@ -672,7 +791,7 @@ mod tests {
assert_eq!(cfg, before);
// The project JDK is the default runtime, and runs the server when new enough.
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
let project = test_jdk(fixtures.path(), 21, "/jdk21b");
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
configure_jdtls(
&mut cfg,
@@ -687,8 +806,8 @@ mod tests {
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(
@@ -705,6 +824,54 @@ mod tests {
assert!(other.args.is_empty() && other.initialization_options.is_none());
}
#[test]
fn jdtls_proxy_properties_respect_exact_user_overrides() {
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
let proxy =
ProxySettings { mode: ProxyMode::Manual, host: "ide.proxy".into(), port: 3128, ..Default::default() };
for (user_arg, overridden) in [
("--jvm-arg=-Dhttp.proxyHost=user.proxy", true),
("--jvm-arg=-Dhttp.proxyHost", true),
("--jvm-arg=-Dhttp.proxyHostSuffix=unrelated", false),
("--jvm-arg=-Dother=-Dhttp.proxyHost", false),
("/workspace/-Dhttp.proxyHost", false),
] {
let mut forms = vec![vec![user_arg.to_owned()]];
if let Some(value) = user_arg.strip_prefix("--jvm-arg=") {
forms.push(vec!["--jvm-arg".into(), value.into()]);
}
for mut args in forms {
let user_args = args.clone();
add_jdtls_proxy_args(&mut args, &proxy);
assert_eq!(args[..user_args.len()], user_args);
assert_eq!(args.contains(&"--jvm-arg=-Dhttp.proxyHost=ide.proxy".into()), !overridden, "{user_arg}");
assert!(args.contains(&"--jvm-arg=-Dhttps.proxyHost=ide.proxy".into()));
let before = args.clone();
add_jdtls_proxy_args(&mut args, &proxy);
assert_eq!(args, before, "idempotent: {user_arg}");
}
}
}
#[test]
fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() {
// aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the
// JVM that runs jdtls that is a build path error in every project using it.
let mut args = Vec::new();
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]);
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args.len(), 1, "added once");
for user in [
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(),
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(),
] {
let mut own = vec![user.clone()];
set_jvm_property(&mut own, ZIP64_LENIENCY);
assert_eq!(own, [user], "the user decides");
}
}
#[test]
fn jdtls_lombok_agent() {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
@@ -747,8 +914,9 @@ mod tests {
#[test]
fn jdtls_needs_a_new_enough_jdk_to_run() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
let old = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8")];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert!(!cfg.env.contains_key("JAVA_HOME"));
@@ -767,7 +935,7 @@ mod tests {
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
let mixed = [old[0].clone(), test_jdk(fixtures.path(), 21, "/jdk21")];
assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21);
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
@@ -780,11 +948,12 @@ mod tests {
#[test]
fn issue_49_jdtls_needs_25_with_8_17_21_installed() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let installed = [
Jdk { major: 21, home: PathBuf::from("/jdk21") },
Jdk { major: 17, home: PathBuf::from("/jdk17") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
test_jdk(fixtures.path(), 21, "/jdk21"),
test_jdk(fixtures.path(), 17, "/jdk17"),
test_jdk(fixtures.path(), 8, "/jdk8"),
];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
@@ -793,12 +962,11 @@ mod tests {
assert!(err.contains("Download JDK 25"), "{err}");
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
let downloaded =
[installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat();
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
assert_eq!(cfg.launch_error, None);
assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
}
#[test]
@@ -835,8 +1003,9 @@ mod tests {
#[test]
fn jdtls_runs_on_the_chosen_server_jdk() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) };
let jdk = |major: u32, home: &str| test_jdk(fixtures.path(), major, home);
let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")];
let project = jdk(17, "/jdk17");
let run = |server: Option<&Jdk>, min_java: u32| {
@@ -857,24 +1026,35 @@ mod tests {
// Project on JDK 17, server on the chosen 21: each keeps its own JDK.
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None));
assert_eq!(
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
);
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17");
assert_eq!(
rt[0],
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
"still compiles on 17"
);
// Not set: the first installed JDK new enough, as before.
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25");
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
// A chosen JDK outside the scanned locations.
let own = jdk(25, "/opt/own-jdk");
let cfg = run(Some(&own), 25);
assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk");
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
// Too old for this jdtls: the user's choice is not overridden, it is explained.
let cfg = run(Some(&jdk(21, "/jdk21")), 25);
let old = jdk(21, "/jdk21");
let cfg = run(Some(&old), 25);
assert!(!cfg.env.contains_key("JAVA_HOME"));
let err = cfg.launch_error.unwrap();
assert!(err.contains("/jdk21") && err.contains("JDK 25 or newer"), "{err}");
// The message embeds jdk.home as written to disk, so match that instead of the
// "/jdk21" literal: its separators are platform-native since test_jdk
// materializes real directories.
assert!(err.contains(old.home.to_string_lossy().as_ref()) && err.contains("JDK 25 or newer"), "{err}");
assert!(err.contains(SERVER_JDK_SETTING), "both messages point at the same setting: {err}");
}
+19 -1
View File
@@ -249,6 +249,10 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
}),
};
let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return };
if !ide_core::java_runtime::supports_java_bundles(&jdk.home) {
cfg.launch_error = Some(crate::java::server_jdk_incomplete_message(&jdk, crate::java::jdtls_min_java(cfg)));
return;
}
cfg.args = java_args(&install, jdk.major, &cfg.args);
cfg.command = java.to_string_lossy().into_owned();
}
@@ -465,7 +469,12 @@ mod tests {
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let jdk = dir.path().join("jdk-25");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(
jdk.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(jdk.join("bin/javac"), "").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
@@ -486,6 +495,15 @@ mod tests {
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
let jre = dir.path().join("jre-25");
std::fs::create_dir_all(jre.join("bin")).unwrap();
std::fs::write(jre.join("bin/java"), "").unwrap();
std::fs::write(jre.join("release"), "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\n").unwrap();
let mut overridden = cfg("jdtls");
overridden.args.push(format!("--java-executable={}", jre.join("bin/java").display()));
launch_directly(&mut overridden);
assert!(overridden.launch_error.unwrap().contains("jdk.jdi"));
// Someone's own wrapper, or no usable JDK: the command is kept.
std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
+18 -2
View File
@@ -166,7 +166,12 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
std::fs::write(
dir.join("release"),
"JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap();
}
#[test]
@@ -307,9 +312,20 @@ fn jdtls_jdk_setting() {
assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None));
s.languages.java.jdtls_jdk = "jdk17/bin".into();
assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17));
std::fs::write(
tmp.path().join("jdk17/release"),
"JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK"));
// A chosen path that is not a JDK is an error, not a silent fallback.
s.languages.java.jdtls_jdk = "missing".into();
std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap();
s.languages.java.jdtls_jdk = "not-a-jdk".into();
assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home"));
// A JDK removed after it was chosen (a version manager upgraded it) says so.
s.languages.java.jdtls_jdk = "jdk17.0.1".into();
let err = jdtls_jdk(tmp.path(), &s).unwrap_err();
assert!(err.contains("jdk17.0.1 does not exist") && err.contains("Languages & Frameworks › Java"), "{err}");
}
#[test]
+19 -1
View File
@@ -53,6 +53,8 @@ pub struct TerminalInfo {
}
struct Session {
/// The window that opened it: closing that window's project closes its terminals.
key: String,
info: TerminalInfo,
master: Mutex<Box<dyn MasterPty + Send>>,
writer: Mutex<Box<dyn Write + Send>>,
@@ -83,6 +85,7 @@ impl TerminalManager {
pub fn spawn(
&self,
options: TerminalOptions,
key: String,
sink: SharedSink,
on_output: Box<dyn Fn(String) + Send>,
) -> IdeResult<TerminalInfo> {
@@ -121,6 +124,7 @@ impl TerminalManager {
let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() };
let exited = Arc::new(AtomicBool::new(false));
let session = Arc::new(Session {
key,
info: info.clone(),
master: Mutex::new(pair.master),
writer: Mutex::new(writer),
@@ -160,6 +164,15 @@ impl TerminalManager {
.map_err(|e| IdeError::internal(format!("resize failed: {e}")))
}
/// Closes the terminals `key` opened: the project of that window is closing.
pub fn kill_key(&self, key: &str) {
let ids: Vec<TerminalId> =
self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect();
for id in ids {
let _ = self.kill(id);
}
}
pub fn kill(&self, id: TerminalId) -> IdeResult<()> {
let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?;
terminate(&session);
@@ -178,6 +191,10 @@ impl TerminalManager {
let Some(pid) = session.info.pid else { return Ok(false) };
Ok(foreground_busy(&session, pid))
}
/// Whether a terminal session is still registered.
pub fn contains(&self, id: TerminalId) -> bool {
self.sessions.lock().contains_key(&id)
}
pub fn list(&self) -> Vec<TerminalInfo> {
let mut out: Vec<TerminalInfo> = self.sessions.lock().values().map(|s| s.info.clone()).collect();
@@ -632,6 +649,7 @@ mod tests {
.manager
.spawn(
opts,
"test".into(),
h.sink.clone(),
Box::new(move |s| {
let _ = tx.send(s);
@@ -789,7 +807,7 @@ mod tests {
let h = harness();
let (other_sink, other_events) = channel_sink();
let (mine, _rx) = spawn_collect(&h, sh("exit 1"));
let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn");
let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn");
match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))),
e => panic!("unexpected {e:?}"),
+3 -1
View File
@@ -21,7 +21,8 @@ pub enum ArchiveKind {
impl ArchiveKind {
pub fn from_name(name: &str) -> Self {
let n = name.to_ascii_lowercase();
if n.ends_with(".zip") || n.ends_with(".vsix") {
// JetBrains' macOS .sit distributions are zip archives.
if n.ends_with(".zip") || n.ends_with(".vsix") || n.ends_with(".sit") {
Self::Zip
} else if n.ends_with(".tar.gz") || n.ends_with(".tgz") {
Self::TarGz
@@ -393,6 +394,7 @@ mod tests {
fn kinds() {
assert_eq!(ArchiveKind::from_name("a.vsix"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("a.ZIP"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("kotlin-server-263.7084.0-aarch64.sit"), ArchiveKind::Zip);
assert_eq!(ArchiveKind::from_name("a.tar.gz"), ArchiveKind::TarGz);
assert_eq!(ArchiveKind::from_name("a.tgz"), ArchiveKind::TarGz);
assert_eq!(ArchiveKind::from_name("zls.tar.xz"), ArchiveKind::TarXz);
+96 -38
View File
@@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
}
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
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"))?)
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
let bin = json["bin"]
.as_str()
.or_else(|| json["bin"]["prettier"].as_str())
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
.or_else(|| json["bin"][tool].as_str())
.ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?;
let path = package.join(bin);
if !path.is_file() {
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display())));
}
Ok(path)
}
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
tool == "prettier" && config.range
}
impl Tools {
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
@@ -34,22 +44,26 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if tool != "prettier" {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
let manifest = package.join("package.json");
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
(package_bin(custom)?, true)
(package_bin(custom, tool)?, true)
} else if let Some(custom) = custom {
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
(package_bin(manifest.parent().unwrap())?, true)
} else if self.root.join("node_modules/prettier/package.json").is_file() {
(package_bin(&self.root.join("node_modules/prettier"))?, true)
} else if let Some(manifest) = find_up(file, &manifest) {
(package_bin(manifest.parent().unwrap(), tool)?, true)
} else if self.root.join(&manifest).is_file() {
(package_bin(&self.root.join(&package), tool)?, true)
} else {
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
(self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false)
};
if node {
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
let name = display_name(tool);
let node =
self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?;
let mut cmd = self.command(node);
cmd.arg(path);
Ok(cmd)
@@ -59,34 +73,40 @@ impl Tools {
}
}
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
let mut args: Vec<OsString> = [
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.into_iter()
.map(Into::into)
.collect();
let mut args: Vec<OsString> = Vec::new();
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
args.extend(
[
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.map(OsString::from),
);
}
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if config.range
if supports_range(tool, config)
&& let Some((start, end)) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
@@ -100,8 +120,8 @@ pub fn arguments(
args
}
pub fn run(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("prettier"))
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))
}
#[cfg(test)]
@@ -118,7 +138,7 @@ mod tests {
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
let failure = run(custom, "text", &Default::default()).unwrap_err();
let failure = run("prettier", custom, "text", &Default::default()).unwrap_err();
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -127,7 +147,7 @@ mod tests {
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
let mut command = Command::new("sh");
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
let failure = run(command, "", &Default::default()).unwrap_err();
let failure = run("prettier", command, "", &Default::default()).unwrap_err();
assert_eq!(failure.message, "error\n".repeat(1000));
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -161,7 +181,8 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -171,4 +192,41 @@ mod tests {
assert!(!args.contains(&"--write".into()));
}
}
#[test]
fn oxfmt_runs_the_nearest_package_without_prettier_flags() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap();
std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap();
std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap();
std::fs::write(root.join(".prettierignore"), "dist").unwrap();
let file = root.join("packages/web/src/app.ts");
let path = std::env::var_os("PATH").unwrap_or_default();
let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current());
let command = tools.formatter_command("oxfmt", &file, None).unwrap();
let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned();
assert_eq!(program, "node");
let script: Vec<_> = command.get_args().collect();
// Compare components: `package_bin` joins the manifest's `bin` entry
// (`bin/oxfmt`) onto a platform path, so separators differ on Windows.
assert_eq!(
Path::new(&script[0]).components().collect::<Vec<_>>(),
root.join("node_modules/oxfmt/bin/oxfmt").components().collect::<Vec<_>>()
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore]
);
assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}");
assert!(!supports_range("oxfmt", &config));
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
}
+57 -49
View File
@@ -108,26 +108,7 @@ fn adoptium_arch(arch: Arch) -> &'static str {
/// The latest Temurin JDK build of Java `feature` for `platform`.
pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String {
assets_url(feature, platform, ImageType::Jdk)
}
#[derive(Clone, Copy)]
enum ImageType {
Jdk,
Jre,
}
impl ImageType {
fn name(self) -> &'static str {
match self {
Self::Jdk => "jdk",
Self::Jre => "jre",
}
}
}
fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String {
let image = image.name();
let image = "jdk";
format!(
"{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse",
adoptium_arch(platform.arch),
@@ -190,16 +171,12 @@ impl AssetVersion {
/// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium
/// `assets/latest` response; `None` when there is no build for the platform.
pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult<Option<(Download, u32)>> {
pick_image(json, platform, ImageType::Jdk)
}
fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult<Option<(Download, u32)>> {
let assets: Vec<Asset> =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" };
let os = adoptium_os(platform.os);
let found = assets.into_iter().find(|a| {
a.binary.image_type.as_deref().is_none_or(|t| t == image.name())
a.binary.image_type.as_deref().is_none_or(|t| t == "jdk")
&& a.binary.heap_size.as_deref().is_none_or(|h| h == "normal")
&& a.binary.os.as_deref().is_none_or(|o| o == os)
&& a.binary.package.name.ends_with(ext)
@@ -249,6 +226,10 @@ pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option<PathBuf> {
subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform))
}
fn usable_java_home(dir: &Path, platform: Platform, runtime: bool) -> Option<PathBuf> {
find_jdk_home(dir, platform).filter(|home| !runtime || ide_core::java_runtime::supports_java_bundles(home))
}
/// The directory to move into place: the archive's single top-level folder
/// holding the JDK, else everything that was unpacked.
fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf {
@@ -283,8 +264,8 @@ impl Tools {
self.download_java(vendor, feature, location, false, cancel, emit)
}
/// Downloads a runtime for a language server, preferring a JRE. Only a
/// platform without a JRE build falls back to a full JDK.
/// Downloads a full JDK for a language server and its bundles (java-debug
/// needs `jdk.jdi`). Kept outside project JDK discovery roots.
pub fn download_java_runtime(
&self,
feature: u32,
@@ -310,19 +291,8 @@ impl Tools {
let http = self.http()?;
let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks"));
emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") });
let mut picked = if runtime {
pick_image(
&http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?,
self.platform,
ImageType::Jre,
)?
} else {
None
};
if picked.is_none() {
picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
}
let mut picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
if picked.is_none() && matches!(self.platform.os, Os::Mac | Os::Windows) && self.platform.arch == Arch::Arm64 {
// Older JDKs may need the OS-provided x64 emulation.
let intel = Platform { os: self.platform.os, arch: Arch::X64 };
@@ -336,7 +306,7 @@ impl Tools {
})?;
let target = location.join(jdk_dir_name(vendor, &dl.version));
let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major };
if let Some(home) = find_jdk_home(&target, self.platform) {
if let Some(home) = usable_java_home(&target, self.platform, runtime) {
emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) });
return Ok(done(home));
}
@@ -351,7 +321,7 @@ impl Tools {
emit(InstallEvent::Line { text: "Extracting…".into() });
let pkg = staging.join("pkg");
archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java", cancel)?;
let home = find_jdk_home(&pkg, self.platform)
let home = usable_java_home(&pkg, self.platform, runtime)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?;
// Last point to stop: from here the JDK replaces what is at `target`.
if cancel.is_cancelled() {
@@ -362,7 +332,7 @@ impl Tools {
}
std::fs::rename(unpacked_root(&pkg, &home), &target)?;
clear_quarantine(&target);
find_jdk_home(&target, self.platform)
usable_java_home(&target, self.platform, runtime)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display())))
})();
let _ = std::fs::remove_dir_all(&staging);
@@ -437,18 +407,56 @@ mod tests {
}
#[test]
fn runtime_assets_use_jre_without_changing_jdk_downloads() {
fn runtime_assets_require_jdk_images() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre"));
assert!(adoptium_assets_url(25, platform).contains("image_type=jdk"));
let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" };
let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", "");
let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\"");
assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none());
assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("offline", platform, ImageType::Jre).is_err());
}
}
#[test]
fn stale_runtime_is_replaced_even_at_the_same_version() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(jdk_dir_name(TEMURIN, "25.0.1"));
let home = if platform.os == Os::Mac { target.join("Contents/Home") } else { target.clone() };
fake_jdk(&home, platform);
let javac = home.join("bin").join(if cfg!(windows) { "javac.exe" } else { "javac" });
std::fs::write(&javac, "").unwrap();
for modules in [None, Some("java.base java.compiler jdk.jdix"), Some("java.base jdk.jdi.agent")] {
let line = modules.map(|m| format!("MODULES=\"{m}\"\n")).unwrap_or_default();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"25.0.1\"\n{line}")).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "{modules:?}");
assert_eq!(usable_java_home(&target, platform, false), Some(home.clone()));
}
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::create_dir_all(home.join("jmods")).unwrap();
for module in ["jdk.compiler", "jdk.jdi", "jdk.unsupported"] {
std::fs::write(home.join("jmods").join(format!("{module}.jmod")), "").unwrap();
}
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(
home.join("release"),
"IMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "Temurin JRE release can list absent modules");
std::fs::write(home.join("release"), "MODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n")
.unwrap();
std::fs::remove_file(javac).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "custom runtime without compiler");
std::fs::write(home.join("release"), "MODULES=\"java.base\"\n").unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "release describes the runtime image");
}
}
+85 -10
View File
@@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[
needs: NODE,
homepage: "https://prettier.io",
},
ToolSpec {
name: "oxfmt",
display: "oxfmt",
kind: Formatter,
commands: &["oxfmt"],
method: Method::Npm { packages: &["oxfmt"] },
fallback: None,
version: ver("oxfmt"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/formatter",
},
ToolSpec {
name: "oxlint",
display: "oxlint",
kind: LanguageServer,
commands: &["oxlint"],
method: Method::Npm { packages: &["oxlint"] },
fallback: None,
version: ver("oxlint"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/linter",
},
ToolSpec {
name: "rust-analyzer",
display: "rust-analyzer",
@@ -614,15 +636,25 @@ pub static TOOLS: &[ToolSpec] = &[
method: Method::Release {
source: ReleaseSource::GitHub { repo: "Kotlin/kotlin-lsp" },
assets: &[
// Current releases (kotlin-server-*): the CLI distribution is a
// .sit on macOS, .tar.gz on Linux and .win.zip on Windows. x64 ships as the
// un-suffixed archive, aarch64 as `-aarch64`; the un-suffixed one
// comes first in the release notes, which is what the x64 pattern
// matches first. Without a matching rule the installer used to fall
// through to a loose `kotlin-*.zip` pattern and POSIX picked the
// Windows archive (#723).
rule(Mac, Arm64, "kotlin-server-*-aarch64.sit"),
rule(Mac, X64, "kotlin-server-*.sit"),
rule(Linux, Arm64, "kotlin-server-*-aarch64.tar.gz"),
rule(Linux, X64, "kotlin-server-*.tar.gz"),
rule(Windows, X64, "kotlin-server-*.win.zip"),
rule(Windows, Arm64, "kotlin-server-*-aarch64.win.zip"),
// Old releases named the archives kotlin-lsp-*.
rule(Mac, Arm64, "kotlin-lsp-*-mac-aarch64.zip"),
rule(Mac, X64, "kotlin-lsp-*-mac-x64.zip"),
rule(Linux, X64, "kotlin-lsp-*-linux-x64.zip"),
rule(Linux, Arm64, "kotlin-lsp-*-linux-aarch64.zip"),
rule(Windows, X64, "kotlin-lsp-*-win-x64.zip"),
// Older releases ship one platform-independent archive.
any_arch(Mac, "kotlin-*.zip"),
any_arch(Linux, "kotlin-*.zip"),
any_arch(Windows, "kotlin-*.zip"),
],
locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"],
},
@@ -955,12 +987,8 @@ mod tests {
known.insert((d.adapter.clone(), d.command.clone()));
}
}
for l in &langs {
if let Some(f) = &l.formatter {
known.insert((f.tool.clone(), f.tool.clone()));
}
}
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
// The host selects the formatter of each file (Prettier or oxfmt).
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
assert!(
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
"{} / {} is not referenced by languages/*.toml",
@@ -988,4 +1016,51 @@ mod tests {
}
}
}
/// The v263 kotlin-server-* release notes: the CLI distribution archives plus
/// the VS Code extension and checksums; each platform must pick its native
/// distribution and the aarch64 archive must never be picked on x64.
#[test]
fn kotlin_lsp_picks_the_native_archive_for_each_platform() {
use crate::platform::{Platform, select_asset};
let names = [
"kotlin-server-0.0.27-mac-amd64.vsix",
"kotlin-server-0.0.27-mac-amd64.vsix.sha256",
"kotlin-server-0.0.27-mac-aarch64.vsix",
"kotlin-server-0.0.27-mac-aarch64.vsix.sha256",
"kotlin-server-0.0.27-linux-amd64.vsix",
"kotlin-server-0.0.27-linux-amd64.vsix.sha256",
"kotlin-server-0.0.27-linux-aarch64.vsix",
"kotlin-server-0.0.27-linux-aarch64.vsix.sha256",
"kotlin-server-0.0.27-win-amd64.vsix",
"kotlin-server-0.0.27-win-amd64.vsix.sha256",
"kotlin-server-0.0.27-win-aarch64.vsix",
"kotlin-server-0.0.27-win-aarch64.vsix.sha256",
"kotlin-server-263.7084.0.sit",
"kotlin-server-263.7084.0.sit.sha256",
"kotlin-server-263.7084.0-aarch64.sit",
"kotlin-server-263.7084.0-aarch64.sit.sha256",
"kotlin-server-263.7084.0.tar.gz",
"kotlin-server-263.7084.0.tar.gz.sha256",
"kotlin-server-263.7084.0-aarch64.tar.gz",
"kotlin-server-263.7084.0-aarch64.tar.gz.sha256",
"kotlin-server-263.7084.0.win.zip",
"kotlin-server-263.7084.0.win.zip.sha256",
"kotlin-server-263.7084.0-aarch64.win.zip",
"kotlin-server-263.7084.0-aarch64.win.zip.sha256",
];
let rules = find("kotlin-lsp").unwrap().asset_rules();
let mac_arm = Platform { os: Mac, arch: Arm64 };
assert_eq!(select_asset(&names, rules, mac_arm), Some("kotlin-server-263.7084.0-aarch64.sit"));
let mac_x64 = Platform { os: Mac, arch: X64 };
assert_eq!(select_asset(&names, rules, mac_x64), Some("kotlin-server-263.7084.0.sit"));
let linux_x64 = Platform { os: Linux, arch: X64 };
assert_eq!(select_asset(&names, rules, linux_x64), Some("kotlin-server-263.7084.0.tar.gz"));
let linux_arm = Platform { os: Linux, arch: Arm64 };
assert_eq!(select_asset(&names, rules, linux_arm), Some("kotlin-server-263.7084.0-aarch64.tar.gz"));
let win_x64 = Platform { os: Windows, arch: X64 };
assert_eq!(select_asset(&names, rules, win_x64), Some("kotlin-server-263.7084.0.win.zip"));
let win_arm = Platform { os: Windows, arch: Arm64 };
assert_eq!(select_asset(&names, rules, win_arm), Some("kotlin-server-263.7084.0-aarch64.win.zip"));
}
}
+2 -2
View File
@@ -19,8 +19,8 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments(&config, path, 4, true, range));
formatter::run(cmd, text, &CancellationToken::new())
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
}
#[test]
+22 -9
View File
@@ -89,15 +89,28 @@ pub(crate) fn blame(
return Ok(uncommitted_lines(line_count));
}
// Restricted blame reads committed blobs only. Feeding working-tree bytes to
// Git's --contents path would run configured clean filters.
let out = if crate::execution_allowed() {
run(
r.root(),
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
Some(&contents),
)?
// Git's --contents path would run configured clean filters, so it blames the
// revision plain. Git learned to combine `--contents` with a revision in 2.41;
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
} else {
run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)?
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), plain_args, None)?, false)
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -106,7 +119,7 @@ pub(crate) fn blame(
line.summary = String::new();
}
}
if !crate::execution_allowed() {
if !contents_blame {
let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default();
for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() {
let start = (hunk.old_start as usize).min(lines.len());
+1
View File
@@ -15,6 +15,7 @@ serde_json.workspace = true
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
semver = "1.0.28"
parking_lot.workspace = true
blake3.workspace = true
tracing.workspace = true
+47 -9
View File
@@ -23,16 +23,26 @@ pub struct InvalidateCachesOptions {
const MARKER: &str = "invalidate-caches.json";
/// Data-directory folders removed for `options`.
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![data_dir.join("index")];
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![local_data_dir.join("index")];
if options.local_history {
dirs.push(data_dir.join("history"));
}
if options.language_servers {
dirs.push(data_dir.join("lsp"));
dirs.push(local_data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
dirs.push(local_data_dir.join("cache"));
}
// Also invalidate pre-migration caches so a later start cannot revive them.
if data_dir != local_data_dir {
dirs.push(data_dir.join("index"));
if options.language_servers {
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
}
}
dirs
}
@@ -57,11 +67,11 @@ pub fn cancel(data_dir: &Path) {
/// Carries out a scheduled invalidation (call at start-up, before projects
/// open). Returns the folders removed.
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
let Some(options) = pending(data_dir) else { return Vec::new() };
cancel(data_dir);
let mut removed = Vec::new();
for dir in cache_dirs(data_dir, &options) {
for dir in cache_dirs(data_dir, local_data_dir, &options) {
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -75,6 +85,34 @@ pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
mod tests {
use super::*;
#[test]
fn split_roots_invalidate_local_caches_and_roaming_history() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true };
assert_eq!(
cache_dirs(&data, &local, &options),
vec![
local.join("index"),
data.join("history"),
local.join("lsp"),
local.join("cache"),
data.join("index"),
data.join("lsp"),
data.join("cache")
]
);
for path in cache_dirs(&data, &local, &options) {
fs::create_dir_all(path).unwrap();
}
fs::create_dir_all(local.join("tools")).unwrap();
schedule(&data, &options).unwrap();
assert_eq!(apply_pending(&data, &local).len(), 7);
assert!(local.join("tools").exists());
assert_eq!(pending(&data), None);
}
#[test]
fn scheduled_invalidation_runs_once() {
let tmp = tempfile::tempdir().unwrap();
@@ -82,16 +120,16 @@ mod tests {
for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] {
fs::create_dir_all(data.join(d)).unwrap();
}
assert!(apply_pending(data).is_empty());
assert!(apply_pending(data, data).is_empty());
let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true };
schedule(data, &opts).unwrap();
assert_eq!(pending(data), Some(opts));
let removed = apply_pending(data);
let removed = apply_pending(data, data);
assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]);
assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists());
assert_eq!(pending(data), None);
assert!(apply_pending(data).is_empty());
assert!(apply_pending(data, data).is_empty());
schedule(data, &InvalidateCachesOptions::default()).unwrap();
cancel(data);
@@ -0,0 +1,102 @@
use super::DependencyVersionSource;
use ide_core::{CancellationToken, IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const TTL: Duration = Duration::from_secs(3600);
const FAILURE_TTL: Duration = Duration::from_secs(30);
const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024;
type Slot = Arc<Mutex<Option<Cached>>>;
#[derive(Clone, Serialize, Deserialize)]
struct Cached {
fetched: u64,
versions: Vec<String>,
#[serde(skip)]
failed: bool,
}
/// Same-package lookups share one lock and fetch. Different packages don't block
/// each other; waiting callers can cancel independently of the active fetch.
pub struct VersionCache {
directory: PathBuf,
entries: Mutex<HashMap<String, Slot>>,
ttl: Duration,
}
impl VersionCache {
pub fn new(directory: PathBuf) -> Self {
Self { directory, entries: Mutex::default(), ttl: TTL }
}
pub fn versions(
&self,
source: &dyn DependencyVersionSource,
package: &str,
cancel: &CancellationToken,
) -> IdeResult<Vec<String>> {
let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b));
if !safe(source.id()) || !safe(package) || package.len() > 64 {
return Ok(Vec::new());
}
let key = format!("{}-{}", source.id(), package.to_ascii_lowercase());
let slot = self.entries.lock().entry(key.clone()).or_default().clone();
let mut cached = loop {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) {
break guard;
}
};
let checked_at = now();
let fresh = |c: &Cached| {
c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs()
};
if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) {
return Ok(entry.versions.clone());
}
let path = self.directory.join(format!("{key}.json"));
let disk = std::fs::metadata(&path)
.ok()
.filter(|m| m.len() <= MAX_DISK_BYTES)
.and_then(|_| std::fs::read(&path).ok())
.and_then(|bytes| serde_json::from_slice::<Cached>(&bytes).ok())
.filter(fresh);
if let Some(entry) = disk {
let versions = entry.versions.clone();
*cached = Some(entry);
return Ok(versions);
}
let result = source.fetch(package, cancel);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let failed = result.is_err();
let versions = result.unwrap_or_default();
let entry = Cached { fetched: now(), versions: versions.clone(), failed };
if !failed
&& let Ok(bytes) = serde_json::to_vec(&entry)
&& std::fs::create_dir_all(&self.directory).is_ok()
{
let temporary = path.with_extension("tmp");
if std::fs::write(&temporary, bytes).is_ok() {
let _ = std::fs::rename(&temporary, &path);
let _ = std::fs::remove_file(&temporary);
}
}
*cached = Some(entry);
Ok(versions)
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
#[cfg(test)]
mod tests;
+97
View File
@@ -0,0 +1,97 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct Source {
calls: AtomicUsize,
fail: bool,
}
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"test"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(20));
if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) }
}
}
fn source(fail: bool) -> Source {
Source { calls: AtomicUsize::new(0), fail }
}
#[test]
fn memory_disk_ttl_corruption_and_failed_cache_writes() {
let dir = tempfile::tempdir().unwrap();
let s = source(false);
let cancel = CancellationToken::new();
let cache = VersionCache::new(dir.path().into());
for _ in 0..2 {
assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
}
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart");
let mut expired = VersionCache::new(dir.path().into());
expired.ttl = Duration::ZERO;
expired.versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap();
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 3);
let file = dir.path().join("not-a-directory");
std::fs::write(&file, "x").unwrap();
assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
}
#[test]
fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]));
}
});
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
let s = source(true);
for _ in 0..2 {
assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty());
}
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted");
let slot = cache.entries.lock().get("test-offline").unwrap().clone();
slot.lock().as_mut().unwrap().fetched -= 31;
cache.versions(&s, "offline", &CancellationToken::new()).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
}
#[test]
fn cancellation_and_invalid_cache_keys_do_not_fetch() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
let token = CancellationToken::new();
token.cancel();
assert!(cache.versions(&s, "foo", &token).is_err());
assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty());
assert_eq!(s.calls.load(Ordering::SeqCst), 0);
}
#[test]
fn cancelled_waiter_does_not_cancel_the_shared_fetch() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
let slot = Arc::new(Mutex::new(None));
cache.entries.lock().insert("test-foo".into(), slot.clone());
let guard = slot.lock();
let token = CancellationToken::new();
std::thread::scope(|scope| {
let waiter = scope.spawn(|| cache.versions(&s, "foo", &token));
token.cancel();
assert!(waiter.join().unwrap().is_err());
});
drop(guard);
assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]);
}
@@ -0,0 +1,150 @@
use super::{DependencyUpdate, ManifestDependency};
use semver::{Op, Version, VersionReq};
use serde::Deserialize;
use toml_edit::{Document, Item, TableLike};
/// Cargo's sparse-index path, including the special cases for short names.
pub fn index_path(name: &str) -> Option<String> {
if name.is_empty()
|| name.len() > 64
|| !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))
|| !name.as_bytes()[0].is_ascii_alphabetic()
{
return None;
}
let name = name.to_ascii_lowercase();
Some(match name.len() {
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{name}", &name[..1]),
_ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
})
}
pub fn parse_index(text: &str) -> Option<Vec<String>> {
#[derive(Deserialize)]
struct Entry {
vers: String,
#[serde(default)]
yanked: bool,
}
let mut versions = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let entry: Entry = serde_json::from_str(line).ok()?;
if !entry.yanked {
versions.push(Version::parse(&entry.vers).ok()?);
}
}
versions.sort_unstable_by(|a, b| b.cmp(a));
versions.dedup();
Some(versions.into_iter().map(|v| v.to_string()).collect())
}
/// A complete TOML parser supplies spans, so strings/comments that resemble tables
/// never become dependencies. Broken documents are silent until they parse again.
pub fn dependencies(text: &str) -> Vec<ManifestDependency> {
let Ok(doc) = text.parse::<Document<String>>() else { return Vec::new() };
let mut out = Vec::new();
sections(doc.as_table(), text, &mut out);
if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like)
&& let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like)
{
collect(deps, text, &mut out);
}
if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
for (_, target) in targets.iter() {
if let Some(target) = target.as_table_like() {
sections(target, text, &mut out);
}
}
}
out.sort_unstable_by_key(|d| d.from);
out
}
fn sections(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = table.get(section).and_then(Item::as_table_like) {
collect(deps, text, out);
}
}
}
fn collect(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
for (name, item) in table.iter() {
let (package, value) = if let Some(dep) = item.as_table_like() {
if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k))
|| dep.get("workspace").and_then(Item::as_bool) == Some(true)
{
continue;
}
let Some(version) = dep.get("version") else { continue };
(dep.get("package").and_then(Item::as_str).unwrap_or(name), version)
} else {
(name, item)
};
if index_path(package).is_none() {
continue;
}
let Some(requirement) = value.as_str() else { continue };
let Some(span) = value.span() else { continue };
let Some(raw) = text.get(span.clone()) else { continue };
// Spans cover the encoded TOML string. Replace its entire contents,
// including escapes, while retaining the original quote delimiters.
let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 };
out.push(ManifestDependency {
package: package.to_owned(),
requirement: requirement.to_owned(),
from: text[..span.start + delimiter].encode_utf16().count() as u32,
to: text[..span.end - delimiter].encode_utf16().count() as u32,
});
}
}
pub fn update(requirement: &str, versions: &[String]) -> Option<DependencyUpdate> {
let req = VersionReq::parse(requirement).ok()?;
let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty());
let latest =
versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?;
if req.matches(&latest) {
return None;
}
// An unavailable future version isn't an invitation to downgrade.
if req.comparators.iter().any(|c| {
(c.op == Op::Greater
&& c.patch.is_none()
&& (latest.major < c.major
|| (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor))))
|| !matches!(c.op, Op::Less | Op::LessEq)
&& latest
<= Version {
major: c.major,
minor: c.minor.unwrap_or(0),
patch: c.patch.unwrap_or(0),
pre: c.pre.clone(),
build: Default::default(),
}
}) {
return None;
}
let trimmed = requirement.trim();
let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start();
let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()));
let mut replacement = latest.to_string();
if simple && latest.pre.is_empty() {
let precision = digits.split('.').count();
let version = match precision {
1 => latest.major.to_string(),
2 => format!("{}.{}", latest.major, latest.minor),
_ => latest.to_string(),
};
let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]);
if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) {
replacement = candidate;
}
}
Some(DependencyUpdate { latest: latest.to_string(), replacement })
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,143 @@
use super::*;
#[test]
fn sparse_paths_and_input_validation() {
for (name, path) in
[("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")]
{
assert_eq!(index_path(name).as_deref(), Some(path));
}
for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] {
assert!(index_path(name).is_none(), "{name}");
}
}
#[test]
fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() {
let text = r#"{"vers":"1.9.0","yanked":false}
{"vers":"2.0.0-beta.1","yanked":false}
{"vers":"9.0.0","yanked":true}
{"vers":"1.10.0","yanked":false}
{"vers":"1.10.0"}"#;
assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]);
assert!(parse_index("{broken").is_none());
assert!(parse_index(r#"{"vers":"bogus"}"#).is_none());
}
#[test]
fn cargo_requirements_updates_and_precision() {
for (req, latest, expected) in [
("1", "1.9.0", None),
("1.0.100", "1.0.228", None),
("^1.2", "1.9.0", None),
("0.7", "0.7.9", None),
("0.0.1", "0.0.2", Some("0.0.2")),
("1", "2.3.4", Some("2")),
("1.2", "2.3.4", Some("2.3")),
("1.2.3", "2.3.0", Some("2.3.0")),
("1.2.3", "1.3.0", None),
("0.2", "1.3.0", Some("1.3")),
("=1.2.3", "1.3.0", Some("=1.3.0")),
("~1.2", "1.3.0", Some("~1.3")),
("^0.2", "0.3.1", Some("^0.3")),
("1.*", "1.9.0", None),
("*", "2.3.4", None),
(">=1, <2", "2.3.4", Some("2.3.4")),
(">=1, <2", "2.0.0", Some("2.0.0")),
("<1", "1.0.0", Some("1.0.0")),
(">=1, <3", "2.3.4", None),
(">=9", "2.3.4", None),
(">1", "1.9.0", None),
(">1.2", "1.2.9", None),
(">1.2.3", "1.2.3", None),
("=9.1.2", "2.3.4", None),
("bad", "2.3.4", None),
("1.0", "2.0.0-beta.1", None),
("1.0.0-beta.1", "1.0.0-beta.2", None),
("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")),
("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")),
] {
let actual = update(req, &[latest.to_owned()]);
assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}");
if let Some(u) = actual {
assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap()));
}
}
let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()];
assert_eq!(update("1", &versions).unwrap().latest, "2.0.0");
}
#[test]
fn locates_all_cargo_sections_and_only_replaces_version_contents() {
let text = r#"# 中文 😀
[package]
name = "fixture"
version = "1.0.0"
[dependencies]
serde = "1.0.100" # stays
rand = '0.7'
tokio = { version = "0.2", features = ["full"] }
renamed = { package = "real-crate", version = "1.2" }
local = { path = "../local", version = "1" }
git = { git = "https://example.com/repo", version = "1" }
inherited = { workspace = true }
other = { registry = "private", version = "1" }
[dev-dependencies]
check = "1"
[build-dependencies.build]
version = "1.2.3"
[target.'cfg(unix)'.dependencies]
native = "0.1"
[target.'cfg(windows)'.dev-dependencies]
win = { version = "1" }
[target.'cfg(unix)'.build-dependencies]
build-native = "2"
[workspace.dependencies]
shared = { version = "1", features = ["std"] }
[package.metadata.dependencies]
not-a-dependency = "1"
"#;
let deps = dependencies(text);
assert_eq!(
deps.iter().map(|d| d.package.as_str()).collect::<Vec<_>>(),
["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"]
);
let utf16: Vec<_> = text.encode_utf16().collect();
for dep in &deps {
assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement);
}
let tokio = &deps[2];
let mut edited = utf16.clone();
edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16());
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\""));
let rand = &deps[1];
let mut edited = utf16;
edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16());
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'"));
}
#[test]
fn invalid_toml_and_decoy_tables_are_silent() {
assert!(dependencies("[dependencies]\na = \"").is_empty());
assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty());
assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty());
let deps = dependencies("[dependencies]\na = \"\"\n");
assert_eq!(deps.len(), 1, "empty version strings still offer completion");
assert_eq!(deps[0].from, deps[0].to);
}
#[test]
fn replaces_encoded_and_multiline_strings_without_damaging_quotes() {
for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] {
let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n");
let dep = dependencies(&text).pop().unwrap();
assert_eq!(dep.requirement, "0.2");
let mut encoded: Vec<_> = text.encode_utf16().collect();
encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16());
let edited = String::from_utf16(&encoded).unwrap();
assert!(edited.contains("features = [\"full\"]"));
assert_eq!(dependencies(&edited)[0].requirement, "1.10");
assert_eq!(edited.matches('"').count(), text.matches('"').count());
assert_eq!(edited.matches('\'').count(), text.matches('\'').count());
}
}
@@ -0,0 +1,41 @@
//! Manifest dependencies and repository versions, independent of editor and transport.
mod cache;
pub mod cargo;
pub use cache::VersionCache;
use ide_core::{CancellationToken, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
/// A repository adapter; Maven/npm can use the same cache and editor protocol.
pub trait DependencyVersionSource: Send + Sync {
/// A filesystem-safe, stable repository identifier.
fn id(&self) -> &'static str;
/// Non-yanked versions in descending semantic order.
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>>;
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependencyUpdate {
pub latest: String,
pub replacement: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ManifestDependency {
pub package: String,
pub requirement: String,
/// UTF-16 offsets of the string's contents (quotes excluded).
pub from: u32,
pub to: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependencyVersions {
pub dependency: ManifestDependency,
pub versions: Vec<String>,
pub update: Option<DependencyUpdate>,
}
+1
View File
@@ -1,6 +1,7 @@
//! The project model that ties documents, settings and history together.
pub mod caches;
pub mod dependencies;
pub mod devcontainer;
pub mod documents;
pub mod editorconfig;
+69
View File
@@ -2,6 +2,29 @@
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
/// Platform adapters supply the system's tab preference and active project state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabbingPreference {
Always,
InFullScreen,
Manually,
}
pub fn should_group_new_project(
preference: TabbingPreference,
fullscreen: bool,
active_project: bool,
light_edit: bool,
) -> bool {
active_project
&& !light_edit
&& match preference {
TabbingPreference::Always => true,
TabbingPreference::InFullScreen => fullscreen,
TabbingPreference::Manually => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowGroup {
pub members: Vec<String>,
@@ -39,6 +62,13 @@ impl WindowGroups {
Ok(())
}
/// Adds a window to the anchor's group without merging unrelated groups.
pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> {
let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]);
members.push(member.to_owned());
self.merge(members, anchor)
}
pub fn select(&mut self, caller: &str, target: &str) -> IdeResult<WindowGroup> {
let group = self
.groups
@@ -94,6 +124,45 @@ mod tests {
values.iter().map(|s| (*s).into()).collect()
}
#[test]
fn new_project_grouping_follows_system_preference_and_excludes_light_edit() {
for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] {
for fullscreen in [false, true] {
for active_project in [false, true] {
for light_edit in [false, true] {
let expected = matches!(
(preference, fullscreen, active_project, light_edit),
(TabbingPreference::Always, _, true, false)
| (TabbingPreference::InFullScreen, true, true, false)
);
assert_eq!(
should_group_new_project(preference, fullscreen, active_project, light_edit),
expected,
"{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}"
);
}
}
}
}
}
#[test]
fn joining_new_windows_preserves_the_active_group_and_other_groups() {
let mut groups = WindowGroups::default();
groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap();
groups.join("a", "b").unwrap();
assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"]));
let before = groups.select("a", "b").unwrap();
assert_eq!(before.selected, "a");
groups.join("b", "c").unwrap();
let before = groups.select("b", "c").unwrap();
assert_eq!(before.selected, "b");
assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"]));
assert_eq!(groups.group("c").unwrap().selected, "c");
assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"]));
assert_eq!(groups.group("other-a").unwrap().selected, "other-b");
}
#[test]
fn selecting_and_detaching_preserve_the_other_sessions() {
let mut groups = WindowGroups::default();
+84
View File
@@ -0,0 +1,84 @@
import { expect, test, type Page } from "@playwright/test";
/** Mount the real console with enough output to recycle rows while selecting. */
async function consoleOutput(page: Page, follow = false) {
await page.goto("/");
await page.evaluate(async (follow) => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const viewPath = "/src/features/run/ConsoleView.tsx";
const modelPath = "/src/features/run/console.ts";
const react = await import(reactPath);
const dom = await import(domPath);
const { createElement, useState } = react.default ?? react;
const { createRoot } = dom.default ?? dom;
const { ConsoleView } = await import(viewPath);
const { ConsoleModel } = await import(modelPath);
const model = new ConsoleModel();
model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") });
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
document.body.append(host);
function Console() {
const [following, setFollowing] = useState(follow);
return createElement(ConsoleView, {
model,
version: 1,
softWrap: false,
follow: following,
onFollowChange: setFollowing,
cwd: "/",
});
}
createRoot(host).render(createElement(Console));
}, follow);
const log = page.getByRole("log", { name: "Console", exact: true });
await expect(log.locator("[data-index]").first()).toBeVisible();
return log;
}
test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
await start.evaluate((el) => {
const selection = window.getSelection()!;
selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5);
});
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await log.evaluate((el) => {
el.scrollTop = 350 * 20;
});
await expect(log.locator('[data-index="350"]')).toBeVisible();
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await expect(start).toHaveCount(0);
});
test("preserves all lines in a drag selection across virtual windows", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
const box = (await start.boundingBox())!;
await page.mouse.move(box.x + 20, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 });
await log.evaluate((el) => {
el.scrollTop = 280 * 20;
});
const end = log.locator('[data-index="285"]');
await expect(end).toBeVisible();
const endBox = (await end.boundingBox())!;
await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 });
await page.mouse.up();
const text = await page.evaluate(() => window.getSelection()?.toString());
expect(text).toContain("output 206\noutput 207");
expect(text).toContain("output 250\noutput 251");
expect(text).toContain("output 284");
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, openFromTree, test } from "./fixtures";
// Headless Chromium hides scrollbars by default; this test drags one.
test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });
test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n"));
// A match on every line covers the overview lane, as a new file's change mark does.
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find" }).fill("mark");
await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible();
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((s) => (s.scrollTop = 0));
const box = (await scroller.boundingBox())!;
// Over a mark and inside the thumb, which is at the top of the track.
const x = box.x + box.width - 5;
const y = box.y + 12;
await page.mouse.move(x - 40, y);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y + 150, { steps: 10 });
await page.mouse.up();
expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0);
// Away from the thumb the marks take the pointer again.
await page.mouse.move(x, box.y + box.height - 20);
await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/);
});
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from "./fixtures";
const themes = [
{ theme: "light", scheme: "theme" },
{ theme: "dark", scheme: "theme" },
{ theme: "dark-hc", scheme: "theme" },
{ theme: "light", scheme: "dark" },
{ theme: "dark", scheme: "light" },
] as const;
for (const { theme, scheme } of themes) {
test(`usage highlights have readable text with ${theme} theme and ${scheme} scheme`, async ({
workbench: page,
}, info) => {
await page.evaluate(
async ({ theme, scheme }) => {
const themePath = "/src/workbench/theme.ts";
const schemePath = "/src/platform/colorScheme.ts";
const usagesPath = "/src/features/code-insight/usages.ts";
const { applyThemeAttributes } = await import(themePath);
const { schemeCss } = await import(schemePath);
const { useUsagesPopup } = await import(usagesPath);
applyThemeAttributes(theme);
const style = document.createElement("style");
style.textContent = schemeCss(scheme);
document.head.append(style);
const symbol = "trainingSessionFetch";
const texts = [
`export function ${symbol}() {}`,
`import { ${symbol} } from './api';`,
`// Call ${symbol} here`,
`const name = '${symbol}';`,
];
useUsagesPopup.setState({
open: true,
anchor: { x: 100, y: 100 },
symbol,
groups: texts.map((text, index) => ({
path: `/contrast/example-${index}.ts`,
usages: [
{
path: `/contrast/example-${index}.ts`,
line: 0,
col: text.indexOf(symbol),
endCol: text.indexOf(symbol) + symbol.length,
text,
},
],
})),
note: null,
});
},
{ theme, scheme },
);
const popup = page.getByRole("dialog", { name: "Usages", exact: true });
await expect(popup.locator(".tok-comment").first()).toBeVisible();
await expect(popup.locator("mark")).toHaveCount(4);
const ratios = await popup.locator("mark").evaluateAll((marks) => {
const luminance = (color: string) => {
const channels = color
.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
};
return marks.flatMap((mark) => {
const background = luminance(getComputedStyle(mark).backgroundColor);
return [mark, ...mark.querySelectorAll("*")].map((node) => {
const foreground = luminance(getComputedStyle(node).color);
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
});
});
});
for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5);
await popup.screenshot({ path: info.outputPath("usage-highlights.png") });
});
}
+6
View File
@@ -0,0 +1,6 @@
id = "asciidoc"
name = "AsciiDoc"
extensions = ["adoc", "asciidoc"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "AsciiDoc"
+13
View File
@@ -0,0 +1,13 @@
id = "d"
name = "D"
extensions = ["d", "di"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "D"
[[language-servers]]
name = "serve-d"
command = "serve-d"
root-markers = ["dub.json", "dub.sdl", "dub.selections.json"]
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`"
+8
View File
@@ -27,6 +27,14 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
View File
@@ -26,6 +26,14 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
View File
@@ -13,3 +13,11 @@ command = "svelteserver"
args = ["--stdio"]
root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"]
install-hint = "npm i -g svelte-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+8
View File
@@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"]
require-root-marker = true
install-hint = "npm i -g @angular/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
View File
@@ -26,6 +26,14 @@ args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
View File
@@ -22,3 +22,11 @@ command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+1 -1
View File
@@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
[apps.openidea.markers]
project_opened = 'opened .* in window'
+8
View File
@@ -78,3 +78,11 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+17
View File
@@ -0,0 +1,17 @@
//! Typed IPC adapters for manifest dependency versions.
use crate::state::AppState;
use ide_core::{DocId, IdeResult, RequestId};
use ide_workspace::dependencies::DependencyVersions;
use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub async fn dependency_versions(
window: Window,
state: State<'_, AppState>,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await
}
+4
View File
@@ -16,6 +16,7 @@ pub mod clipboard;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod docker;
@@ -190,6 +191,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
dependencies::dependency_versions,
spell::spell_check,
spell::spell_suggest,
search::search_start,
@@ -296,6 +298,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_changelists_save,
vcs::vcs_write_patch,
term::term_spawn,
term::term_store_image,
term::term_write,
term::term_resize,
term::term_kill,
@@ -303,6 +306,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_default_shell,
term::term_available_shells,
run::run_configs,
run::run_dotnet_resolve,
run::run_save_configs,
run::run_templates,
run::run_save_templates,
+11
View File
@@ -8,6 +8,17 @@ use ide_run::{OutputChunk, RunConfig, RunInfo};
use tauri::ipc::Channel;
use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub async fn run_dotnet_resolve(
window: Window,
state: State<'_, AppState>,
config: RunConfig,
debug: bool,
) -> IdeResult<RunConfig> {
ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await
}
#[tauri::command]
#[specta::specta]
pub async fn run_application_build(
+11
View File
@@ -24,6 +24,17 @@ pub async fn term_spawn(
)
}
#[tauri::command]
#[specta::specta]
pub async fn term_store_image(
state: State<'_, AppState>,
id: TerminalId,
data: Vec<u8>,
mime: String,
) -> IdeResult<String> {
ide_host::ops::term::store_image(&state.host, id, data, mime).await
}
#[tauri::command]
#[specta::specta]
pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> {
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more